When no config file exists, ask the user whether to run the interactive wizard or apply sensible defaults immediately. - Add `SetupChoice` enum and `parse_setup_choice_input` (pure, tested) - Add `render_setup_choice_prompt` and `render_default_config_saved` UI helpers - Extract `Config::write_default_config` and `Config::run_wizard` from `ensure_config_exists` for clarity; routing now driven by the user's choice - 9 new tests covering all branches of `parse_setup_choice_input` and the default-config write path
1017 lines
33 KiB
Rust
1017 lines
33 KiB
Rust
use crate::session::Window;
|
||
use anyhow::{Context, Result};
|
||
use lipgloss::{Color, Style};
|
||
use std::io::{self, Write};
|
||
|
||
// Tokyo Night theme colors (as RGB tuples)
|
||
fn color_blue() -> Color {
|
||
Color::from_rgb(122, 162, 247)
|
||
} // #7AA2F7
|
||
fn color_purple() -> Color {
|
||
Color::from_rgb(187, 154, 247)
|
||
} // #BB9AF7
|
||
fn color_light_gray() -> Color {
|
||
Color::from_rgb(169, 177, 214)
|
||
} // #A9B1D6
|
||
fn color_dark_gray() -> Color {
|
||
Color::from_rgb(86, 95, 137)
|
||
} // #565F89
|
||
fn color_green() -> Color {
|
||
Color::from_rgb(158, 206, 106)
|
||
} // #9ECE6A
|
||
fn color_orange() -> Color {
|
||
Color::from_rgb(224, 175, 104)
|
||
} // #E0AF68
|
||
|
||
/// Renders a styled welcome screen for first-time setup
|
||
pub fn render_welcome_banner() {
|
||
let title_style = Style::new().bold(true).foreground(color_blue());
|
||
|
||
let subtitle_style = Style::new().foreground(color_purple());
|
||
|
||
let text_style = Style::new().foreground(color_light_gray());
|
||
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!();
|
||
println!("{}", title_style.render(" 🚀 Welcome to tmuxido!"));
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
subtitle_style.render(" 📁 Let's set up your project directories")
|
||
);
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
text_style.render(" Please specify where tmuxido should look for your projects.")
|
||
);
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
text_style.render(" You can add multiple paths separated by commas:")
|
||
);
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 💡 Example: ~/Projects, ~/work, ~/personal/repos")
|
||
);
|
||
println!();
|
||
}
|
||
|
||
/// Renders a prompt asking for paths
|
||
pub fn render_paths_prompt() -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
|
||
print!(" {} ", prompt_style.render("❯ Paths:"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Renders a success message after config is created with all settings
|
||
pub fn render_config_created(
|
||
paths: &[String],
|
||
max_depth: usize,
|
||
cache_enabled: bool,
|
||
cache_ttl_hours: u64,
|
||
windows: &[Window],
|
||
) {
|
||
let success_style = Style::new().bold(true).foreground(color_green());
|
||
let label_style = Style::new().foreground(color_light_gray());
|
||
let value_style = Style::new().bold(true).foreground(color_blue());
|
||
let path_style = Style::new().foreground(color_blue());
|
||
let window_style = Style::new().foreground(color_purple());
|
||
let info_style = Style::new().foreground(color_dark_gray());
|
||
let bool_enabled_style = Style::new().bold(true).foreground(color_green());
|
||
let bool_disabled_style = Style::new().bold(true).foreground(color_orange());
|
||
|
||
println!();
|
||
println!("{}", success_style.render(" ✅ Configuration saved!"));
|
||
println!();
|
||
|
||
// Project discovery section
|
||
println!("{}", label_style.render(" 📁 Project Discovery:"));
|
||
println!(
|
||
" {} {} {}",
|
||
label_style.render("Max scan depth:"),
|
||
value_style.render(&max_depth.to_string()),
|
||
label_style.render("levels")
|
||
);
|
||
println!();
|
||
|
||
// Paths
|
||
println!("{}", label_style.render(" 📂 Directories:"));
|
||
for path in paths {
|
||
println!(" {}", path_style.render(&format!("• {}", path)));
|
||
}
|
||
println!();
|
||
|
||
// Cache settings
|
||
println!("{}", label_style.render(" 💾 Cache Settings:"));
|
||
let cache_status = if cache_enabled {
|
||
bool_enabled_style.render("enabled")
|
||
} else {
|
||
bool_disabled_style.render("disabled")
|
||
};
|
||
println!(" {} {}", label_style.render("Status:"), cache_status);
|
||
if cache_enabled {
|
||
println!(
|
||
" {} {} {}",
|
||
label_style.render("TTL:"),
|
||
value_style.render(&cache_ttl_hours.to_string()),
|
||
label_style.render("hours")
|
||
);
|
||
}
|
||
println!();
|
||
|
||
// Default session
|
||
println!("{}", label_style.render(" 🪟 Default Windows:"));
|
||
for window in windows {
|
||
println!(" {}", window_style.render(&format!("◦ {}", window.name)));
|
||
if let Some(layout) = &window.layout {
|
||
println!(
|
||
"{}",
|
||
info_style.render(&format!(" └─ layout: {}", layout))
|
||
);
|
||
}
|
||
if !window.panes.is_empty() {
|
||
for (i, pane) in window.panes.iter().enumerate() {
|
||
let pane_display = if pane.is_empty() {
|
||
format!(" └─ pane {} (shell)", i + 1)
|
||
} else {
|
||
format!(" └─ pane {}: {}", i + 1, pane)
|
||
};
|
||
println!("{}", info_style.render(&pane_display));
|
||
}
|
||
}
|
||
}
|
||
println!();
|
||
|
||
println!(
|
||
"{}",
|
||
info_style.render(
|
||
" ⚙️ You can edit ~/.config/tmuxido/tmuxido.toml anytime to change these settings."
|
||
)
|
||
);
|
||
println!();
|
||
}
|
||
|
||
/// Renders a warning when user provides no input (fallback to default)
|
||
pub fn render_fallback_message() {
|
||
let warning_style = Style::new().italic(true).foreground(color_orange());
|
||
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
warning_style.render(" ⚠️ No paths provided. Using default: ~/Projects")
|
||
);
|
||
}
|
||
|
||
/// Renders a section header for grouping related settings
|
||
pub fn render_section_header(title: &str) {
|
||
let header_style = Style::new().bold(true).foreground(color_purple());
|
||
|
||
println!();
|
||
println!("{}", header_style.render(&format!(" 📋 {}", title)));
|
||
}
|
||
|
||
/// Renders a prompt for max_depth with instructions
|
||
pub fn render_max_depth_prompt() -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" How many levels deep should tmuxido search for git repositories?")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Higher values = deeper search, but slower. Default: 5")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Max depth:"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Renders a prompt for cache_enabled with instructions
|
||
pub fn render_cache_enabled_prompt() -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Enable caching to speed up project discovery?")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Cache avoids rescanning unchanged directories. Default: yes (y)")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Enable cache? (y/n):"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_lowercase())
|
||
}
|
||
|
||
/// Renders a prompt for cache_ttl_hours with instructions
|
||
pub fn render_cache_ttl_prompt() -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" How long should the cache remain valid (in hours)?")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" After this time, tmuxido will rescan your directories. Default: 24")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Cache TTL (hours):"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Renders a prompt for default session windows with instructions
|
||
pub fn render_windows_prompt() -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" What windows should be created by default in new tmux sessions?")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Enter window names separated by commas. Default: editor, terminal")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 💡 Tip: Common choices are 'editor', 'terminal', 'server', 'logs'")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Window names:"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Renders a prompt asking for panes in a specific window
|
||
pub fn render_panes_prompt(window_name: &str) -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
let window_style = Style::new().bold(true).foreground(color_purple());
|
||
|
||
println!();
|
||
println!(" Configuring window: {}", window_style.render(window_name));
|
||
println!(
|
||
"{}",
|
||
hint_style
|
||
.render(" Enter pane names separated by commas, or leave empty for a single pane.")
|
||
);
|
||
println!("{}", hint_style.render(" 💡 Example: code, logs, tests"));
|
||
print!(" {} ", prompt_style.render("❯ Pane names:"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Renders a prompt for the layout of a window with multiple panes
|
||
pub fn render_layout_prompt(window_name: &str, pane_count: usize) -> Result<Option<String>> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
let window_style = Style::new().bold(true).foreground(color_purple());
|
||
let label_style = Style::new().foreground(color_blue());
|
||
|
||
println!();
|
||
println!(
|
||
" Layout for window {} ({} panes):",
|
||
window_style.render(window_name),
|
||
label_style.render(&pane_count.to_string())
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Choose a pane layout (leave empty for no layout):")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 1. main-horizontal — main pane on top, others below")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 2. main-vertical — main pane on left, others on right")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 3. tiled — all panes tiled equally")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 4. even-horizontal — all panes side by side")
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 5. even-vertical — all panes stacked vertically")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Layout (1-5 or name):"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(parse_layout_input(input.trim()))
|
||
}
|
||
|
||
/// Parse layout input: accepts number (1-5) or layout name; returns None for empty/invalid
|
||
pub fn parse_layout_input(input: &str) -> Option<String> {
|
||
match input.trim() {
|
||
"" => None,
|
||
"1" | "main-horizontal" => Some("main-horizontal".to_string()),
|
||
"2" | "main-vertical" => Some("main-vertical".to_string()),
|
||
"3" | "tiled" => Some("tiled".to_string()),
|
||
"4" | "even-horizontal" => Some("even-horizontal".to_string()),
|
||
"5" | "even-vertical" => Some("even-vertical".to_string()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Renders a prompt for a pane command
|
||
pub fn render_pane_command_prompt(pane_name: &str) -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
let pane_style = Style::new().foreground(color_blue());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(&format!(
|
||
" What command should run in pane '{}' on startup?",
|
||
pane_style.render(pane_name)
|
||
))
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Leave empty to run the default shell, or enter a command like 'nvim', 'npm run dev'")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Command:"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Parse max_depth input, returning None for empty/invalid (use default)
|
||
pub fn parse_max_depth_input(input: &str) -> Option<usize> {
|
||
let trimmed = input.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
trimmed.parse::<usize>().ok().filter(|&n| n > 0)
|
||
}
|
||
|
||
/// Parse cache enabled input, returning None for empty (use default)
|
||
pub fn parse_cache_enabled_input(input: &str) -> Option<bool> {
|
||
let trimmed = input.trim().to_lowercase();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
match trimmed.as_str() {
|
||
"y" | "yes" => Some(true),
|
||
"n" | "no" => Some(false),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Parse cache TTL input, returning None for empty/invalid (use default)
|
||
pub fn parse_cache_ttl_input(input: &str) -> Option<u64> {
|
||
let trimmed = input.trim();
|
||
if trimmed.is_empty() {
|
||
return None;
|
||
}
|
||
trimmed.parse::<u64>().ok().filter(|&n| n > 0)
|
||
}
|
||
|
||
// ============================================================================
|
||
// Shortcut wizard UI
|
||
// ============================================================================
|
||
|
||
/// Render warning when the desktop environment could not be detected
|
||
pub fn render_shortcut_unknown_de() {
|
||
let warning_style = Style::new().italic(true).foreground(color_orange());
|
||
println!(
|
||
"{}",
|
||
warning_style.render(" Desktop environment not detected. Skipping shortcut setup.")
|
||
);
|
||
println!(
|
||
"{}",
|
||
warning_style.render(" Run 'tmuxido --setup-shortcut' later when your DE is active.")
|
||
);
|
||
}
|
||
|
||
/// Ask the user whether to set up a keyboard shortcut. Returns `true` if yes.
|
||
pub fn render_shortcut_setup_prompt() -> Result<bool> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" Set up a keyboard shortcut to launch tmuxido from anywhere?")
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Set up shortcut? (Y/n):"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
let trimmed = input.trim().to_lowercase();
|
||
Ok(trimmed != "n" && trimmed != "no")
|
||
}
|
||
|
||
/// Ask the user for a key combo (shows the default in brackets).
|
||
pub fn render_key_combo_prompt(default: &str) -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(&format!(
|
||
" Enter the key combo to launch tmuxido (default: {})",
|
||
default
|
||
))
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" 💡 Example: Super+Shift+T, Super+Ctrl+P")
|
||
);
|
||
print!(
|
||
" {} ",
|
||
prompt_style.render(&format!("❯ Key combo [{}]:", default))
|
||
);
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Show a conflict warning and ask whether to use the suggested alternative.
|
||
/// Returns `true` if the user accepts the suggestion.
|
||
pub fn render_shortcut_conflict_prompt(
|
||
combo: &str,
|
||
taken_by: &str,
|
||
suggestion: &str,
|
||
) -> Result<bool> {
|
||
let warning_style = Style::new().foreground(color_orange());
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
warning_style.render(&format!(
|
||
" ⚠️ {} is already taken by: {}",
|
||
combo, taken_by
|
||
))
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style.render(&format!(" Use {} instead?", suggestion))
|
||
);
|
||
print!(" {} ", prompt_style.render("❯ Use suggestion? (Y/n):"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
let trimmed = input.trim().to_lowercase();
|
||
Ok(trimmed != "n" && trimmed != "no")
|
||
}
|
||
|
||
/// Render a success message after the shortcut has been written
|
||
pub fn render_shortcut_success(de: &str, combo: &str, details: &str, reload_hint: &str) {
|
||
let success_style = Style::new().bold(true).foreground(color_green());
|
||
let label_style = Style::new().foreground(color_light_gray());
|
||
let value_style = Style::new().bold(true).foreground(color_blue());
|
||
let info_style = Style::new().foreground(color_dark_gray());
|
||
|
||
println!();
|
||
println!("{}", success_style.render(" ⌨️ Shortcut configured!"));
|
||
println!(
|
||
" {} {}",
|
||
label_style.render("Combo:"),
|
||
value_style.render(combo)
|
||
);
|
||
println!(
|
||
" {} {}",
|
||
label_style.render("Desktop:"),
|
||
value_style.render(de)
|
||
);
|
||
println!(" {}", info_style.render(details));
|
||
println!();
|
||
println!(" {}", info_style.render(reload_hint));
|
||
println!();
|
||
}
|
||
|
||
/// Ask the user whether to install the .desktop entry and icon
|
||
pub fn render_desktop_integration_prompt() -> Result<bool> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
|
||
println!(
|
||
"{}",
|
||
hint_style.render(
|
||
" Install a .desktop entry so tmuxido appears in app launchers (Walker, Rofi, etc.)?"
|
||
)
|
||
);
|
||
println!(
|
||
"{}",
|
||
hint_style
|
||
.render(" Also downloads the 96×96 icon from GitHub (requires internet access).")
|
||
);
|
||
print!(
|
||
" {} ",
|
||
prompt_style.render("❯ Install desktop entry? (Y/n):")
|
||
);
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
let trimmed = input.trim().to_lowercase();
|
||
Ok(trimmed != "n" && trimmed != "no")
|
||
}
|
||
|
||
/// Render a success message after desktop integration is installed
|
||
pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInstallResult) {
|
||
let success_style = Style::new().bold(true).foreground(color_green());
|
||
let label_style = Style::new().foreground(color_light_gray());
|
||
let value_style = Style::new().bold(true).foreground(color_blue());
|
||
let warn_style = Style::new().italic(true).foreground(color_orange());
|
||
|
||
println!();
|
||
println!("{}", success_style.render(" 🖥️ Desktop entry installed!"));
|
||
println!(
|
||
" {} {}",
|
||
label_style.render(".desktop:"),
|
||
value_style.render(&result.desktop_path.display().to_string())
|
||
);
|
||
if result.icon_downloaded {
|
||
println!(
|
||
" {} {}",
|
||
label_style.render("icon:"),
|
||
value_style.render(&result.icon_path.display().to_string())
|
||
);
|
||
} else {
|
||
println!(
|
||
" {}",
|
||
warn_style.render("icon: download skipped (no network or curl unavailable)")
|
||
);
|
||
}
|
||
println!();
|
||
}
|
||
|
||
// ============================================================================
|
||
|
||
/// Choices offered when no configuration file is found on first run
|
||
#[derive(Debug, PartialEq)]
|
||
pub enum SetupChoice {
|
||
Wizard,
|
||
Default,
|
||
}
|
||
|
||
/// Parse the first-run setup choice input.
|
||
///
|
||
/// Accepts:
|
||
/// - `""`, `" "`, `"1"`, `"w"`, `"wizard"` → `Wizard` (default)
|
||
/// - `"2"`, `"d"`, `"default"` → `Default`
|
||
/// - anything else falls back to `Wizard`
|
||
pub fn parse_setup_choice_input(input: &str) -> SetupChoice {
|
||
match input.trim().to_lowercase().as_str() {
|
||
"2" | "d" | "default" => SetupChoice::Default,
|
||
_ => SetupChoice::Wizard,
|
||
}
|
||
}
|
||
|
||
/// Renders the first-run prompt asking whether to run the wizard or use defaults.
|
||
/// Returns the raw user input.
|
||
pub fn render_setup_choice_prompt() -> Result<String> {
|
||
let prompt_style = Style::new().bold(true).foreground(color_green());
|
||
let hint_style = Style::new().italic(true).foreground(color_dark_gray());
|
||
let title_style = Style::new().bold(true).foreground(color_blue());
|
||
let option_style = Style::new().foreground(color_purple());
|
||
|
||
println!();
|
||
println!("{}", title_style.render(" 🚀 Welcome to tmuxido!"));
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
hint_style.render(" No configuration found. How would you like to get started?")
|
||
);
|
||
println!();
|
||
println!(
|
||
" {}",
|
||
option_style
|
||
.render("1. Run setup wizard — configure paths, cache, and windows interactively")
|
||
);
|
||
println!(
|
||
" {}",
|
||
option_style.render("2. Use default config — start immediately with sensible defaults")
|
||
);
|
||
println!();
|
||
print!(" {} ", prompt_style.render("❯ Choose (1/2):"));
|
||
io::stdout().flush().context("Failed to flush stdout")?;
|
||
|
||
let mut input = String::new();
|
||
io::stdin()
|
||
.read_line(&mut input)
|
||
.context("Failed to read input")?;
|
||
|
||
Ok(input.trim().to_string())
|
||
}
|
||
|
||
/// Renders a confirmation message after the default config is written.
|
||
pub fn render_default_config_saved(config_path: &str) {
|
||
let success_style = Style::new().bold(true).foreground(color_green());
|
||
let info_style = Style::new().foreground(color_dark_gray());
|
||
let path_style = Style::new().bold(true).foreground(color_blue());
|
||
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
success_style.render(" ✅ Default configuration saved!")
|
||
);
|
||
println!(
|
||
" {} {}",
|
||
info_style.render("Config:"),
|
||
path_style.render(config_path)
|
||
);
|
||
println!();
|
||
println!(
|
||
"{}",
|
||
info_style.render(
|
||
" ⚙️ Edit it anytime to customise your setup, or run 'tmuxido --setup-shortcut'."
|
||
)
|
||
);
|
||
println!();
|
||
}
|
||
|
||
// ============================================================================
|
||
|
||
/// Parse comma-separated list into Vec<String>, filtering empty items
|
||
pub fn parse_comma_separated_list(input: &str) -> Vec<String> {
|
||
input
|
||
.trim()
|
||
.split(',')
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.collect()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn should_return_none_for_empty_max_depth() {
|
||
assert_eq!(parse_max_depth_input(""), None);
|
||
assert_eq!(parse_max_depth_input(" "), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_valid_max_depth() {
|
||
assert_eq!(parse_max_depth_input("5"), Some(5));
|
||
assert_eq!(parse_max_depth_input("10"), Some(10));
|
||
assert_eq!(parse_max_depth_input(" 3 "), Some(3));
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_invalid_max_depth() {
|
||
assert_eq!(parse_max_depth_input("0"), None);
|
||
assert_eq!(parse_max_depth_input("-1"), None);
|
||
assert_eq!(parse_max_depth_input("abc"), None);
|
||
assert_eq!(parse_max_depth_input("3.5"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_empty_cache_enabled() {
|
||
assert_eq!(parse_cache_enabled_input(""), None);
|
||
assert_eq!(parse_cache_enabled_input(" "), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_yes_as_true() {
|
||
assert_eq!(parse_cache_enabled_input("y"), Some(true));
|
||
assert_eq!(parse_cache_enabled_input("Y"), Some(true));
|
||
assert_eq!(parse_cache_enabled_input("yes"), Some(true));
|
||
assert_eq!(parse_cache_enabled_input("YES"), Some(true));
|
||
assert_eq!(parse_cache_enabled_input("Yes"), Some(true));
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_no_as_false() {
|
||
assert_eq!(parse_cache_enabled_input("n"), Some(false));
|
||
assert_eq!(parse_cache_enabled_input("N"), Some(false));
|
||
assert_eq!(parse_cache_enabled_input("no"), Some(false));
|
||
assert_eq!(parse_cache_enabled_input("NO"), Some(false));
|
||
assert_eq!(parse_cache_enabled_input("No"), Some(false));
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_invalid_cache_input() {
|
||
assert_eq!(parse_cache_enabled_input("maybe"), None);
|
||
assert_eq!(parse_cache_enabled_input("true"), None);
|
||
assert_eq!(parse_cache_enabled_input("1"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_empty_cache_ttl() {
|
||
assert_eq!(parse_cache_ttl_input(""), None);
|
||
assert_eq!(parse_cache_ttl_input(" "), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_valid_cache_ttl() {
|
||
assert_eq!(parse_cache_ttl_input("24"), Some(24));
|
||
assert_eq!(parse_cache_ttl_input("12"), Some(12));
|
||
assert_eq!(parse_cache_ttl_input(" 48 "), Some(48));
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_invalid_cache_ttl() {
|
||
assert_eq!(parse_cache_ttl_input("0"), None);
|
||
assert_eq!(parse_cache_ttl_input("-1"), None);
|
||
assert_eq!(parse_cache_ttl_input("abc"), None);
|
||
assert_eq!(parse_cache_ttl_input("12.5"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_empty_comma_list() {
|
||
let result = parse_comma_separated_list("");
|
||
assert!(result.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_single_item() {
|
||
let result = parse_comma_separated_list("editor");
|
||
assert_eq!(result, vec!["editor"]);
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_multiple_items() {
|
||
let result = parse_comma_separated_list("editor, terminal, server");
|
||
assert_eq!(result, vec!["editor", "terminal", "server"]);
|
||
}
|
||
|
||
#[test]
|
||
fn should_trim_whitespace_in_comma_list() {
|
||
let result = parse_comma_separated_list(" editor , terminal ");
|
||
assert_eq!(result, vec!["editor", "terminal"]);
|
||
}
|
||
|
||
#[test]
|
||
fn should_filter_empty_parts_in_comma_list() {
|
||
let result = parse_comma_separated_list("editor,,terminal");
|
||
assert_eq!(result, vec!["editor", "terminal"]);
|
||
}
|
||
|
||
#[test]
|
||
fn color_blue_should_return_expected_rgb() {
|
||
let color = color_blue();
|
||
// We can't easily test the internal RGB values, but we can verify it doesn't panic
|
||
let _ = color;
|
||
}
|
||
|
||
#[test]
|
||
fn color_functions_should_return_distinct_colors() {
|
||
// Verify all color functions return valid Color objects
|
||
let colors = vec![
|
||
color_blue(),
|
||
color_purple(),
|
||
color_light_gray(),
|
||
color_dark_gray(),
|
||
color_green(),
|
||
color_orange(),
|
||
];
|
||
// Just verify they don't panic and are distinct
|
||
assert_eq!(colors.len(), 6);
|
||
}
|
||
|
||
#[test]
|
||
fn render_section_header_should_not_panic() {
|
||
// This test verifies the function doesn't panic
|
||
// We can't capture stdout easily in unit tests without additional setup
|
||
render_section_header("Test Section");
|
||
}
|
||
|
||
#[test]
|
||
fn render_welcome_banner_should_not_panic() {
|
||
render_welcome_banner();
|
||
}
|
||
|
||
#[test]
|
||
fn render_fallback_message_should_not_panic() {
|
||
render_fallback_message();
|
||
}
|
||
|
||
#[test]
|
||
fn render_config_created_should_not_panic() {
|
||
let windows = vec![
|
||
Window {
|
||
name: "editor".to_string(),
|
||
panes: vec!["nvim .".to_string()],
|
||
layout: None,
|
||
},
|
||
Window {
|
||
name: "terminal".to_string(),
|
||
panes: vec![],
|
||
layout: None,
|
||
},
|
||
];
|
||
render_config_created(&vec!["~/Projects".to_string()], 5, true, 24, &windows);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_empty_layout_input() {
|
||
assert_eq!(parse_layout_input(""), None);
|
||
assert_eq!(parse_layout_input(" "), None);
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_layout_by_number() {
|
||
assert_eq!(parse_layout_input("1"), Some("main-horizontal".to_string()));
|
||
assert_eq!(parse_layout_input("2"), Some("main-vertical".to_string()));
|
||
assert_eq!(parse_layout_input("3"), Some("tiled".to_string()));
|
||
assert_eq!(parse_layout_input("4"), Some("even-horizontal".to_string()));
|
||
assert_eq!(parse_layout_input("5"), Some("even-vertical".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn should_parse_layout_by_name() {
|
||
assert_eq!(
|
||
parse_layout_input("main-horizontal"),
|
||
Some("main-horizontal".to_string())
|
||
);
|
||
assert_eq!(
|
||
parse_layout_input("main-vertical"),
|
||
Some("main-vertical".to_string())
|
||
);
|
||
assert_eq!(parse_layout_input("tiled"), Some("tiled".to_string()));
|
||
assert_eq!(
|
||
parse_layout_input("even-horizontal"),
|
||
Some("even-horizontal".to_string())
|
||
);
|
||
assert_eq!(
|
||
parse_layout_input("even-vertical"),
|
||
Some("even-vertical".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_none_for_invalid_layout_input() {
|
||
assert_eq!(parse_layout_input("6"), None);
|
||
assert_eq!(parse_layout_input("0"), None);
|
||
assert_eq!(parse_layout_input("unknown"), None);
|
||
assert_eq!(parse_layout_input("horizontal"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn render_config_created_with_disabled_cache_should_not_panic() {
|
||
let windows = vec![Window {
|
||
name: "editor".to_string(),
|
||
panes: vec![],
|
||
layout: None,
|
||
}];
|
||
render_config_created(&vec!["~/work".to_string()], 3, false, 24, &windows);
|
||
}
|
||
|
||
#[test]
|
||
fn render_shortcut_unknown_de_should_not_panic() {
|
||
render_shortcut_unknown_de();
|
||
}
|
||
|
||
#[test]
|
||
fn render_shortcut_success_should_not_panic() {
|
||
render_shortcut_success(
|
||
"Hyprland",
|
||
"Super+Shift+T",
|
||
"Added to ~/.config/hypr/bindings.conf",
|
||
"Reload Hyprland with Super+Shift+R to activate.",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_desktop_integration_success_should_not_panic() {
|
||
use crate::shortcut::DesktopInstallResult;
|
||
use std::path::PathBuf;
|
||
let result = DesktopInstallResult {
|
||
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
|
||
icon_path: PathBuf::from(
|
||
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
|
||
),
|
||
icon_downloaded: true,
|
||
};
|
||
render_desktop_integration_success(&result);
|
||
}
|
||
|
||
#[test]
|
||
fn render_desktop_integration_success_without_icon_should_not_panic() {
|
||
use crate::shortcut::DesktopInstallResult;
|
||
use std::path::PathBuf;
|
||
let result = DesktopInstallResult {
|
||
desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"),
|
||
icon_path: PathBuf::from(
|
||
"/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png",
|
||
),
|
||
icon_downloaded: false,
|
||
};
|
||
render_desktop_integration_success(&result);
|
||
}
|
||
|
||
// ---- SetupChoice / parse_setup_choice_input ----
|
||
|
||
#[test]
|
||
fn should_return_wizard_for_empty_input() {
|
||
assert_eq!(parse_setup_choice_input(""), SetupChoice::Wizard);
|
||
assert_eq!(parse_setup_choice_input(" "), SetupChoice::Wizard);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_wizard_for_option_1() {
|
||
assert_eq!(parse_setup_choice_input("1"), SetupChoice::Wizard);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_wizard_for_w_aliases() {
|
||
assert_eq!(parse_setup_choice_input("w"), SetupChoice::Wizard);
|
||
assert_eq!(parse_setup_choice_input("wizard"), SetupChoice::Wizard);
|
||
assert_eq!(parse_setup_choice_input("W"), SetupChoice::Wizard);
|
||
assert_eq!(parse_setup_choice_input("WIZARD"), SetupChoice::Wizard);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_default_for_option_2() {
|
||
assert_eq!(parse_setup_choice_input("2"), SetupChoice::Default);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_default_for_d_aliases() {
|
||
assert_eq!(parse_setup_choice_input("d"), SetupChoice::Default);
|
||
assert_eq!(parse_setup_choice_input("default"), SetupChoice::Default);
|
||
assert_eq!(parse_setup_choice_input("D"), SetupChoice::Default);
|
||
assert_eq!(parse_setup_choice_input("DEFAULT"), SetupChoice::Default);
|
||
}
|
||
|
||
#[test]
|
||
fn should_return_wizard_for_unknown_input() {
|
||
assert_eq!(parse_setup_choice_input("banana"), SetupChoice::Wizard);
|
||
assert_eq!(parse_setup_choice_input("3"), SetupChoice::Wizard);
|
||
assert_eq!(parse_setup_choice_input("yes"), SetupChoice::Wizard);
|
||
}
|
||
|
||
#[test]
|
||
fn render_default_config_saved_should_not_panic() {
|
||
render_default_config_saved("/home/user/.config/tmuxido/tmuxido.toml");
|
||
}
|
||
}
|