diff --git a/src/cli.rs b/src/cli.rs index 33ea951..83afee6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,4 @@ -//! CLI argument parsing using Clap derive macros. - -use crate::models::ide::Ide; +use crate::models::ide::IdeSelection; use clap::{Args, Parser, Subcommand}; #[derive(Parser)] @@ -39,7 +37,7 @@ pub struct OpenArgs { /// IDE to use for opening (overrides config default). #[arg(short, long)] - pub ide: Option, + pub ide: Option, } /// Subcommands for `dev project`. @@ -72,7 +70,7 @@ pub enum ConfigSubcommand { /// Set the default IDE to use when opening projects. SetDefaultIde { /// The IDE to set as default. - ide: Ide, + ide: IdeSelection, }, } diff --git a/src/commands/config.rs b/src/commands/config.rs index 7765620..9e5e55b 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -8,18 +8,29 @@ use crate::{ config::Config, }; +use crate::models::ide::IdeSelection; + /// Execute a configuration command. pub fn execute(cmd: ConfigCommand) -> Result<()> { match cmd.command { ConfigSubcommand::Init => init(), ConfigSubcommand::Show => show(), ConfigSubcommand::SetDefaultIde { ide } => { - let detected = crate::ide::detect::detect_ides(); - if !detected.iter().any(|i| i.ide == ide) { - anyhow::bail!("IDE '{:?}' is not installed on your system.", ide); + let mut config = Config::load()?; + match &ide { + IdeSelection::BuiltIn(b) => { + let detected = crate::ide::detect::detect_ides(); + if !detected.iter().any(|i| i.ide == *b) { + anyhow::bail!("IDE '{:?}' is not installed on your system.", b); + } + } + IdeSelection::Custom(id) => { + if !config.custom_ides.iter().any(|c| c.id == *id) { + anyhow::bail!("Custom IDE '{}' is not configured.", id); + } + } } - let mut config = Config::load()?; config.default_ide = ide; config.save()?; diff --git a/src/commands/project.rs b/src/commands/project.rs index dfc6fa8..aeee591 100644 --- a/src/commands/project.rs +++ b/src/commands/project.rs @@ -63,11 +63,11 @@ fn open(args: OpenArgs) -> Result<()> { bail!("Project '{}' not found.", args.project); }; - let ide = args.ide.unwrap_or(config.default_ide); + let ide = args.ide.unwrap_or_else(|| config.default_ide.clone()); - launcher::launch(ide, &project.path)?; + launcher::launch_selection(&ide, &project.path, &config.custom_ides)?; - config.add_recent_project(&project); + config.add_recent_project_with_ide(&project, ide); config.save()?; println!("{} {}", "Opened".green(), project.path.display()); diff --git a/src/config.rs b/src/config.rs index a2d4c52..f3a3a64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,20 +7,77 @@ use directories::{BaseDirs, ProjectDirs}; use serde::{Deserialize, Serialize}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::models::{ide::Ide, project::Project, recent_project::RecentProject}; +use crate::models::{ + custom_ide::CustomIde, + ide::{Ide, IdeSelection}, + project::Project, + recent_project::RecentProject, +}; + +fn default_max_depth() -> usize { + 4 +} + +fn default_ignore_patterns() -> Vec { + vec![ + "node_modules".to_string(), + "target".to_string(), + "vendor".to_string(), + ".venv".to_string(), + "venv".to_string(), + "build".to_string(), + "dist".to_string(), + ] +} + +fn default_theme() -> String { + "neon".to_string() +} + +fn default_recent_limit() -> usize { + 10 +} + +fn default_auto_refresh() -> bool { + true +} /// User configuration for dev-cli. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { /// Directories to search for Git repositories. pub projects_root: Vec, /// Default IDE used when opening projects. - pub default_ide: Ide, + pub default_ide: IdeSelection, /// Recently opened projects. #[serde(default)] pub recent_projects: Vec, + + /// User-configured custom IDEs. + #[serde(default)] + pub custom_ides: Vec, + + /// Maximum search depth for repository scanner. + #[serde(default = "default_max_depth")] + pub max_depth: usize, + + /// Extra directory names to ignore during scanning. + #[serde(default = "default_ignore_patterns")] + pub ignore_patterns: Vec, + + /// Active TUI theme palette name ("neon", "cyberpunk", "catppuccin", "monokai", "high-contrast"). + #[serde(default = "default_theme")] + pub theme: String, + + /// Maximum number of recent projects to retain. + #[serde(default = "default_recent_limit")] + pub recent_projects_limit: usize, + + /// Automatically refresh project list when starting the TUI. + #[serde(default = "default_auto_refresh")] + pub auto_refresh_on_launch: bool, } impl Default for Config { @@ -32,8 +89,14 @@ impl Default for Config { Self { projects_root: vec![home.join("Projects")], - default_ide: Ide::Vscode, + default_ide: IdeSelection::BuiltIn(Ide::Vscode), recent_projects: Vec::new(), + custom_ides: Vec::new(), + max_depth: default_max_depth(), + ignore_patterns: default_ignore_patterns(), + theme: default_theme(), + recent_projects_limit: default_recent_limit(), + auto_refresh_on_launch: default_auto_refresh(), } } } @@ -117,10 +180,10 @@ impl Config { } pub fn add_recent_project(&mut self, project: &Project) { - self.add_recent_project_with_ide(project, self.default_ide); + self.add_recent_project_with_ide(project, self.default_ide.clone()); } - pub fn add_recent_project_with_ide(&mut self, project: &Project, ide: Ide) { + pub fn add_recent_project_with_ide(&mut self, project: &Project, ide: IdeSelection) { self.recent_projects.retain(|p| p.path != project.path); self.recent_projects.insert( @@ -135,4 +198,35 @@ impl Config { self.recent_projects.truncate(10); } + + /// Add a custom IDE to the configuration. + pub fn add_custom_ide(&mut self, ide: CustomIde) { + self.custom_ides.retain(|i| i.id != ide.id); + self.custom_ides.push(ide); + self.custom_ides.sort_by_key(|a| a.display_name.to_lowercase()); + } + + /// Remove a custom IDE by its identifier. + pub fn remove_custom_ide(&mut self, id: &str) { + self.custom_ides.retain(|i| i.id != id); + } + + /// Look up a custom IDE by its identifier. + pub fn get_custom_ide(&self, id: &str) -> Option<&CustomIde> { + self.custom_ides.iter().find(|i| i.id == id) + } + + /// Add a project root directory to the configuration. + pub fn add_project_root(&mut self, root: PathBuf) { + if !self.projects_root.contains(&root) { + self.projects_root.push(root); + } + } + + /// Remove a project root directory by index. + pub fn remove_project_root(&mut self, index: usize) { + if index < self.projects_root.len() { + self.projects_root.remove(index); + } + } } diff --git a/src/ide/detect.rs b/src/ide/detect.rs index 21080e7..9b30bc6 100644 --- a/src/ide/detect.rs +++ b/src/ide/detect.rs @@ -1,7 +1,7 @@ //! IDE detection algorithm. use directories::BaseDirs; -use std::path::Path; +use std::path::{Path, PathBuf}; use which::which; use crate::{ide::registry::InstalledIde, models::ide::Ide}; @@ -9,16 +9,86 @@ use crate::{ide::registry::InstalledIde, models::ide::Ide}; pub fn detect_ides() -> Vec { let mut found = Vec::new(); - detect_cli(&mut found, Ide::Vscode, "VS Code", "code"); + detect_cli(&mut found, Ide::Antigravity, "Antigravity", "antigravity"); + detect_cli(&mut found, Ide::AntigravityCli, "Antigravity CLI", "agy"); + if !found.iter().any(|i| matches!(i.ide, Ide::AntigravityCli)) { + detect_cli(&mut found, Ide::AntigravityCli, "Antigravity CLI", "antigravity"); + } detect_cli(&mut found, Ide::Cursor, "Cursor", "cursor"); + detect_cli(&mut found, Ide::Vscode, "VS Code", "code"); + detect_cli(&mut found, Ide::Windsurf, "Windsurf", "windsurf"); detect_cli(&mut found, Ide::Claude, "Claude Code", "claude"); - detect_cli(&mut found, Ide::Terminal, "Windows Terminal", "wt"); + detect_cli(&mut found, Ide::Sublime, "Sublime Text", "subl"); + detect_cli(&mut found, Ide::Neovim, "Neovim", "nvim"); + detect_cli(&mut found, Ide::Zed, "Zed", "zed"); + detect_cli(&mut found, Ide::Idea, "IntelliJ IDEA", "idea"); + detect_cli(&mut found, Ide::PyCharm, "PyCharm", "pycharm"); + detect_cli(&mut found, Ide::WebStorm, "WebStorm", "webstorm"); + detect_cli(&mut found, Ide::CLion, "CLion", "clion"); + detect_cli(&mut found, Ide::RustRover, "RustRover", "rustrover"); + detect_cli(&mut found, Ide::GoLand, "GoLand", "goland"); + detect_cli(&mut found, Ide::Fleet, "Fleet", "fleet"); + detect_cli(&mut found, Ide::Rider, "Rider", "rider"); + detect_cli(&mut found, Ide::AndroidStudio, "Android Studio", "studio"); + detect_cli(&mut found, Ide::VisualStudio, "Visual Studio", "devenv"); + detect_cli(&mut found, Ide::Helix, "Helix", "hx"); + + // Cross-platform Terminal detection (Windows, macOS, Linux) + detect_terminal(&mut found); detect_common_windows_locations(&mut found); + detect_common_macos_locations(&mut found); + detect_common_linux_locations(&mut found); + + found.sort_by_key(|a| a.display_name.to_lowercase()); found } +fn detect_terminal(list: &mut Vec) { + if cfg!(windows) { + if let Ok(path) = which("wt") { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", path)); + } else if let Ok(cmd) = which("cmd") { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", cmd)); + } else if let Ok(powershell) = which("powershell") { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", powershell)); + } + } else if cfg!(target_os = "macos") { + if let Ok(open_path) = which("open") { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", open_path)); + } else { + let open_bin = PathBuf::from("/usr/bin/open"); + if open_bin.exists() { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", open_bin)); + } else if let Ok(sh) = which("zsh").or_else(|_| which("bash")) { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", sh)); + } + } + } else { + let linux_terminals = [ + "gnome-terminal", + "konsole", + "alacritty", + "kitty", + "wezterm", + "xterm", + "x-terminal-emulator", + ]; + let mut term_found = false; + for t in &linux_terminals { + if let Ok(path) = which(t) { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", path)); + term_found = true; + break; + } + } + if !term_found && let Ok(sh) = which("bash").or_else(|_| which("sh")) { + list.push(InstalledIde::new(Ide::Terminal, "Terminal", sh)); + } + } +} + /// Check if IDE is available as command-line tool in PATH. fn detect_cli(list: &mut Vec, ide: Ide, name: &str, cmd: &str) { if let Ok(path) = which(cmd) { @@ -35,24 +105,475 @@ fn detect_common_windows_locations(list: &mut Vec) { detect_common_windows_locations_in(list, &home); } +/// Check common macOS installation directories. +fn detect_common_macos_locations(list: &mut Vec) { + let home = match BaseDirs::new() { + Some(dirs) => dirs.home_dir().to_path_buf(), + None => return, + }; + detect_common_macos_locations_in(list, &home); +} + +/// Check common Linux installation directories. +fn detect_common_linux_locations(list: &mut Vec) { + let home = match BaseDirs::new() { + Some(dirs) => dirs.home_dir().to_path_buf(), + None => return, + }; + detect_common_linux_locations_in(list, &home); +} + +/// Check common macOS installation directories. +#[doc(hidden)] +pub fn detect_common_macos_locations_in(list: &mut Vec, home: &Path) { + let app_dirs = [PathBuf::from("/Applications"), home.join("Applications")]; + + let macos_apps = [ + (Ide::Vscode, "VS Code", "Visual Studio Code.app/Contents/Resources/app/bin/code"), + (Ide::Cursor, "Cursor", "Cursor.app/Contents/Resources/app/bin/cursor"), + (Ide::Antigravity, "Antigravity", "Antigravity.app/Contents/Resources/app/bin/antigravity"), + (Ide::Antigravity, "Antigravity", "Antigravity.app/Contents/Resources/app/bin/agy"), + (Ide::Windsurf, "Windsurf", "Windsurf.app/Contents/Resources/app/bin/windsurf"), + (Ide::Sublime, "Sublime Text", "Sublime Text.app/Contents/SharedSupport/bin/subl"), + (Ide::Zed, "Zed", "Zed.app/Contents/MacOS/zed"), + (Ide::Idea, "IntelliJ IDEA", "IntelliJ IDEA.app/Contents/MacOS/idea"), + (Ide::Idea, "IntelliJ IDEA", "IntelliJ IDEA CE.app/Contents/MacOS/idea"), + (Ide::PyCharm, "PyCharm", "PyCharm.app/Contents/MacOS/pycharm"), + (Ide::PyCharm, "PyCharm", "PyCharm CE.app/Contents/MacOS/pycharm"), + (Ide::WebStorm, "WebStorm", "WebStorm.app/Contents/MacOS/webstorm"), + (Ide::CLion, "CLion", "CLion.app/Contents/MacOS/clion"), + (Ide::RustRover, "RustRover", "RustRover.app/Contents/MacOS/rustrover"), + (Ide::GoLand, "GoLand", "GoLand.app/Contents/MacOS/goland"), + (Ide::Fleet, "Fleet", "Fleet.app/Contents/MacOS/Fleet"), + (Ide::Rider, "Rider", "Rider.app/Contents/MacOS/rider"), + (Ide::AndroidStudio, "Android Studio", "Android Studio.app/Contents/MacOS/studio"), + ]; + + for (ide, name, rel_path) in &macos_apps { + if list.iter().any(|i| i.ide == *ide) { + continue; + } + for dir in &app_dirs { + let candidate = dir.join(rel_path); + if candidate.exists() { + list.push(InstalledIde::new(*ide, name, candidate)); + break; + } + } + } + + let macos_cli_tools = [ + ( + Ide::AntigravityCli, + "Antigravity CLI", + vec![ + home.join(".antigravity/bin/agy"), + home.join(".antigravity/bin/antigravity"), + home.join(".local/bin/agy"), + home.join(".local/bin/antigravity"), + PathBuf::from("/opt/homebrew/bin/agy"), + PathBuf::from("/opt/homebrew/bin/antigravity"), + PathBuf::from("/usr/local/bin/agy"), + PathBuf::from("/usr/local/bin/antigravity"), + ], + ), + ( + Ide::Claude, + "Claude Code", + vec![ + home.join(".local/bin/claude"), + PathBuf::from("/opt/homebrew/bin/claude"), + PathBuf::from("/usr/local/bin/claude"), + ], + ), + ( + Ide::Neovim, + "Neovim", + vec![ + home.join(".local/bin/nvim"), + PathBuf::from("/opt/homebrew/bin/nvim"), + PathBuf::from("/usr/local/bin/nvim"), + ], + ), + ( + Ide::Helix, + "Helix", + vec![ + home.join(".local/bin/hx"), + PathBuf::from("/opt/homebrew/bin/hx"), + PathBuf::from("/usr/local/bin/hx"), + ], + ), + ]; + + for (ide, name, paths) in &macos_cli_tools { + detect_first_existing(list, *ide, name, paths); + } +} + +/// Check common Linux installation directories. +#[doc(hidden)] +pub fn detect_common_linux_locations_in(list: &mut Vec, home: &Path) { + let linux_bins = [ + ( + Ide::AntigravityCli, + "Antigravity CLI", + vec![ + home.join(".antigravity/bin/agy"), + home.join(".antigravity/bin/antigravity"), + home.join(".local/bin/agy"), + home.join(".local/bin/antigravity"), + PathBuf::from("/usr/bin/agy"), + PathBuf::from("/usr/bin/antigravity"), + PathBuf::from("/usr/local/bin/agy"), + PathBuf::from("/usr/local/bin/antigravity"), + PathBuf::from("/snap/bin/agy"), + PathBuf::from("/snap/bin/antigravity"), + ], + ), + ( + Ide::Vscode, + "VS Code", + vec![ + PathBuf::from("/snap/bin/code"), + PathBuf::from("/usr/bin/code"), + PathBuf::from("/usr/local/bin/code"), + home.join(".local/bin/code"), + ], + ), + ( + Ide::Cursor, + "Cursor", + vec![ + PathBuf::from("/usr/bin/cursor"), + PathBuf::from("/usr/local/bin/cursor"), + PathBuf::from("/snap/bin/cursor"), + home.join(".local/bin/cursor"), + home.join(".local/bin/Cursor.AppImage"), + ], + ), + ( + Ide::Claude, + "Claude Code", + vec![ + PathBuf::from("/usr/bin/claude"), + PathBuf::from("/usr/local/bin/claude"), + home.join(".local/bin/claude"), + ], + ), + ( + Ide::Windsurf, + "Windsurf", + vec![ + PathBuf::from("/usr/bin/windsurf"), + PathBuf::from("/usr/local/bin/windsurf"), + home.join(".local/bin/windsurf"), + ], + ), + ( + Ide::Sublime, + "Sublime Text", + vec![ + PathBuf::from("/snap/bin/subl"), + PathBuf::from("/usr/bin/subl"), + PathBuf::from("/usr/local/bin/subl"), + ], + ), + ( + Ide::Neovim, + "Neovim", + vec![ + PathBuf::from("/usr/bin/nvim"), + PathBuf::from("/usr/local/bin/nvim"), + home.join(".local/bin/nvim"), + PathBuf::from("/snap/bin/nvim"), + ], + ), + ( + Ide::Helix, + "Helix", + vec![ + PathBuf::from("/usr/bin/hx"), + PathBuf::from("/usr/local/bin/hx"), + home.join(".local/bin/hx"), + PathBuf::from("/snap/bin/hx"), + ], + ), + ( + Ide::Zed, + "Zed", + vec![ + PathBuf::from("/usr/bin/zed"), + PathBuf::from("/usr/local/bin/zed"), + home.join(".local/bin/zed"), + home.join(".local/bin/zed-cli"), + ], + ), + ]; + + for (ide, name, paths) in &linux_bins { + detect_first_existing(list, *ide, name, paths); + } + + let jb_tools = [ + (Ide::Idea, "IntelliJ IDEA", "idea", &["IDEA-ULT", "IDEA-C"][..]), + (Ide::PyCharm, "PyCharm", "pycharm", &["PyCharm-P", "PyCharm-C"][..]), + (Ide::WebStorm, "WebStorm", "webstorm", &["WebStorm"][..]), + (Ide::CLion, "CLion", "clion", &["CLion"][..]), + (Ide::RustRover, "RustRover", "rustrover", &["RustRover"][..]), + (Ide::GoLand, "GoLand", "goland", &["GoLand"][..]), + (Ide::Rider, "Rider", "rider", &["Rider"][..]), + ]; + + let jb_toolbox_apps = home.join(".local/share/JetBrains/Toolbox/apps"); + scan_jetbrains_toolbox(list, &jb_toolbox_apps, &jb_tools); +} + /// Check common Windows installation directories under a given home directory. #[doc(hidden)] pub fn detect_common_windows_locations_in(list: &mut Vec, home: &Path) { - // VS Code: Standard Windows installation path - let vscode = home.join("AppData/Local/Programs/Microsoft VS Code/bin/code.cmd"); - if vscode.exists() && !list.iter().any(|i| matches!(i.ide, Ide::Vscode)) { - list.push(InstalledIde::new(Ide::Vscode, "VS Code", vscode)); + let is_real_home = BaseDirs::new().map(|b| b.home_dir() == home).unwrap_or(false); + + let mut pf_roots = vec![home.join("Program Files"), home.join("Program Files (x86)")]; + + if is_real_home { + let pf = std::env::var("ProgramFiles").unwrap_or_else(|_| r"C:\Program Files".to_string()); + let pf86 = std::env::var("ProgramFiles(x86)") + .unwrap_or_else(|_| r"C:\Program Files (x86)".to_string()); + pf_roots.push(PathBuf::from(&pf)); + pf_roots.push(PathBuf::from(&pf86)); } - // Cursor: Standard Windows installation path - let cursor = home.join("AppData/Local/Programs/Cursor/Cursor.exe"); - if cursor.exists() && !list.iter().any(|i| matches!(i.ide, Ide::Cursor)) { - list.push(InstalledIde::new(Ide::Cursor, "Cursor", cursor)); + // Visual Studio (2022, 2019, 2017 across Community, Professional, Enterprise, Preview) + let vs_editions = ["Community", "Professional", "Enterprise", "Preview", "BuildTools"]; + let vs_years = ["2022", "2019", "2017"]; + for root in &pf_roots { + for year in &vs_years { + for edition in &vs_editions { + let devenv = root.join(format!( + "Microsoft Visual Studio/{year}/{edition}/Common7/IDE/devenv.exe" + )); + if devenv.exists() && !list.iter().any(|i| matches!(i.ide, Ide::VisualStudio)) { + list.push(InstalledIde::new(Ide::VisualStudio, "Visual Studio", devenv)); + break; + } + } + } } + // Antigravity (GUI IDE) + detect_first_existing( + list, + Ide::Antigravity, + "Antigravity", + &[ + home.join("AppData/Local/Programs/Antigravity/Antigravity.exe"), + pf_roots[0].join("Antigravity/Antigravity.exe"), + ], + ); + + // Antigravity CLI + detect_first_existing( + list, + Ide::AntigravityCli, + "Antigravity CLI", + &[ + home.join("AppData/Local/Programs/Antigravity/bin/agy.cmd"), + home.join(".antigravity/bin/agy.exe"), + home.join(".antigravity/bin/antigravity.exe"), + ], + ); + + // VS Code: Standard Windows User & System installation paths + detect_first_existing( + list, + Ide::Vscode, + "VS Code", + &[ + home.join("AppData/Local/Programs/Microsoft VS Code/bin/code.cmd"), + home.join("AppData/Local/Programs/Microsoft VS Code/Code.exe"), + pf_roots[0].join("Microsoft VS Code/bin/code.cmd"), + pf_roots[0].join("Microsoft VS Code/Code.exe"), + ], + ); + + // Cursor: Standard Windows installation path + detect_first_existing( + list, + Ide::Cursor, + "Cursor", + &[ + home.join("AppData/Local/Programs/Cursor/Cursor.exe"), + pf_roots[0].join("Cursor/Cursor.exe"), + ], + ); + + // Windsurf: Standard Windows installation path + detect_first_existing( + list, + Ide::Windsurf, + "Windsurf", + &[ + home.join("AppData/Local/Programs/Windsurf/Windsurf.exe"), + home.join("AppData/Local/Programs/Windsurf/bin/windsurf.cmd"), + ], + ); + // Claude Code: ~/.local/bin location - let claude = home.join(".local/bin/claude.exe"); - if claude.exists() && !list.iter().any(|i| matches!(i.ide, Ide::Claude)) { - list.push(InstalledIde::new(Ide::Claude, "Claude Code", claude)); + detect_first_existing(list, Ide::Claude, "Claude Code", &[home.join(".local/bin/claude.exe")]); + + // Zed: Standard Windows AppData installation path + detect_first_existing( + list, + Ide::Zed, + "Zed", + &[home.join("AppData/Local/Programs/Zed/Zed.exe")], + ); + + // Fleet: Standard Windows AppData installation path + detect_first_existing( + list, + Ide::Fleet, + "Fleet", + &[home.join("AppData/Local/Programs/Fleet/Fleet.exe")], + ); + + // Sublime Text + let st_candidates = [ + pf_roots[0].join("Sublime Text/sublime_text.exe"), + pf_roots[0].join("Sublime Text 3/sublime_text.exe"), + pf_roots[1].join("Sublime Text/sublime_text.exe"), + pf_roots[1].join("Sublime Text 3/sublime_text.exe"), + ]; + detect_first_existing(list, Ide::Sublime, "Sublime Text", &st_candidates); + + // Android Studio + let studio_candidates = [ + pf_roots[0].join("Android/Android Studio/bin/studio64.exe"), + pf_roots[1].join("Android/Android Studio/bin/studio64.exe"), + ]; + detect_first_existing(list, Ide::AndroidStudio, "Android Studio", &studio_candidates); + + // Neovim + let nvim_candidates = + [pf_roots[0].join("Neovim/bin/nvim.exe"), pf_roots[1].join("Neovim/bin/nvim.exe")]; + detect_first_existing(list, Ide::Neovim, "Neovim", &nvim_candidates); + + // JetBrains IDEs (IntelliJ IDEA, PyCharm, WebStorm, CLion, RustRover, GoLand, Rider) + let jb_tools = [ + (Ide::Idea, "IntelliJ IDEA", "idea64.exe", &["IDEA-ULT", "IDEA-C"][..]), + (Ide::PyCharm, "PyCharm", "pycharm64.exe", &["PyCharm-P", "PyCharm-C"][..]), + (Ide::WebStorm, "WebStorm", "webstorm64.exe", &["WebStorm"][..]), + (Ide::CLion, "CLion", "clion64.exe", &["CLion"][..]), + (Ide::RustRover, "RustRover", "rustrover64.exe", &["RustRover"][..]), + (Ide::GoLand, "GoLand", "goland64.exe", &["GoLand"][..]), + (Ide::Rider, "Rider", "rider64.exe", &["Rider"][..]), + ]; + + scan_jetbrains_program_files(list, &pf_roots, &jb_tools); + + let local_appdata = home.join("AppData/Local"); + let jb_toolbox_apps = local_appdata.join("JetBrains/Toolbox/apps"); + scan_jetbrains_toolbox(list, &jb_toolbox_apps, &jb_tools); +} + +fn detect_first_existing( + list: &mut Vec, + ide: Ide, + name: &str, + candidates: &[PathBuf], +) { + if list.iter().any(|i| i.ide == ide) { + return; + } + for candidate in candidates { + if candidate.exists() { + list.push(InstalledIde::new(ide, name, candidate.clone())); + break; + } + } +} + +fn scan_jetbrains_toolbox( + list: &mut Vec, + jb_toolbox_apps: &Path, + jb_tools: &[(Ide, &str, &str, &[&str])], +) { + if !jb_toolbox_apps.is_dir() { + return; + } + for (ide, name, exe_name, tb_names) in jb_tools { + if list.iter().any(|i| i.ide == *ide) { + continue; + } + for tb_name in *tb_names { + let tool_dir = jb_toolbox_apps.join(tb_name); + if tool_dir.is_dir() + && let Ok(channels) = std::fs::read_dir(&tool_dir) + { + for channel in channels.flatten() { + if let Ok(builds) = std::fs::read_dir(channel.path()) { + for build in builds.flatten() { + let candidate = build.path().join("bin").join(exe_name); + if candidate.exists() { + list.push(InstalledIde::new(*ide, name, candidate)); + break; + } + } + } + } + } + } + } +} + +fn scan_jetbrains_program_files( + list: &mut Vec, + pf_roots: &[PathBuf], + jb_tools: &[(Ide, &str, &str, &[&str])], +) { + for (ide, name, exe_name, _) in jb_tools { + if list.iter().any(|i| i.ide == *ide) { + continue; + } + for root in pf_roots { + let jb_dir = root.join("JetBrains"); + if jb_dir.is_dir() + && let Ok(entries) = std::fs::read_dir(&jb_dir) + { + for entry in entries.flatten() { + let candidate = entry.path().join("bin").join(exe_name); + if candidate.exists() { + list.push(InstalledIde::new(*ide, name, candidate)); + break; + } + } + } + } + } +} + +/// Check whether a file path points to a valid executable. +/// +/// On Windows, checks that the file exists and has a recognised +/// executable extension (`.exe`, `.cmd`, `.bat`, `.com`). +pub fn verify_executable(path: &Path) -> bool { + if !path.exists() || !path.is_file() { + return false; + } + + if cfg!(windows) { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| { + let lower = ext.to_lowercase(); + lower == "exe" || lower == "cmd" || lower == "bat" || lower == "com" + }) + .unwrap_or(false) + } else { + // On Unix, any existing file is accepted. A more + // thorough check could inspect the execute permission + // bits, but that isn't needed for the current scope. + true } } diff --git a/src/ide/launcher.rs b/src/ide/launcher.rs index 1ddfe53..275f703 100644 --- a/src/ide/launcher.rs +++ b/src/ide/launcher.rs @@ -19,7 +19,29 @@ use std::{path::Path, process::Command}; use anyhow::{Context, Result, bail}; -use crate::{ide::detect::detect_ides, models::ide::Ide}; +use crate::{ + ide::detect::detect_ides, + models::custom_ide::CustomIde, + models::ide::{Ide, IdeSelection}, +}; + +/// Launch an IDE selection (built-in or custom) to open a project. +pub fn launch_selection( + selection: &IdeSelection, + project: &Path, + custom_ides: &[CustomIde], +) -> Result<()> { + match selection { + IdeSelection::BuiltIn(ide) => launch(*ide, project), + IdeSelection::Custom(id) => { + let custom = custom_ides + .iter() + .find(|c| c.id == *id) + .with_context(|| format!("Custom IDE '{}' not found", id))?; + launch_custom(custom, project) + } + } +} /// Launch an IDE to open a project. /// @@ -53,18 +75,69 @@ pub fn launch(ide: Ide, project: &Path) -> Result<()> { #[doc(hidden)] pub fn launch_spawn(ide: Ide, project: &Path, executable: &Path) -> Result<()> { match ide { - Ide::Claude => { + Ide::Claude | Ide::AntigravityCli => { Command::new(executable) .current_dir(project) .status() - .context("Couldn't start Claude Code")?; + .context("Couldn't start CLI editor/agent")?; } - Ide::Terminal => { + Ide::Neovim | Ide::Helix => { Command::new(executable) - .arg("-d") .arg(project) - .spawn() - .context("Couldn't open Windows Terminal")?; + .current_dir(project) + .status() + .context("Couldn't start terminal editor")?; + } + Ide::Terminal => { + if cfg!(windows) { + let exe_name = + executable.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase(); + if exe_name.contains("wt") { + Command::new(executable) + .arg("-d") + .arg(project) + .spawn() + .context("Couldn't open Windows Terminal")?; + } else { + Command::new(executable) + .current_dir(project) + .spawn() + .context("Couldn't open Terminal")?; + } + } else if cfg!(target_os = "macos") { + if executable.to_string_lossy().contains("open") { + Command::new(executable) + .arg("-a") + .arg("Terminal") + .arg(project) + .spawn() + .context("Couldn't open macOS Terminal")?; + } else { + Command::new(executable) + .current_dir(project) + .spawn() + .context("Couldn't open Terminal shell")?; + } + } else { + let exe_name = + executable.file_name().and_then(|n| n.to_str()).unwrap_or("").to_lowercase(); + let mut cmd = Command::new(executable); + if exe_name.contains("gnome-terminal") { + cmd.arg(format!("--working-directory={}", project.display())); + } else if exe_name.contains("konsole") { + cmd.arg("--workdir").arg(project); + } else if exe_name.contains("alacritty") + || exe_name.contains("kitty") + || exe_name.contains("wezterm") + { + cmd.arg("--working-directory").arg(project); + } else if exe_name.contains("xterm") { + cmd.arg("-e").arg(format!("cd {}; $SHELL", project.display())); + } else { + cmd.current_dir(project); + } + cmd.spawn().context("Couldn't open Linux Terminal")?; + } } _ => { Command::new(executable).arg(project).spawn().context("Couldn't launch IDE")?; @@ -72,3 +145,27 @@ pub fn launch_spawn(ide: Ide, project: &Path, executable: &Path) -> Result<()> { } Ok(()) } + +/// Launch a custom IDE to open a project. +/// +/// Supports an optional argument template with `{path}` as a placeholder +/// for the project directory. When no template is provided, the project +/// path is passed as the sole argument. +pub fn launch_custom(custom: &CustomIde, project: &Path) -> Result<()> { + let project_str = project.to_string_lossy(); + + let mut cmd = Command::new(&custom.executable); + + if let Some(ref template) = custom.args_template { + let args: Vec = + template.split_whitespace().map(|arg| arg.replace("{path}", &project_str)).collect(); + + cmd.args(&args); + } else { + cmd.arg(project); + } + + cmd.spawn().with_context(|| format!("Couldn't launch {}", custom.display_name))?; + + Ok(()) +} diff --git a/src/models/custom_ide.rs b/src/models/custom_ide.rs new file mode 100644 index 0000000..4f2897a --- /dev/null +++ b/src/models/custom_ide.rs @@ -0,0 +1,35 @@ +//! Custom IDE type definitions. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// A user-configured custom IDE. +/// +/// Unlike the built-in `Ide` enum variants which are auto-detected, +/// custom IDEs are manually added by the user with an explicit +/// executable path and optional argument template. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CustomIde { + /// Unique identifier (e.g. "neovim", "fleet"). + pub id: String, + + /// Display name shown in the TUI (e.g. "Neovim", "JetBrains Fleet"). + pub display_name: String, + + /// Absolute path to the executable. + pub executable: PathBuf, + + /// Optional argument template. Use `{path}` as a placeholder for the + /// project directory. + /// + /// Example: `"--new-window {path}"` + /// + /// If `None`, the project path is passed as the sole argument. + #[serde(default)] + pub args_template: Option, + + /// Whether this IDE has been verified (executable exists). + #[serde(default)] + pub verified: bool, +} diff --git a/src/models/ide.rs b/src/models/ide.rs index 1bae9b4..e29229e 100644 --- a/src/models/ide.rs +++ b/src/models/ide.rs @@ -5,78 +5,238 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] pub enum Ide { + /// Antigravity — Google Antigravity Agentic IDE + Antigravity, + + /// Antigravity CLI — Antigravity command line tool + AntigravityCli, + /// Cursor — AI-powered code editor Cursor, /// VS Code — Visual Studio Code Vscode, - /// Claude Code — Claude AI editor + /// Windsurf — Codeium Windsurf AI IDE + Windsurf, + + /// Claude Code — Claude AI CLI editor Claude, - /// Windows Terminal — Terminal/CLI - Terminal, + /// Sublime Text — Lightweight code editor + Sublime, + + /// Neovim — Hyperextensible Vim-based text editor + Neovim, + + /// Zed — High-performance Rust-based editor + Zed, - /// IntelliJ IDEA — Java IDE + /// IntelliJ IDEA — Java & JVM IDE Idea, - /// JetBrains Rider — .NET IDE + /// PyCharm — Python IDE + PyCharm, + + /// WebStorm — JavaScript & TypeScript IDE + WebStorm, + + /// CLion — C & C++ IDE + CLion, + + /// RustRover — Rust IDE + RustRover, + + /// GoLand — Go IDE + GoLand, + + /// Fleet — JetBrains Next-Gen IDE + Fleet, + + /// JetBrains Rider — .NET & Game Dev IDE Rider, - /// Zed — High-performance editor - Zed, + /// Android Studio — Android Mobile IDE + AndroidStudio, + + /// Visual Studio — Microsoft IDE + VisualStudio, + + /// Helix — Modal terminal editor + Helix, + + /// Windows Terminal — Terminal/CLI + Terminal, } impl Ide { - /// Human-readable display label for TUI badges. - pub fn display_name(&self) -> &'static str { + fn info(&self) -> (&'static str, &'static str, ratatui::style::Color) { + use ratatui::style::Color::*; match self { - Ide::Cursor => "Cursor", - Ide::Vscode => "VS Code", - Ide::Claude => "Claude Code", - Ide::Terminal => "Terminal", - Ide::Idea => "IntelliJ IDEA", - Ide::Rider => "Rider", - Ide::Zed => "Zed", + Ide::Antigravity => ("Antigravity", "🌌", Rgb(140, 90, 255)), + Ide::AntigravityCli => ("Antigravity CLI", "⚡", Rgb(175, 120, 255)), + Ide::Cursor => ("Cursor", "🎯", Rgb(0, 220, 200)), + Ide::Vscode => ("VS Code", "🟦", Rgb(35, 145, 255)), + Ide::Windsurf => ("Windsurf", "🏄", Rgb(0, 210, 180)), + Ide::Claude => ("Claude Code", "🤖", Rgb(220, 130, 70)), + Ide::Sublime => ("Sublime Text", "🧡", Rgb(255, 150, 40)), + Ide::Neovim => ("Neovim", "🟩", Rgb(87, 175, 77)), + Ide::Zed => ("Zed", "⚡", Rgb(255, 195, 45)), + Ide::Idea => ("IntelliJ IDEA", "💡", Rgb(255, 80, 140)), + Ide::PyCharm => ("PyCharm", "🐍", Rgb(70, 210, 120)), + Ide::WebStorm => ("WebStorm", "🌐", Rgb(0, 200, 240)), + Ide::CLion => ("CLion", "⚙️", Rgb(50, 170, 220)), + Ide::RustRover => ("RustRover", "🦀", Rgb(240, 100, 50)), + Ide::GoLand => ("GoLand", "🦫", Rgb(60, 200, 210)), + Ide::Fleet => ("Fleet", "⚡", Rgb(160, 110, 255)), + Ide::Rider => ("Rider", "🚀", Rgb(180, 80, 240)), + Ide::AndroidStudio => ("Android Studio", "📱", Rgb(100, 195, 115)), + Ide::VisualStudio => ("Visual Studio", "💜", Rgb(130, 85, 225)), + Ide::Helix => ("Helix", "🧬", Rgb(170, 100, 240)), + Ide::Terminal => ("Terminal", "💻", Rgb(140, 210, 90)), } } + /// Human-readable display label for TUI badges. + pub fn display_name(&self) -> &'static str { + self.info().0 + } + /// Icon logo representing the IDE. pub fn icon(&self) -> &'static str { - match self { - Ide::Cursor => "🎯", - Ide::Vscode => "🟦", - Ide::Claude => "🤖", - Ide::Terminal => "💻", - Ide::Idea => "💡", - Ide::Rider => "🚀", - Ide::Zed => "⚡", - } + self.info().1 } /// Distinct theme color for each IDE badge. pub fn color(&self) -> ratatui::style::Color { - match self { - Ide::Cursor => ratatui::style::Color::Rgb(0, 220, 200), // Cyan / Teal - Ide::Vscode => ratatui::style::Color::Rgb(35, 145, 255), // VS Code Blue - Ide::Claude => ratatui::style::Color::Rgb(220, 130, 70), // Anthropic Copper - Ide::Terminal => ratatui::style::Color::Rgb(140, 210, 90), // Terminal Green - Ide::Idea => ratatui::style::Color::Rgb(255, 80, 140), // IntelliJ Pink - Ide::Rider => ratatui::style::Color::Rgb(180, 80, 240), // Rider Purple - Ide::Zed => ratatui::style::Color::Rgb(255, 195, 45), // Zed Amber - } + self.info().2 } /// Return the next IDE variant in cycling sequence. pub fn next(&self) -> Self { match self { + Ide::Antigravity => Ide::AntigravityCli, + Ide::AntigravityCli => Ide::Cursor, Ide::Cursor => Ide::Vscode, - Ide::Vscode => Ide::Claude, - Ide::Claude => Ide::Terminal, - Ide::Terminal => Ide::Idea, - Ide::Idea => Ide::Rider, - Ide::Rider => Ide::Zed, - Ide::Zed => Ide::Cursor, + Ide::Vscode => Ide::Windsurf, + Ide::Windsurf => Ide::Claude, + Ide::Claude => Ide::Sublime, + Ide::Sublime => Ide::Neovim, + Ide::Neovim => Ide::Zed, + Ide::Zed => Ide::Idea, + Ide::Idea => Ide::PyCharm, + Ide::PyCharm => Ide::WebStorm, + Ide::WebStorm => Ide::CLion, + Ide::CLion => Ide::RustRover, + Ide::RustRover => Ide::GoLand, + Ide::GoLand => Ide::Fleet, + Ide::Fleet => Ide::Rider, + Ide::Rider => Ide::AndroidStudio, + Ide::AndroidStudio => Ide::VisualStudio, + Ide::VisualStudio => Ide::Helix, + Ide::Helix => Ide::Terminal, + Ide::Terminal => Ide::Antigravity, + } + } +} + +/// A unified representation of a selected IDE — either a built-in auto-detected IDE or a custom IDE. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum IdeSelection { + /// A built-in supported IDE (VS Code, Cursor, Antigravity, etc.). + BuiltIn(Ide), + /// A user-configured custom IDE referenced by its unique string identifier. + Custom(String), +} + +impl Default for IdeSelection { + fn default() -> Self { + IdeSelection::BuiltIn(Ide::Vscode) + } +} + +impl From for IdeSelection { + fn from(ide: Ide) -> Self { + IdeSelection::BuiltIn(ide) + } +} + +impl std::fmt::Display for IdeSelection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IdeSelection::BuiltIn(ide) => write!(f, "{}", ide.display_name()), + IdeSelection::Custom(id) => write!(f, "{}", id), + } + } +} + +impl std::str::FromStr for IdeSelection { + type Err = String; + + fn from_str(s: &str) -> Result { + let s_lower = s.to_lowercase(); + match s_lower.as_str() { + "antigravity" => Ok(IdeSelection::BuiltIn(Ide::Antigravity)), + "antigravity-cli" | "agy" => Ok(IdeSelection::BuiltIn(Ide::AntigravityCli)), + "cursor" => Ok(IdeSelection::BuiltIn(Ide::Cursor)), + "vscode" | "code" => Ok(IdeSelection::BuiltIn(Ide::Vscode)), + "windsurf" => Ok(IdeSelection::BuiltIn(Ide::Windsurf)), + "claude" => Ok(IdeSelection::BuiltIn(Ide::Claude)), + "sublime" | "subl" => Ok(IdeSelection::BuiltIn(Ide::Sublime)), + "neovim" | "nvim" | "vim" => Ok(IdeSelection::BuiltIn(Ide::Neovim)), + "zed" => Ok(IdeSelection::BuiltIn(Ide::Zed)), + "idea" | "intellij" => Ok(IdeSelection::BuiltIn(Ide::Idea)), + "pycharm" => Ok(IdeSelection::BuiltIn(Ide::PyCharm)), + "webstorm" | "wstorm" => Ok(IdeSelection::BuiltIn(Ide::WebStorm)), + "clion" => Ok(IdeSelection::BuiltIn(Ide::CLion)), + "rustrover" => Ok(IdeSelection::BuiltIn(Ide::RustRover)), + "goland" => Ok(IdeSelection::BuiltIn(Ide::GoLand)), + "fleet" => Ok(IdeSelection::BuiltIn(Ide::Fleet)), + "rider" => Ok(IdeSelection::BuiltIn(Ide::Rider)), + "studio" | "android-studio" => Ok(IdeSelection::BuiltIn(Ide::AndroidStudio)), + "visualstudio" | "devenv" | "vs" => Ok(IdeSelection::BuiltIn(Ide::VisualStudio)), + "helix" | "hx" => Ok(IdeSelection::BuiltIn(Ide::Helix)), + "terminal" | "wt" => Ok(IdeSelection::BuiltIn(Ide::Terminal)), + _ => Ok(IdeSelection::Custom(s.to_string())), + } + } +} + +impl IdeSelection { + /// Get display name for this IDE selection. + pub fn display_name<'a>( + &'a self, + custom_ides: &'a [crate::models::custom_ide::CustomIde], + ) -> &'a str { + match self { + IdeSelection::BuiltIn(ide) => ide.display_name(), + IdeSelection::Custom(id) => { + if let Some(custom) = custom_ides.iter().find(|c| c.id == *id) { + &custom.display_name + } else { + id.as_str() + } + } + } + } + + /// Icon logo representing the IDE. + pub fn icon<'a>(&'a self, _custom_ides: &'a [crate::models::custom_ide::CustomIde]) -> &'a str { + match self { + IdeSelection::BuiltIn(ide) => ide.icon(), + IdeSelection::Custom(_) => "📝", + } + } + + /// Distinct theme color for each IDE badge. + pub fn color( + &self, + _custom_ides: &[crate::models::custom_ide::CustomIde], + ) -> ratatui::style::Color { + match self { + IdeSelection::BuiltIn(ide) => ide.color(), + IdeSelection::Custom(_) => ratatui::style::Color::Rgb(255, 180, 50), } } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 4e93177..951ad96 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -7,7 +7,9 @@ //! //! - [`ide::Ide`] — Supported IDE type enum //! - [`project::Project`] — Discovered Git repository +//! - [`custom_ide::CustomIde`] — User-configured custom IDE +pub mod custom_ide; pub mod ide; pub mod project; pub mod recent_project; diff --git a/src/models/recent_project.rs b/src/models/recent_project.rs index 45816de..d63baea 100644 --- a/src/models/recent_project.rs +++ b/src/models/recent_project.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; -use crate::models::ide::Ide; +use crate::models::ide::IdeSelection; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RecentProject { @@ -19,5 +19,5 @@ pub struct RecentProject { /// Last IDE used to open the project. #[serde(default)] - pub ide: Option, + pub ide: Option, } diff --git a/src/onboarding.rs b/src/onboarding.rs index ffff719..86342d2 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -78,7 +78,9 @@ pub fn run_onboarding() -> Result<()> { roots.push(PathBuf::from(next_root)); } - Config { projects_root: roots, default_ide, recent_projects: Vec::new() }.save()?; + let config = + Config { projects_root: roots, default_ide: default_ide.into(), ..Config::default() }; + config.save()?; outro("✨ Setup complete! You're ready to use dev-cli.")?; diff --git a/src/scanner.rs b/src/scanner.rs index d4449aa..61097a3 100644 --- a/src/scanner.rs +++ b/src/scanner.rs @@ -15,6 +15,16 @@ const IGNORED_DIRS: &[&str] = /// Discover every Git repository beneath one or more configured project roots. pub fn discover_projects(roots: &[PathBuf]) -> Result> { + let config = crate::config::Config::load().unwrap_or_default(); + discover_projects_with_config(roots, Some(config.max_depth), &config.ignore_patterns) +} + +/// Discover Git repositories with explicit max depth and ignore patterns settings. +pub fn discover_projects_with_config( + roots: &[PathBuf], + max_depth: Option, + ignore_patterns: &[String], +) -> Result> { let mut projects = Vec::new(); let mut seen = HashSet::new(); @@ -23,7 +33,7 @@ pub fn discover_projects(roots: &[PathBuf]) -> Result> { continue; } - scan_root(root, &mut projects, &mut seen)?; + scan_root_opts(root, max_depth, ignore_patterns, &mut projects, &mut seen)?; } projects.sort_by(|a, b| a.name.cmp(&b.name)); @@ -31,16 +41,33 @@ pub fn discover_projects(roots: &[PathBuf]) -> Result> { Ok(projects) } -/// Scan a single configured root for Git repositories. -fn scan_root(root: &Path, projects: &mut Vec, seen: &mut HashSet) -> Result<()> { - let walker = WalkBuilder::new(root) - .hidden(false) - .git_ignore(true) - .git_exclude(true) - .git_global(true) - .filter_entry(|entry| { +/// Scan a single configured root for Git repositories with options. +fn scan_root_opts( + root: &Path, + max_depth: Option, + ignore_patterns: &[String], + projects: &mut Vec, + seen: &mut HashSet, +) -> Result<()> { + let mut builder = WalkBuilder::new(root); + builder.hidden(false).git_ignore(true).git_exclude(true).git_global(true); + + if let Some(depth) = max_depth { + builder.max_depth(Some(depth)); + } + + let extra_ignores: Vec = ignore_patterns.to_vec(); + + let walker = builder + .filter_entry(move |entry| { let name = entry.file_name().to_string_lossy(); - !IGNORED_DIRS.contains(&name.as_ref()) + if IGNORED_DIRS.contains(&name.as_ref()) { + return false; + } + if extra_ignores.iter().any(|p| p.eq_ignore_ascii_case(&name)) { + return false; + } + true }) .build(); diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 0d54ef3..321ea5b 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::{ config::Config, ide::launcher, - models::{ide::Ide, project::Project}, + models::{ide::IdeSelection, project::Project}, }; pub fn open_path(path: &Path) -> anyhow::Result<()> { @@ -16,16 +16,19 @@ pub fn open_path(path: &Path) -> anyhow::Result<()> { /// Open a project using the real launcher and default IDE. pub fn open_project(project: &Project) -> Result<()> { - open_project_with(project, launcher::launch) + let config = Config::load()?; + open_project_with_ide(project, config.default_ide.clone(), |selection, path| { + launcher::launch_selection(&selection, path, &config.custom_ides) + }) } /// Open a project with a specific target IDE using testable injected launcher. -pub fn open_project_with_ide(project: &Project, ide: Ide, mut launch: F) -> Result<()> +pub fn open_project_with_ide(project: &Project, ide: IdeSelection, mut launch: F) -> Result<()> where - F: FnMut(Ide, &Path) -> Result<()>, + F: FnMut(IdeSelection, &Path) -> Result<()>, { let mut config = Config::load()?; - launch(ide, &project.path)?; + launch(ide.clone(), &project.path)?; config.add_recent_project_with_ide(project, ide); config.save()?; Ok(()) @@ -34,7 +37,7 @@ where /// Testable version that accepts an injected launcher. pub fn open_project_with(project: &Project, launch: F) -> Result<()> where - F: FnMut(Ide, &Path) -> Result<()>, + F: FnMut(IdeSelection, &Path) -> Result<()>, { let config = Config::load()?; open_project_with_ide(project, config.default_ide, launch) diff --git a/src/tui/app.rs b/src/tui/app.rs index 86fb761..3afc128 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -40,8 +40,11 @@ pub fn run() -> Result<()> { /// Process any pending IDE launch requested before app exit. pub fn process_pending_launch(state: &AppState) { - if let Some((ide, ref project)) = state.pending_launch { - let _ = super::actions::open_project_with_ide(project, ide, crate::ide::launcher::launch); + if let Some((ref ide, ref project)) = state.pending_launch { + let custom_ides = state.custom_ides.clone(); + let _ = super::actions::open_project_with_ide(project, ide.clone(), |selection, path| { + crate::ide::launcher::launch_selection(&selection, path, &custom_ides) + }); } } diff --git a/src/tui/event.rs b/src/tui/event.rs index fc315c5..a8dae76 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -7,7 +7,7 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use crate::{models::project::Project, tui::state::Tab}; -use super::state::AppState; +use super::state::{AppState, IdeTabFocus, InputMode, SettingsTabFocus}; /// Poll and handle terminal events. pub fn handle_events(state: &mut AppState) -> Result<()> { @@ -40,90 +40,263 @@ pub fn handle_key(key: KeyEvent, state: &mut AppState) { /// Handle key events with a custom launcher dependency injection. pub fn handle_key_with_launcher(key: KeyEvent, state: &mut AppState, launcher: L) where - L: Fn(crate::models::ide::Ide, &Project) -> Result<()>, + L: Fn(crate::models::ide::IdeSelection, &Project) -> Result<()>, +{ + match state.input_mode { + InputMode::Normal => handle_normal_key(key, state, launcher), + InputMode::Editing => handle_editing_key(key, state), + } +} + +/// Handle keys in Normal mode (navigation, actions, search). +fn handle_normal_key(key: KeyEvent, state: &mut AppState, launcher: L) +where + L: Fn(crate::models::ide::IdeSelection, &Project) -> Result<()>, { match key.code { - KeyCode::Esc => state.quit(), + KeyCode::Esc => { + if state.active_tab == Tab::Ide && state.ide_tab_focus == IdeTabFocus::ConfirmDelete { + state.cancel_delete_custom_ide(); + } else if !state.search_query.is_empty() { + state.clear_search(); + state.clamp_selection(); + } else { + state.quit(); + } + } KeyCode::F(1) => { let _ = state.refresh(); } - KeyCode::Tab => { - state.cycle_selected_ide(); - } + KeyCode::Tab => match state.active_tab { + Tab::Projects | Tab::Recent => state.cycle_selected_ide(), + _ => {} + }, KeyCode::Down => state.move_down(), KeyCode::Up => state.move_up(), KeyCode::Backspace => { - state.pop_char(); - state.clamp_selection(); + if matches!(state.active_tab, Tab::Projects) { + state.pop_char(); + state.clamp_selection(); + } } - KeyCode::Char(c) => { - state.push_char(c); - state.clamp_selection(); + KeyCode::Delete => { + if matches!(state.active_tab, Tab::Projects) { + state.delete_char(); + state.clamp_selection(); + } } - KeyCode::Enter => match state.active_tab { + KeyCode::Enter => handle_enter(state, launcher), + + KeyCode::Left => { + if matches!(state.active_tab, Tab::Projects) && !state.search_query.is_empty() { + state.move_search_cursor_left(); + } else { + state.active_tab = match state.active_tab { + Tab::Recent => Tab::Recent, + Tab::Projects => Tab::Recent, + Tab::Ide => Tab::Projects, + Tab::Settings => Tab::Ide, + }; + + state.selected_index = 0; + state.clear_search(); + state.ide_status_message = None; + state.settings_status_message = None; + state.clamp_selection(); + } + } + + KeyCode::Right => { + if matches!(state.active_tab, Tab::Projects) && !state.search_query.is_empty() { + state.move_search_cursor_right(); + } else { + state.active_tab = match state.active_tab { + Tab::Recent => Tab::Projects, + Tab::Projects => Tab::Ide, + Tab::Ide => Tab::Settings, + Tab::Settings => Tab::Settings, + }; + + state.selected_index = 0; + state.clear_search(); + state.ide_status_message = None; + state.settings_status_message = None; + state.clamp_selection(); + } + } + + KeyCode::Home => { + if matches!(state.active_tab, Tab::Projects) { + state.move_search_cursor_home(); + } + } + + KeyCode::End => { + if matches!(state.active_tab, Tab::Projects) { + state.move_search_cursor_end(); + } + } + + KeyCode::Char(c) => match state.active_tab { Tab::Projects => { - let is_folder = state - .visible_items() - .get(state.selected_index) - .map(|n| n.is_folder) - .unwrap_or(false); - - if is_folder { - state.toggle_selected(); - } else if let Some(project) = state.selected_project() { - let ide = state.get_project_ide(&project.path); - if launcher(ide, project).is_ok() { - state.pending_launch = Some((ide, project.clone())); - state.quit(); - } + state.push_char(c); + state.clamp_selection(); + } + Tab::Ide => handle_ide_tab_char(c, state), + Tab::Settings => handle_settings_tab_char(c, state), + _ => {} + }, + + _ => {} + } +} + +/// Handle Enter key based on active tab. +fn handle_enter(state: &mut AppState, launcher: L) +where + L: Fn(crate::models::ide::IdeSelection, &Project) -> Result<()>, +{ + match state.active_tab { + Tab::Projects => { + let is_folder = state + .visible_items() + .get(state.selected_index) + .map(|n| n.is_folder) + .unwrap_or(false); + + if is_folder { + state.toggle_selected(); + } else if let Some(project) = state.selected_project() { + let ide = state.get_project_ide(&project.path); + if launcher(ide.clone(), project).is_ok() { + state.pending_launch = Some((ide, project.clone())); + state.quit(); } } + } - Tab::Recent => { - if let Some(recent) = state.selected_recent_project() { - let mut project = Project::new(recent.path.clone(), recent.path.clone()); - if !recent.name.is_empty() { - project.name = recent.name.clone(); - } - let ide = state.get_recent_project_ide(recent); - if launcher(ide, &project).is_ok() { - state.pending_launch = Some((ide, project)); - state.quit(); - } + Tab::Recent => { + if let Some(recent) = state.selected_recent_project() { + let mut project = Project::new(recent.path.clone(), recent.path.clone()); + if !recent.name.is_empty() { + project.name = recent.name.clone(); + } + let ide = state.get_recent_project_ide(recent); + if launcher(ide.clone(), &project).is_ok() { + state.pending_launch = Some((ide, project)); + state.quit(); } } + } + + Tab::Ide => { + if state.ide_tab_focus == IdeTabFocus::ConfirmDelete { + state.remove_selected_custom_ide(); + } else { + state.set_default_selected_ide(); + } + } + + Tab::Settings => {} + } +} +/// Handle letter keys on the IDE tab (Normal mode). +fn handle_ide_tab_char(c: char, state: &mut AppState) { + if state.ide_tab_focus == IdeTabFocus::ConfirmDelete { + match c { + 'y' | 'Y' => state.remove_selected_custom_ide(), + 'n' | 'N' => state.cancel_delete_custom_ide(), _ => {} - }, + } + return; + } - KeyCode::Left => { - state.active_tab = match state.active_tab { - Tab::Recent => Tab::Recent, - Tab::Projects => Tab::Recent, - Tab::Ide => Tab::Projects, - Tab::Settings => Tab::Ide, - }; + match c { + 'a' | 'A' => { + state.ide_tab_focus = IdeTabFocus::AddForm; + state.input_mode = InputMode::Editing; + state.ide_form_name.clear(); + state.ide_form_name_cursor = 0; + state.ide_form_path.clear(); + state.ide_form_path_cursor = 0; + state.ide_form_args.clear(); + state.ide_form_args_cursor = 0; + state.ide_form_field = 0; + state.ide_status_message = None; + } + 'd' | 'D' => state.request_delete_selected_custom_ide(), + 's' | 'S' => state.set_default_selected_ide(), + _ => {} + } +} - state.selected_index = 0; - state.clamp_selection(); +/// Handle letter keys on the Settings tab (Normal mode). +fn handle_settings_tab_char(c: char, state: &mut AppState) { + match c { + 'a' | 'A' => { + state.settings_tab_focus = SettingsTabFocus::AddRoot; + state.input_mode = InputMode::Editing; + state.settings_add_root.clear(); + state.settings_add_root_cursor = 0; + state.settings_status_message = None; } + 'd' | 'D' => state.remove_selected_root(), + 't' | 'T' => state.cycle_theme(), + 'm' | 'M' => state.cycle_max_depth(), + 'l' | 'L' => state.cycle_recent_limit(), + 'r' | 'R' => state.toggle_auto_refresh(), + 'X' => state.reset_config_defaults(), + _ => {} + } +} - KeyCode::Right => { - state.active_tab = match state.active_tab { - Tab::Recent => Tab::Projects, - Tab::Projects => Tab::Ide, - Tab::Ide => Tab::Settings, - Tab::Settings => Tab::Settings, - }; +/// Handle keys in Editing mode (form input). +fn handle_editing_key(key: KeyEvent, state: &mut AppState) { + match key.code { + KeyCode::Esc => { + // Cancel editing and return to Normal mode. + state.input_mode = InputMode::Normal; + match state.active_tab { + Tab::Ide => state.ide_tab_focus = IdeTabFocus::List, + Tab::Settings => state.settings_tab_focus = SettingsTabFocus::List, + _ => {} + } + } - state.selected_index = 0; - state.clamp_selection(); + KeyCode::Enter => match state.active_tab { + Tab::Ide => { + if let Err(msg) = state.submit_ide_form() { + state.ide_status_message = Some(format!("✗ {msg}")); + } + } + Tab::Settings => { + if let Err(msg) = state.submit_add_root() { + state.settings_status_message = Some(format!("✗ {msg}")); + } + } + _ => {} + }, + + KeyCode::Tab => { + if matches!(state.active_tab, Tab::Ide) { + state.cycle_ide_form_field(); + } } + KeyCode::Left => state.editing_move_cursor_left(), + KeyCode::Right => state.editing_move_cursor_right(), + KeyCode::Home => state.editing_move_cursor_home(), + KeyCode::End => state.editing_move_cursor_end(), + KeyCode::Backspace => state.editing_backspace(), + KeyCode::Delete => state.editing_delete(), + + KeyCode::Char(c) => state.editing_insert_char(c), + _ => {} } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 9b1cfd7..7ac69c6 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -5,7 +5,8 @@ pub mod app; pub mod data; pub mod event; pub mod state; -mod theme; +pub mod theme; + pub mod tree; pub mod ui; pub mod widgets; diff --git a/src/tui/state.rs b/src/tui/state.rs index 0591547..ab83602 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -4,7 +4,8 @@ use std::{collections::HashMap, path::PathBuf}; use crate::{ config::Config, - models::{ide::Ide, project::Project, recent_project::RecentProject}, + ide::detect::verify_executable, + models::{custom_ide::CustomIde, ide::Ide, project::Project, recent_project::RecentProject}, tui::tree::{DisplayNode, TreeNode, build_project_tree, flatten_filtered_tree, flatten_tree}, }; @@ -17,6 +18,42 @@ pub enum Tab { Settings, } +/// Keyboard input routing mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputMode { + /// Normal mode — arrow keys navigate, chars go to search. + Normal, + /// Editing a text field (IDE tab or Settings tab form). + Editing, +} + +/// Which element has focus on the IDE tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdeTabFocus { + /// Browsing the list of IDEs. + List, + /// Filling in the "Add IDE" form. + AddForm, + /// Confirming deletion of a custom IDE. + ConfirmDelete, +} + +/// Which element has focus on the Settings tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsTabFocus { + /// Browsing settings list. + List, + /// Adding a new project root. + AddRoot, +} + +/// An entry in the IDE tab list — either a built-in detected IDE or a custom one. +#[derive(Debug, Clone)] +pub enum IdeListEntry { + BuiltIn { ide: Ide, is_default: bool }, + Custom { custom: CustomIde, is_default: bool }, +} + #[derive(Debug)] /// Core state for the Ratatui application. pub struct AppState { @@ -32,6 +69,9 @@ pub struct AppState { /// Current search query. pub search_query: String, + /// Character cursor position in search query. + pub search_cursor: usize, + /// Currently selected row in the active list. pub selected_index: usize, @@ -42,26 +82,88 @@ pub struct AppState { pub active_tab: Tab, /// Configured default IDE. - pub default_ide: Ide, + pub default_ide: crate::models::ide::IdeSelection, /// Installed IDEs available on the system. pub installed_ides: Vec, /// Per-project IDE selection overrides. - pub ide_overrides: HashMap, + pub ide_overrides: HashMap, /// Project and IDE pending launch after TUI exit. - pub pending_launch: Option<(Ide, Project)>, + pub pending_launch: Option<(crate::models::ide::IdeSelection, Project)>, + + // --- Input mode --- + /// Current input routing mode. + pub input_mode: InputMode, + + // --- IDE tab state --- + /// IDE tab focus area. + pub ide_tab_focus: IdeTabFocus, + + /// User-configured custom IDEs loaded from config. + pub custom_ides: Vec, + + /// IDE add-form: display name. + pub ide_form_name: String, + pub ide_form_name_cursor: usize, + + /// IDE add-form: executable path. + pub ide_form_path: String, + pub ide_form_path_cursor: usize, + + /// IDE add-form: argument template. + pub ide_form_args: String, + pub ide_form_args_cursor: usize, + + /// IDE add-form: which field is active (0=name, 1=path, 2=args). + pub ide_form_field: usize, + + /// IDE tab status message (e.g. "✓ Verified" or "✗ Not found"). + pub ide_status_message: Option, + + // --- Settings tab state --- + /// Settings tab focus area. + pub settings_tab_focus: SettingsTabFocus, + + /// Settings tab: new root path input. + pub settings_add_root: String, + pub settings_add_root_cursor: usize, + + /// Project root directories (loaded from config). + pub project_roots: Vec, + + /// Settings tab status message. + pub settings_status_message: Option, + + /// Maximum search depth for repository scanner. + pub max_depth: usize, + + /// Extra directory names ignored during scanning. + pub ignore_patterns: Vec, + + /// Active TUI theme palette name. + pub theme: String, + + /// Maximum recent projects to retain. + pub recent_projects_limit: usize, + + /// Auto refresh projects on launch. + pub auto_refresh_on_launch: bool, } impl Default for AppState { fn default() -> Self { - let default_ide = Config::load().map(|config| config.default_ide).unwrap_or(Ide::Vscode); + let config = Config::load().unwrap_or_default(); + let default_ide = config.default_ide; + let mut custom_ides = config.custom_ides.clone(); + custom_ides.sort_by_key(|a| a.display_name.to_lowercase()); + let project_roots = config.projects_root.clone(); let detected: Vec = crate::ide::detect::detect_ides().into_iter().map(|i| i.ide).collect(); - let installed_ides = if detected.is_empty() { + let mut installed_ides = if detected.is_empty() { vec![ Ide::Cursor, Ide::Vscode, @@ -74,12 +176,14 @@ impl Default for AppState { } else { detected }; + installed_ides.sort_by_key(|a| a.display_name().to_lowercase()); Self { projects: Vec::new(), project_tree: Vec::new(), recent_projects: Vec::new(), search_query: String::new(), + search_cursor: 0, selected_index: 0, should_quit: false, active_tab: Tab::Projects, @@ -87,6 +191,31 @@ impl Default for AppState { installed_ides, ide_overrides: HashMap::new(), pending_launch: None, + + input_mode: InputMode::Normal, + + ide_tab_focus: IdeTabFocus::List, + custom_ides, + ide_form_name: String::new(), + ide_form_name_cursor: 0, + ide_form_path: String::new(), + ide_form_path_cursor: 0, + ide_form_args: String::new(), + ide_form_args_cursor: 0, + ide_form_field: 0, + ide_status_message: None, + + settings_tab_focus: SettingsTabFocus::List, + settings_add_root: String::new(), + settings_add_root_cursor: 0, + project_roots, + settings_status_message: None, + + max_depth: config.max_depth, + ignore_patterns: config.ignore_patterns, + theme: config.theme, + recent_projects_limit: config.recent_projects_limit, + auto_refresh_on_launch: config.auto_refresh_on_launch, } } } @@ -98,6 +227,11 @@ impl AppState { Self::default() } + /// Returns the active theme palette. + pub fn palette(&self) -> crate::tui::theme::Palette { + crate::tui::theme::get_palette(&self.theme) + } + pub fn set_projects(&mut self, projects: Vec) { self.projects = projects.clone(); self.project_tree = build_project_tree(projects); @@ -114,41 +248,140 @@ impl AppState { let projects = crate::tui::data::load_projects()?; self.set_projects(projects); self.load_recent_projects(); + self.reload_config_state(); self.clamp_selection(); Ok(()) } + /// Reload config-derived state (custom IDEs, project roots, default IDE, settings). + pub fn reload_config_state(&mut self) { + if let Ok(config) = Config::load() { + let mut custom_ides = config.custom_ides; + custom_ides.sort_by_key(|a| a.display_name.to_lowercase()); + self.custom_ides = custom_ides; + self.project_roots = config.projects_root; + self.default_ide = config.default_ide; + self.max_depth = config.max_depth; + self.ignore_patterns = config.ignore_patterns; + self.theme = config.theme; + self.recent_projects_limit = config.recent_projects_limit; + self.auto_refresh_on_launch = config.auto_refresh_on_launch; + } + } + + pub fn cycle_theme(&mut self) { + let themes = ["neon", "cyberpunk", "catppuccin", "monokai", "high-contrast"]; + let current_idx = + themes.iter().position(|&t| t.eq_ignore_ascii_case(&self.theme)).unwrap_or(0); + let next_idx = (current_idx + 1) % themes.len(); + self.theme = themes[next_idx].to_string(); + + if let Ok(mut config) = Config::load() { + config.theme = self.theme.clone(); + let _ = config.save(); + } + let palette_name = crate::tui::theme::get_palette(&self.theme).name; + self.settings_status_message = Some(format!("✓ Theme set to {}", palette_name)); + } + + pub fn cycle_max_depth(&mut self) { + let depths = [2, 3, 4, 5, 6, 8]; + let current_idx = depths.iter().position(|&d| d == self.max_depth).unwrap_or(2); + let next_idx = (current_idx + 1) % depths.len(); + self.max_depth = depths[next_idx]; + + if let Ok(mut config) = Config::load() { + config.max_depth = self.max_depth; + let _ = config.save(); + } + let _ = self.refresh(); + self.settings_status_message = Some(format!("✓ Max scan depth set to {}", self.max_depth)); + } + + pub fn cycle_recent_limit(&mut self) { + let limits = [5, 10, 15, 20, 25]; + let current_idx = limits.iter().position(|&l| l == self.recent_projects_limit).unwrap_or(1); + let next_idx = (current_idx + 1) % limits.len(); + self.recent_projects_limit = limits[next_idx]; + + if let Ok(mut config) = Config::load() { + config.recent_projects_limit = self.recent_projects_limit; + let _ = config.save(); + } + self.settings_status_message = + Some(format!("✓ Recent projects limit set to {}", self.recent_projects_limit)); + } + + pub fn toggle_auto_refresh(&mut self) { + self.auto_refresh_on_launch = !self.auto_refresh_on_launch; + + if let Ok(mut config) = Config::load() { + config.auto_refresh_on_launch = self.auto_refresh_on_launch; + let _ = config.save(); + } + let status = if self.auto_refresh_on_launch { "Enabled" } else { "Disabled" }; + self.settings_status_message = Some(format!("✓ Auto-refresh on launch {}", status)); + } + + pub fn reset_config_defaults(&mut self) { + let default_config = Config::default(); + if default_config.save().is_ok() { + self.reload_config_state(); + let _ = self.refresh(); + self.settings_status_message = Some("✓ Settings reset to defaults".to_string()); + } + } + pub fn quit(&mut self) { self.should_quit = true; } + /// Returns all available IDE selections (detected built-in + user custom IDEs). + pub fn available_ide_selections(&self) -> Vec { + let mut selections: Vec = self + .installed_ides + .iter() + .map(|&ide| crate::models::ide::IdeSelection::BuiltIn(ide)) + .collect(); + + for custom in &self.custom_ides { + selections.push(crate::models::ide::IdeSelection::Custom(custom.id.clone())); + } + + selections + } + /// Get the target IDE for a project path (override or default). - pub fn get_project_ide(&self, path: &std::path::Path) -> Ide { - let ide = self.ide_overrides.get(path).copied().unwrap_or(self.default_ide); - self.ensure_installed_ide(ide) + pub fn get_project_ide(&self, path: &std::path::Path) -> crate::models::ide::IdeSelection { + let ide = self.ide_overrides.get(path).cloned().unwrap_or_else(|| self.default_ide.clone()); + self.ensure_available_ide(ide) } /// Get the target IDE for a recent project (override, recent default, or config default). - pub fn get_recent_project_ide(&self, recent: &RecentProject) -> Ide { - let ide = if let Some(&override_ide) = self.ide_overrides.get(&recent.path) { - override_ide + pub fn get_recent_project_ide( + &self, + recent: &RecentProject, + ) -> crate::models::ide::IdeSelection { + let ide = if let Some(override_ide) = self.ide_overrides.get(&recent.path) { + override_ide.clone() } else { - recent.ide.unwrap_or(self.default_ide) + recent.ide.clone().unwrap_or_else(|| self.default_ide.clone()) }; - self.ensure_installed_ide(ide) + self.ensure_available_ide(ide) } - fn ensure_installed_ide(&self, ide: Ide) -> Ide { - if !self.installed_ides.is_empty() && !self.installed_ides.contains(&ide) { - self.installed_ides[0] - } else { - ide - } + fn ensure_available_ide( + &self, + ide: crate::models::ide::IdeSelection, + ) -> crate::models::ide::IdeSelection { + let available = self.available_ide_selections(); + if !available.is_empty() && !available.contains(&ide) { available[0].clone() } else { ide } } - /// Cycle through available installed IDEs for the currently selected project (Projects or Recent tab). + /// Cycle through available installed built-in and custom IDEs for the currently selected project (Projects or Recent tab). pub fn cycle_selected_ide(&mut self) { - if self.installed_ides.is_empty() { + let available = self.available_ide_selections(); + if available.is_empty() { return; } @@ -164,9 +397,9 @@ impl AppState { _ => return, }; - let next_ide = match self.installed_ides.iter().position(|&i| i == current_ide) { - Some(idx) => self.installed_ides[(idx + 1) % self.installed_ides.len()], - None => self.installed_ides[0], + let next_ide = match available.iter().position(|i| i == ¤t_ide) { + Some(idx) => available[(idx + 1) % available.len()].clone(), + None => available[0].clone(), }; self.ide_overrides.insert(path, next_ide); @@ -226,7 +459,8 @@ impl AppState { let len = match self.active_tab { Tab::Projects => self.visible_items().len(), Tab::Recent => self.recent_projects.len(), - _ => 0, + Tab::Ide => self.ide_tab_items().len(), + Tab::Settings => self.settings_item_count(), }; if len == 0 { @@ -246,20 +480,144 @@ impl AppState { } pub fn push_char(&mut self, c: char) { - self.search_query.push(c); + let char_idx = self.search_cursor; + let byte_idx = self + .search_query + .char_indices() + .nth(char_idx) + .map(|(i, _)| i) + .unwrap_or(self.search_query.len()); + self.search_query.insert(byte_idx, c); + self.search_cursor += 1; self.selected_index = 0; } pub fn pop_char(&mut self) { - self.search_query.pop(); + if self.search_cursor > 0 { + let remove_idx = self.search_cursor - 1; + if let Some((byte_idx, _)) = self.search_query.char_indices().nth(remove_idx) { + self.search_query.remove(byte_idx); + self.search_cursor -= 1; + } + } + self.selected_index = 0; + } + + pub fn delete_char(&mut self) { + let char_idx = self.search_cursor; + if let Some((byte_idx, _)) = self.search_query.char_indices().nth(char_idx) { + self.search_query.remove(byte_idx); + } self.selected_index = 0; } + pub fn move_search_cursor_left(&mut self) { + self.search_cursor = self.search_cursor.saturating_sub(1); + } + + pub fn move_search_cursor_right(&mut self) { + let max_len = self.search_query.chars().count(); + if self.search_cursor < max_len { + self.search_cursor += 1; + } + } + + pub fn move_search_cursor_home(&mut self) { + self.search_cursor = 0; + } + + pub fn move_search_cursor_end(&mut self) { + self.search_cursor = self.search_query.chars().count(); + } + + pub fn clear_search(&mut self) { + self.search_query.clear(); + self.search_cursor = 0; + self.selected_index = 0; + } + + pub fn active_editing_field_and_cursor(&mut self) -> Option<(&mut String, &mut usize)> { + if self.input_mode != InputMode::Editing { + return None; + } + match self.active_tab { + Tab::Ide => match self.ide_form_field { + 0 => Some((&mut self.ide_form_name, &mut self.ide_form_name_cursor)), + 1 => Some((&mut self.ide_form_path, &mut self.ide_form_path_cursor)), + 2 => Some((&mut self.ide_form_args, &mut self.ide_form_args_cursor)), + _ => Some((&mut self.ide_form_name, &mut self.ide_form_name_cursor)), + }, + Tab::Settings => { + Some((&mut self.settings_add_root, &mut self.settings_add_root_cursor)) + } + _ => None, + } + } + + pub fn editing_insert_char(&mut self, c: char) { + if let Some((field, cursor)) = self.active_editing_field_and_cursor() { + let char_idx = *cursor; + let byte_idx = + field.char_indices().nth(char_idx).map(|(i, _)| i).unwrap_or(field.len()); + field.insert(byte_idx, c); + *cursor += 1; + } + } + + pub fn editing_backspace(&mut self) { + if let Some((field, cursor)) = self.active_editing_field_and_cursor() + && *cursor > 0 + { + let remove_idx = *cursor - 1; + if let Some((byte_idx, _)) = field.char_indices().nth(remove_idx) { + field.remove(byte_idx); + *cursor -= 1; + } + } + } + + pub fn editing_delete(&mut self) { + if let Some((field, cursor)) = self.active_editing_field_and_cursor() { + let char_idx = *cursor; + if let Some((byte_idx, _)) = field.char_indices().nth(char_idx) { + field.remove(byte_idx); + } + } + } + + pub fn editing_move_cursor_left(&mut self) { + if let Some((_, cursor)) = self.active_editing_field_and_cursor() { + *cursor = cursor.saturating_sub(1); + } + } + + pub fn editing_move_cursor_right(&mut self) { + if let Some((field, cursor)) = self.active_editing_field_and_cursor() { + let max_len = field.chars().count(); + if *cursor < max_len { + *cursor += 1; + } + } + } + + pub fn editing_move_cursor_home(&mut self) { + if let Some((_, cursor)) = self.active_editing_field_and_cursor() { + *cursor = 0; + } + } + + pub fn editing_move_cursor_end(&mut self) { + if let Some((field, cursor)) = self.active_editing_field_and_cursor() { + *cursor = field.chars().count(); + } + } + pub fn clamp_selection(&mut self) { let len = match self.active_tab { Tab::Projects => self.visible_items().len(), Tab::Recent => self.recent_projects.len(), - _ => 0, + Tab::Ide => self.ide_tab_items().len(), + Tab::Settings => self.settings_item_count(), }; if len == 0 { @@ -284,4 +642,241 @@ impl AppState { self.selected_index.saturating_sub(visible_rows.saturating_sub(1)) } + + // ────────────────────────────────────────────────────── + // IDE Tab + // ────────────────────────────────────────────────────── + + /// Build the merged list of detected built-in + custom IDEs. + pub fn ide_tab_items(&self) -> Vec { + let mut entries: Vec = self + .installed_ides + .iter() + .map(|&ide| IdeListEntry::BuiltIn { + ide, + is_default: self.default_ide == crate::models::ide::IdeSelection::BuiltIn(ide), + }) + .collect(); + + for custom in &self.custom_ides { + entries.push(IdeListEntry::Custom { + custom: custom.clone(), + is_default: self.default_ide + == crate::models::ide::IdeSelection::Custom(custom.id.clone()), + }); + } + + entries + } + + /// Set the currently selected built-in or custom IDE as the global default. + pub fn set_default_selected_ide(&mut self) { + let items = self.ide_tab_items(); + match items.get(self.selected_index) { + Some(IdeListEntry::BuiltIn { ide, .. }) => { + let selection = crate::models::ide::IdeSelection::BuiltIn(*ide); + self.default_ide = selection.clone(); + + if let Ok(mut config) = Config::load() { + config.default_ide = selection; + let _ = config.save(); + } + + self.ide_status_message = Some(format!("✓ {} set as default", ide.display_name())); + } + Some(IdeListEntry::Custom { custom, .. }) => { + let selection = crate::models::ide::IdeSelection::Custom(custom.id.clone()); + self.default_ide = selection.clone(); + + if let Ok(mut config) = Config::load() { + config.default_ide = selection; + let _ = config.save(); + } + + self.ide_status_message = Some(format!("✓ {} set as default", custom.display_name)); + } + None => {} + } + } + + /// Request deletion of the currently selected custom IDE (triggers confirmation prompt). + pub fn request_delete_selected_custom_ide(&mut self) { + let items = self.ide_tab_items(); + if let Some(IdeListEntry::Custom { custom, .. }) = items.get(self.selected_index) { + self.ide_tab_focus = IdeTabFocus::ConfirmDelete; + self.ide_status_message = + Some(format!("Are you sure you wish to delete '{}'?", custom.display_name)); + } + } + + /// Cancel custom IDE deletion request. + pub fn cancel_delete_custom_ide(&mut self) { + self.ide_tab_focus = IdeTabFocus::List; + self.ide_status_message = None; + } + + /// Remove the currently selected custom IDE. + pub fn remove_selected_custom_ide(&mut self) { + let items = self.ide_tab_items(); + if let Some(IdeListEntry::Custom { custom, .. }) = items.get(self.selected_index) { + let id = custom.id.clone(); + let name = custom.display_name.clone(); + self.custom_ides.retain(|c| c.id != id); + + if self.default_ide == crate::models::ide::IdeSelection::Custom(id.clone()) { + self.default_ide = self + .available_ide_selections() + .first() + .cloned() + .unwrap_or(crate::models::ide::IdeSelection::BuiltIn(Ide::Vscode)); + } + + if let Ok(mut config) = Config::load() { + config.remove_custom_ide(&id); + config.default_ide = self.default_ide.clone(); + let _ = config.save(); + } + + self.ide_tab_focus = IdeTabFocus::List; + self.clamp_selection(); + self.ide_status_message = Some(format!("✓ {} removed", name)); + } else { + self.ide_tab_focus = IdeTabFocus::List; + } + } + + /// Submit the IDE add form — validate, verify executable, and persist a new custom IDE. + pub fn submit_ide_form(&mut self) -> Result<(), String> { + let name = self.ide_form_name.trim().to_string(); + let path = self.ide_form_path.trim().to_string(); + let args = self.ide_form_args.trim().to_string(); + + if name.is_empty() { + return Err("Name cannot be empty".to_string()); + } + if path.is_empty() { + return Err("Path cannot be empty".to_string()); + } + + let executable = PathBuf::from(&path); + if !verify_executable(&executable) { + return Err(format!("Executable path '{}' does not exist or is invalid", path)); + } + + let id = name.to_lowercase().replace(' ', "-"); + + if self.custom_ides.iter().any(|c| c.id == id) { + return Err(format!("IDE '{}' already exists", name)); + } + + let custom = CustomIde { + id, + display_name: name, + executable, + args_template: if args.is_empty() { None } else { Some(args) }, + verified: true, + }; + + self.custom_ides.push(custom.clone()); + self.custom_ides.sort_by_key(|a| a.display_name.to_lowercase()); + + if let Ok(mut config) = Config::load() { + config.add_custom_ide(custom); + let _ = config.save(); + } + + // Reset form + self.ide_form_name.clear(); + self.ide_form_name_cursor = 0; + self.ide_form_path.clear(); + self.ide_form_path_cursor = 0; + self.ide_form_args.clear(); + self.ide_form_args_cursor = 0; + self.ide_form_field = 0; + self.ide_tab_focus = IdeTabFocus::List; + self.input_mode = InputMode::Normal; + + self.ide_status_message = Some("✓ Added and verified".to_string()); + + Ok(()) + } + + /// Get the active IDE form field's mutable reference. + pub fn active_ide_form_field(&mut self) -> &mut String { + match self.ide_form_field { + 0 => &mut self.ide_form_name, + 1 => &mut self.ide_form_path, + 2 => &mut self.ide_form_args, + _ => &mut self.ide_form_name, + } + } + + /// Cycle the IDE form's active field. + pub fn cycle_ide_form_field(&mut self) { + self.ide_form_field = (self.ide_form_field + 1) % 3; + } + + // ────────────────────────────────────────────────────── + // Settings Tab + // ────────────────────────────────────────────────────── + + /// Number of items on the Settings tab list. + pub fn settings_item_count(&self) -> usize { + self.project_roots.len() + } + + /// Submit the "Add Root" form — validate and persist. + pub fn submit_add_root(&mut self) -> Result<(), String> { + let path_str = self.settings_add_root.trim().to_string(); + if path_str.is_empty() { + return Err("Path cannot be empty".to_string()); + } + + let path = PathBuf::from(&path_str); + + if !path.exists() || !path.is_dir() { + return Err("Path does not exist or is not a directory".to_string()); + } + + if self.project_roots.contains(&path) { + return Err("Root already exists".to_string()); + } + + self.project_roots.push(path.clone()); + + if let Ok(mut config) = Config::load() { + config.add_project_root(path); + let _ = config.save(); + } + + self.settings_add_root.clear(); + self.settings_add_root_cursor = 0; + self.settings_tab_focus = SettingsTabFocus::List; + self.input_mode = InputMode::Normal; + + self.settings_status_message = Some("✓ Root added".to_string()); + + // Refresh projects after adding a root. + let _ = self.refresh(); + + Ok(()) + } + + /// Remove the currently selected project root. + pub fn remove_selected_root(&mut self) { + if self.selected_index < self.project_roots.len() { + let removed = self.project_roots.remove(self.selected_index); + + if let Ok(mut config) = Config::load() { + config.projects_root = self.project_roots.clone(); + let _ = config.save(); + } + + self.clamp_selection(); + self.settings_status_message = Some(format!("✓ Removed {}", removed.display())); + + // Refresh projects after removing a root. + let _ = self.refresh(); + } + } } diff --git a/src/tui/theme.rs b/src/tui/theme.rs index c70993e..2e6ef9a 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -34,3 +34,53 @@ pub const INFO: Color = Color::Rgb(125, 180, 255); /// Secondary accent / purple actions. pub const PURPLE: Color = Color::Rgb(180, 120, 255); + +/// Palette configuration containing all UI colors. +#[allow(dead_code)] +#[derive(Debug, Clone, Copy)] +pub struct Palette { + pub name: &'static str, + pub primary: Color, + pub background: Color, + pub text: Color, + pub muted: Color, + pub border: Color, + pub success: Color, + pub warning: Color, + pub danger: Color, + pub info: Color, + pub purple: Color, + pub highlight_bg: Color, +} + +fn from_rgb_list(name: &'static str, colors: &[(u8, u8, u8); 11]) -> Palette { + let c = |i: usize| Color::Rgb(colors[i].0, colors[i].1, colors[i].2); + Palette { + name, + primary: c(0), + background: c(1), + text: c(2), + muted: c(3), + border: c(4), + success: c(5), + warning: c(6), + danger: c(7), + info: c(8), + purple: c(9), + highlight_bg: c(10), + } +} + +#[rustfmt::skip] +pub fn get_palette(theme_name: &str) -> Palette { + match theme_name.to_lowercase().as_str() { + "cyberpunk" => from_rgb_list("Cyberpunk", &[(255, 0, 127), (10, 5, 20), (240, 240, 255), (140, 100, 180), (200, 0, 255), (0, 255, 180), (255, 200, 0), (255, 50, 80), (0, 220, 255), (210, 80, 255), (120, 0, 150)]), + "catppuccin" => from_rgb_list("Catppuccin", &[(137, 180, 250), (30, 30, 46), (205, 214, 244), (147, 153, 178), (88, 91, 112), (166, 227, 161), (249, 226, 175), (243, 139, 168), (148, 226, 213), (203, 166, 247), (69, 71, 90)]), + "monokai" => from_rgb_list("Monokai", &[(166, 226, 46), (39, 40, 34), (248, 248, 242), (117, 113, 94), (73, 72, 62), (166, 226, 46), (230, 219, 116), (249, 38, 114), (102, 217, 239), (174, 129, 255), (73, 72, 62)]), + "high-contrast" => from_rgb_list("High Contrast", &[(255, 255, 0), (0, 0, 0), (255, 255, 255), (180, 180, 180), (255, 255, 255), (0, 255, 0), (255, 255, 0), (255, 0, 0), (0, 255, 255), (255, 0, 255), (50, 50, 50)]), + _ => Palette { + name: "Neon Purple", primary: PRIMARY, background: BACKGROUND, text: TEXT, muted: MUTED, + border: BORDER, success: SUCCESS, warning: WARNING, danger: DANGER, info: INFO, purple: PURPLE, highlight_bg: HIGHLIGHT_BG, + }, + } +} diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 9cf1cd5..227b946 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -7,16 +7,14 @@ use ratatui::{ widgets::Block, }; -use crate::tui::widgets::{footer, header, placeholder, project_list, recent, search, tabs}; +use crate::tui::widgets::{footer, header, ide_list, project_list, recent, search, settings, tabs}; -use super::{ - state::{AppState, Tab}, - theme, -}; +use super::state::{AppState, Tab}; /// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, state: &AppState) { - frame.render_widget(Block::default().bg(theme::BACKGROUND), frame.area()); + let palette = state.palette(); + frame.render_widget(Block::default().bg(palette.background), frame.area()); let chunks = Layout::vertical([ Constraint::Length(2), // Header @@ -27,23 +25,27 @@ pub fn render(frame: &mut Frame, state: &AppState) { ]) .split(frame.area()); - header::render(frame, chunks[0]); - tabs::render(frame, chunks[1], state); + header::render(frame, chunks[0], palette); + tabs::render(frame, chunks[1], state, palette); match state.active_tab { Tab::Projects => { - search::render(frame, chunks[2], state); - project_list::render(frame, chunks[3], state); + search::render(frame, chunks[2], state, palette); + project_list::render(frame, chunks[3], state, palette); } Tab::Recent => { frame.render_widget(Block::default(), chunks[2]); - recent::render(frame, chunks[3], state); + recent::render(frame, chunks[3], state, palette); + } + Tab::Ide => { + frame.render_widget(Block::default(), chunks[2]); + ide_list::render(frame, chunks[3], state, palette); } - Tab::Ide | Tab::Settings => { + Tab::Settings => { frame.render_widget(Block::default(), chunks[2]); - placeholder::render(frame, chunks[3], state.active_tab); + settings::render(frame, chunks[3], state); } } - footer::render(frame, chunks[4]); + footer::render(frame, chunks[4], state, palette); } diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index fb640a8..e1bd3b3 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -8,23 +8,14 @@ use ratatui::{ widgets::Paragraph, }; -use crate::tui::theme; +use crate::tui::{ + state::{AppState, IdeTabFocus, InputMode, SettingsTabFocus, Tab}, + theme::Palette, +}; /// Render the widget onto the given frame and area. -pub fn render(frame: &mut Frame, area: Rect) { - let line = Line::from(vec![ - Span::styled("↵ Enter", Style::default().fg(theme::SUCCESS)), - Span::raw(" "), - Span::styled("↑↓ Navigate", Style::default().fg(theme::PRIMARY)), - Span::raw(" "), - Span::styled("Tab IDE", Style::default().fg(theme::PRIMARY)), - Span::raw(" "), - Span::styled("/ Search", Style::default().fg(theme::WARNING)), - Span::raw(" "), - Span::styled("F1 Refresh", Style::default().fg(theme::PURPLE)), - Span::raw(" "), - Span::styled("Esc Quit", Style::default().fg(theme::DANGER)), - ]); +pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let line = build_footer_line(state, palette); frame.render_widget( Paragraph::new(line) @@ -33,3 +24,94 @@ pub fn render(frame: &mut Frame, area: Rect) { area, ); } + +/// Build the footer line based on the active tab and input mode. +fn build_footer_line(state: &AppState, palette: Palette) -> Line<'static> { + if state.input_mode == InputMode::Editing { + return match state.active_tab { + Tab::Ide => Line::from(vec![ + Span::styled("Tab", Style::default().fg(palette.primary)), + Span::raw(" Next Field "), + Span::styled("↵ Submit", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("Esc Cancel", Style::default().fg(palette.danger)), + ]), + Tab::Settings => Line::from(vec![ + Span::styled("↵ Submit", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("Esc Cancel", Style::default().fg(palette.danger)), + ]), + _ => Line::from(""), + }; + } + + match state.active_tab { + Tab::Projects => Line::from(vec![ + Span::styled("↵ Enter", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("↑↓ Navigate", Style::default().fg(palette.primary)), + Span::raw(" "), + Span::styled("Tab IDE", Style::default().fg(palette.info)), + Span::raw(" "), + Span::styled("/ Search", Style::default().fg(palette.warning)), + Span::raw(" "), + Span::styled("F1 Refresh", Style::default().fg(palette.purple)), + Span::raw(" "), + Span::styled("Esc Quit", Style::default().fg(palette.danger)), + ]), + Tab::Recent => Line::from(vec![ + Span::styled("↵ Enter", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("↑↓ Navigate", Style::default().fg(palette.primary)), + Span::raw(" "), + Span::styled("Tab IDE", Style::default().fg(palette.info)), + Span::raw(" "), + Span::styled("Esc Quit", Style::default().fg(palette.danger)), + ]), + + Tab::Ide => match state.ide_tab_focus { + IdeTabFocus::List => Line::from(vec![ + Span::styled("↑↓ Navigate", Style::default().fg(palette.primary)), + Span::raw(" "), + Span::styled("a Add", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("d Delete", Style::default().fg(palette.danger)), + Span::raw(" "), + Span::styled("s Default", Style::default().fg(palette.warning)), + Span::raw(" "), + Span::styled("Esc Quit", Style::default().fg(palette.danger)), + ]), + IdeTabFocus::ConfirmDelete => Line::from(vec![ + Span::styled("y Yes Delete", Style::default().fg(palette.danger)), + Span::raw(" "), + Span::styled("n / Esc Cancel", Style::default().fg(palette.primary)), + ]), + IdeTabFocus::AddForm => Line::from(""), + }, + Tab::Settings => { + if state.settings_tab_focus == SettingsTabFocus::List { + Line::from(vec![ + Span::styled("↑↓ Nav", Style::default().fg(palette.primary)), + Span::raw(" "), + Span::styled("a Add Root", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("d Delete", Style::default().fg(palette.danger)), + Span::raw(" "), + Span::styled("t Theme", Style::default().fg(palette.purple)), + Span::raw(" "), + Span::styled("m Depth", Style::default().fg(palette.warning)), + Span::raw(" "), + Span::styled("l Limit", Style::default().fg(palette.info)), + Span::raw(" "), + Span::styled("r AutoRef", Style::default().fg(palette.success)), + Span::raw(" "), + Span::styled("X Reset", Style::default().fg(palette.warning)), + Span::raw(" "), + Span::styled("Esc Quit", Style::default().fg(palette.danger)), + ]) + } else { + Line::from("") + } + } + } +} diff --git a/src/tui/widgets/header.rs b/src/tui/widgets/header.rs index 0bda28a..3f6fbd4 100644 --- a/src/tui/widgets/header.rs +++ b/src/tui/widgets/header.rs @@ -8,13 +8,13 @@ use ratatui::{ widgets::Paragraph, }; -use crate::tui::theme; +use crate::tui::theme::Palette; /// Render the widget onto the given frame and area. -pub fn render(frame: &mut Frame, area: Rect) { +pub fn render(frame: &mut Frame, area: Rect, palette: Palette) { let title = Line::from(vec![ Span::raw("🚀 "), - Span::styled("dev-cli", Style::default().fg(theme::PRIMARY).add_modifier(Modifier::BOLD)), + Span::styled("dev-cli", Style::default().fg(palette.primary).add_modifier(Modifier::BOLD)), ]); frame.render_widget(Paragraph::new(title).alignment(Alignment::Center), area); diff --git a/src/tui/widgets/ide_list.rs b/src/tui/widgets/ide_list.rs new file mode 100644 index 0000000..8994889 --- /dev/null +++ b/src/tui/widgets/ide_list.rs @@ -0,0 +1,316 @@ +//! IDE management tab widget. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, ListItem, Paragraph}, +}; + +use crate::models::custom_ide::CustomIde; +use crate::tui::{ + state::{AppState, IdeTabFocus, InputMode}, + theme::Palette, + widgets::list::{Notice, render_centered_notice, render_list_with_symbol}, +}; + +/// Render the IDE management tab. +pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + match state.ide_tab_focus { + IdeTabFocus::List | IdeTabFocus::ConfirmDelete => { + render_ide_list(frame, area, state, palette) + } + IdeTabFocus::AddForm => render_split_view(frame, area, state, palette), + } +} + +/// Render the IDE list split into Installed IDEs (top) and Custom IDEs (bottom). +fn render_ide_list(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let installed_count = state.installed_ides.len(); + let custom_count = state.custom_ides.len(); + let total_count = installed_count + custom_count; + + if total_count == 0 { + render_centered_notice( + frame, + area, + Notice { + title: " Installed & Custom IDEs ", + icon: "🔧", + icon_color: palette.muted, + heading: "No IDEs detected", + subtext: "Press 'a' to add a custom IDE.", + }, + palette, + ); + return; + } + + let (list_area, status_area) = if let Some(ref msg) = state.ide_status_message { + let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + (chunks[0], Some((chunks[1], msg))) + } else { + (area, None) + }; + + let split_chunks = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(list_area); + + let installed_items: Vec = state + .installed_ides + .iter() + .enumerate() + .map(|(idx, &ide)| { + let is_default = state.default_ide == crate::models::ide::IdeSelection::BuiltIn(ide); + let is_selected = state.selected_index == idx; + render_installed_entry( + ide, + is_default, + is_selected, + split_chunks[0].width.saturating_sub(4) as usize, + palette, + ) + }) + .collect(); + + let selected_top = + if state.selected_index < installed_count { Some(state.selected_index) } else { None }; + + render_list_with_symbol( + frame, + split_chunks[0], + format!(" Installed IDEs ({}) ", installed_count), + installed_items, + selected_top, + "", + palette, + ); + + let custom_items = build_custom_ide_items( + state, + installed_count, + split_chunks[1].width.saturating_sub(4) as usize, + true, + palette, + ); + + let selected_bottom = if state.selected_index >= installed_count && custom_count > 0 { + Some(state.selected_index - installed_count) + } else { + None + }; + + render_list_with_symbol( + frame, + split_chunks[1], + format!(" Custom IDEs ({}) ", custom_count), + custom_items, + selected_bottom, + "", + palette, + ); + + if let Some((area, msg)) = status_area { + let color = if msg.starts_with('✓') { + palette.success + } else if msg.starts_with("Are you sure") { + palette.warning + } else { + palette.danger + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color)))), + area, + ); + } +} + +/// Render the split view: IDE list split on top, form on bottom. +fn render_split_view(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let chunks = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area); + + let top_chunks = + Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(chunks[0]); + + let installed_items = state + .installed_ides + .iter() + .map(|&ide| { + let is_default = state.default_ide == crate::models::ide::IdeSelection::BuiltIn(ide); + render_installed_entry( + ide, + is_default, + false, + top_chunks[0].width.saturating_sub(4) as usize, + palette, + ) + }) + .collect(); + render_list_with_symbol( + frame, + top_chunks[0], + format!(" Installed IDEs ({}) ", state.installed_ides.len()), + installed_items, + None, + "", + palette, + ); + + let custom_items = build_custom_ide_items( + state, + 0, + top_chunks[1].width.saturating_sub(4) as usize, + false, + palette, + ); + render_list_with_symbol( + frame, + top_chunks[1], + format!(" Custom IDEs ({}) ", state.custom_ides.len()), + custom_items, + None, + "", + palette, + ); + + // Bottom half: Add form + render_add_form(frame, chunks[1], state, palette); +} + +/// Helper to build list items for custom IDEs. +fn build_custom_ide_items( + state: &AppState, + installed_offset: usize, + width: usize, + interactive: bool, + palette: Palette, +) -> Vec> { + if state.custom_ides.is_empty() { + vec![ListItem::new(vec![ + Line::from(Span::styled( + " No custom IDEs configured. Press 'a' to add.", + Style::default().fg(palette.muted), + )), + Line::default(), + ])] + } else { + state + .custom_ides + .iter() + .enumerate() + .map(|(idx, custom)| { + let is_default = state.default_ide + == crate::models::ide::IdeSelection::Custom(custom.id.clone()); + let is_selected = interactive && (state.selected_index == installed_offset + idx); + render_custom_entry(custom, is_default, is_selected, width, palette) + }) + .collect() + } +} + +#[allow(clippy::too_many_arguments)] +#[rustfmt::skip] +fn render_entry_row( + left_name: &str, status_text: &str, status_color: ratatui::style::Color, extra_tag: Option<&str>, + is_default: bool, is_selected: bool, available_width: usize, palette: Palette, +) -> Vec> { + let prefix = if is_selected { "❯ " } else { " " }; + let tag_len = extra_tag.map_or(0, |t| unicode_width::UnicodeWidthStr::width(t) + 2); + let right_len = unicode_width::UnicodeWidthStr::width(status_text) + tag_len + if is_default { 11 } else { 0 }; + let left_len = unicode_width::UnicodeWidthStr::width(left_name); + let spacing = available_width.saturating_sub(2 + left_len + right_len); + + let p_style = if is_selected { Style::default().fg(palette.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette.muted) }; + let l_style = Style::default().fg(palette.text).add_modifier(Modifier::BOLD); + + let mut spans = vec![ + Span::styled(prefix.to_string(), p_style), + Span::styled(left_name.to_string(), l_style), + Span::raw(" ".repeat(spacing)), + ]; + + if is_default { + spans.push(Span::styled("★ DEFAULT ".to_string(), Style::default().fg(palette.primary).add_modifier(Modifier::BOLD))); + } + spans.push(Span::styled(status_text.to_string(), Style::default().fg(status_color))); + if let Some(tag) = extra_tag { + spans.push(Span::styled(format!(" {}", tag), Style::default().fg(palette.muted))); + } + spans +} + +#[rustfmt::skip] +fn render_installed_entry( + ide: crate::models::ide::Ide, is_default: bool, is_selected: bool, available_width: usize, palette: Palette, +) -> ListItem<'static> { + let spans = render_entry_row(ide.display_name(), "✓ Detected", palette.success, None, is_default, is_selected, available_width, palette); + ListItem::new(vec![Line::from(spans), Line::default()]) +} + +/// Render a custom IDE entry. +#[rustfmt::skip] +fn render_custom_entry( + custom: &CustomIde, is_default: bool, is_selected: bool, available_width: usize, palette: Palette, +) -> ListItem<'static> { + let (status_text, status_color) = if custom.verified { ("✓ Verified", palette.success) } else { ("✗ Unverified", palette.warning) }; + let spans = render_entry_row(&custom.display_name, status_text, status_color, Some("(custom)"), is_default, is_selected, available_width, palette); + ListItem::new(vec![ + Line::from(spans), + Line::from(vec![Span::raw(" "), Span::styled(custom.executable.display().to_string(), Style::default().fg(palette.muted))]), + Line::default(), + ]) +} + +/// Render the "Add Custom IDE" form. +fn render_add_form(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let is_editing = state.input_mode == InputMode::Editing; + + let name_active = state.ide_form_field == 0; + let path_active = state.ide_form_field == 1; + let args_active = state.ide_form_field == 2; + + let mut line_name = vec![Span::styled(" Name: ", Style::default().fg(palette.info))]; + line_name.extend(crate::tui::widgets::list::render_field_spans( + &state.ide_form_name, + state.ide_form_name_cursor, + name_active, + is_editing, + palette, + )); + + let mut line_path = vec![Span::styled(" Path: ", Style::default().fg(palette.info))]; + line_path.extend(crate::tui::widgets::list::render_field_spans( + &state.ide_form_path, + state.ide_form_path_cursor, + path_active, + is_editing, + palette, + )); + + let mut line_args = vec![Span::styled(" Args: ", Style::default().fg(palette.info))]; + line_args.extend(crate::tui::widgets::list::render_field_spans( + &state.ide_form_args, + state.ide_form_args_cursor, + args_active, + is_editing, + palette, + )); + + let mut lines = + vec![Line::from(""), Line::from(line_name), Line::from(line_path), Line::from(line_args)]; + crate::tui::widgets::list::append_form_footer( + &mut lines, + state.ide_status_message.as_deref(), + palette, + ); + + let form = Paragraph::new(lines).block( + Block::default().title(" Add Custom IDE ").borders(Borders::ALL).border_style( + Style::default().fg(if is_editing { palette.primary } else { palette.border }), + ), + ); + + frame.render_widget(form, area); +} diff --git a/src/tui/widgets/list.rs b/src/tui/widgets/list.rs index 8064a4d..f235b59 100644 --- a/src/tui/widgets/list.rs +++ b/src/tui/widgets/list.rs @@ -8,25 +8,41 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, }; -use crate::tui::theme; +use crate::tui::theme::Palette; /// Render a styled selectable list used throughout the dashboard. +#[allow(clippy::too_many_arguments)] pub fn render_list( frame: &mut Frame, area: Rect, title: String, items: Vec, selected: Option, + palette: Palette, +) { + render_list_with_symbol(frame, area, title, items, selected, "❯ ", palette); +} + +/// Render a styled selectable list with a customizable highlight symbol. +#[allow(clippy::too_many_arguments)] +pub fn render_list_with_symbol( + frame: &mut Frame, + area: Rect, + title: String, + items: Vec, + selected: Option, + symbol: &str, + palette: Palette, ) { let list = List::new(items) .block( Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), + .border_style(Style::default().fg(palette.border)), ) - .highlight_style(Style::default().bg(theme::HIGHLIGHT_BG).add_modifier(Modifier::BOLD)) - .highlight_symbol("❯ "); + .highlight_style(Style::default().bg(palette.highlight_bg).add_modifier(Modifier::BOLD)) + .highlight_symbol(symbol); let mut state = ListState::default(); state.select(selected); @@ -44,25 +60,91 @@ pub struct Notice<'a> { } /// Render a centered notice paragraph inside a bordered block. -pub fn render_centered_notice(frame: &mut Frame, area: Rect, notice: Notice<'_>) { +pub fn render_centered_notice(frame: &mut Frame, area: Rect, notice: Notice<'_>, palette: Palette) { let widget = Paragraph::new(vec![ Line::from(""), Line::from(Span::styled(notice.icon, Style::default().fg(notice.icon_color))), Line::from(""), Line::from(Span::styled( notice.heading, - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + Style::default().fg(palette.text).add_modifier(Modifier::BOLD), )), Line::from(""), - Line::from(Span::styled(notice.subtext, Style::default().fg(theme::MUTED))), + Line::from(Span::styled(notice.subtext, Style::default().fg(palette.muted))), ]) .alignment(Alignment::Center) .block( Block::default() .title(notice.title) .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), + .border_style(Style::default().fg(palette.border)), ); frame.render_widget(widget, area); } + +/// Helper to split a string at a character cursor offset into: +/// `(before_str, cursor_char, after_str)` +pub fn split_at_cursor(s: &str, cursor: usize) -> (&str, &str, &str) { + let char_count = s.chars().count(); + let cursor = cursor.min(char_count); + let byte_idx = s.char_indices().nth(cursor).map(|(i, _)| i).unwrap_or(s.len()); + let before = &s[..byte_idx]; + if cursor < char_count { + let next_byte_idx = s.char_indices().nth(cursor + 1).map(|(i, _)| i).unwrap_or(s.len()); + let cursor_char = &s[byte_idx..next_byte_idx]; + let after = &s[next_byte_idx..]; + (before, cursor_char, after) + } else { + (before, " ", "") + } +} + +/// Helper to append common Submit/Cancel action buttons and optional status error line to form lines. +pub fn append_form_footer<'a>( + lines: &mut Vec>, + status_message: Option<&str>, + palette: Palette, +) { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Enter", Style::default().fg(palette.success)), + Span::raw(" Submit "), + Span::styled("Esc", Style::default().fg(palette.danger)), + Span::raw(" Cancel"), + ])); + + if let Some(msg) = status_message + && msg.starts_with('✗') + { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {msg}"), + Style::default().fg(palette.danger), + ))); + } +} + +/// Helper to render spans for an editable form input field with active cursor highlighting. +pub fn render_field_spans<'a>( + val: &'a str, + cursor_pos: usize, + is_active: bool, + is_editing: bool, + palette: Palette, +) -> Vec> { + if is_active && is_editing { + let (before, cursor_char, after) = split_at_cursor(val, cursor_pos); + vec![ + Span::styled(before.to_string(), Style::default().fg(palette.text)), + Span::styled( + cursor_char.to_string(), + Style::default().bg(palette.primary).fg(palette.background), + ), + Span::styled(after.to_string(), Style::default().fg(palette.text)), + ] + } else { + vec![Span::styled(val.to_string(), Style::default().fg(palette.muted))] + } +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index fa23661..176396f 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -1,8 +1,10 @@ pub mod footer; pub mod header; +pub mod ide_list; pub mod list; pub mod placeholder; pub mod project_list; pub mod recent; pub mod search; +pub mod settings; pub mod tabs; diff --git a/src/tui/widgets/placeholder.rs b/src/tui/widgets/placeholder.rs index 6bf63fc..a535852 100644 --- a/src/tui/widgets/placeholder.rs +++ b/src/tui/widgets/placeholder.rs @@ -4,12 +4,12 @@ use ratatui::{Frame, layout::Rect}; use crate::tui::{ state::Tab, - theme, + theme::Palette, widgets::list::{Notice, render_centered_notice}, }; /// Render a coming soon placeholder for unimplemented tabs. -pub fn render(frame: &mut Frame, area: Rect, tab: Tab) { +pub fn render(frame: &mut Frame, area: Rect, tab: Tab, palette: Palette) { let title = match tab { Tab::Ide => " IDE ", Tab::Settings => " Settings ", @@ -22,9 +22,10 @@ pub fn render(frame: &mut Frame, area: Rect, tab: Tab) { Notice { title, icon: "🚧", - icon_color: theme::WARNING, + icon_color: palette.warning, heading: "Coming Soon", subtext: "This tab will be implemented in a later milestone.", }, + palette, ); } diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index 82e1426..c4bae99 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -11,7 +11,7 @@ use ratatui::{ use crate::{ tui::{ state::AppState, - theme, + theme::Palette, widgets::list::{Notice, render_centered_notice, render_list}, }, utils::path::display_path, @@ -19,7 +19,7 @@ use crate::{ use unicode_width::UnicodeWidthStr; /// Render the widget onto the given frame and area. -pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { +pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { // Route to the correct tab. let items = state.visible_items(); let count = state.filtered_projects().len(); @@ -32,10 +32,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { Notice { title: &title, icon: "📂", - icon_color: theme::MUTED, + icon_color: palette.muted, heading: "No projects found", subtext: "Try another search.", }, + palette, ); return; } @@ -49,9 +50,9 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let (icon, icon_color, path_padding) = if node.is_folder { let icon_str = if node.is_expanded { "[-] 📂 " } else { "[+] 📁 " }; - (icon_str, theme::WARNING, " ") + (icon_str, palette.warning, " ") } else { - ("📦 ", theme::INFO, " ") + ("📦 ", palette.info, " ") }; let path_str = display_path(node.path); @@ -59,12 +60,12 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let mut line2_spans = vec![ Span::raw(indent.clone()), Span::raw(path_padding), - Span::styled(path_str.clone(), Style::default().fg(theme::MUTED)), + Span::styled(path_str.clone(), Style::default().fg(palette.muted)), ]; if !node.is_folder { let ide = state.get_project_ide(node.path); - let badge = format!("[{}]", ide.display_name()); + let badge = format!("[{}]", ide.display_name(&state.custom_ides)); let left_len = UnicodeWidthStr::width(indent.as_str()) + UnicodeWidthStr::width(path_padding) + UnicodeWidthStr::width(path_str.as_str()); @@ -79,7 +80,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { line2_spans.push(Span::raw(" ".repeat(spaces))); line2_spans.push(Span::styled( badge, - Style::default().fg(ide.color()).add_modifier(Modifier::BOLD), + Style::default().fg(ide.color(&state.custom_ides)).add_modifier(Modifier::BOLD), )); } @@ -89,7 +90,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { Span::styled(icon, Style::default().fg(icon_color)), Span::styled( node.name, - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + Style::default().fg(palette.text).add_modifier(Modifier::BOLD), ), ]), Line::from(line2_spans), @@ -98,5 +99,5 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { }) .collect(); - render_list(frame, area, title, list_items, Some(state.selected_index)); + render_list(frame, area, title, list_items, Some(state.selected_index), palette); } diff --git a/src/tui/widgets/recent.rs b/src/tui/widgets/recent.rs index 7c03d0e..bbff04b 100644 --- a/src/tui/widgets/recent.rs +++ b/src/tui/widgets/recent.rs @@ -15,7 +15,7 @@ use crate::{ models::recent_project::RecentProject, tui::{ state::AppState, - theme, + theme::Palette, widgets::list::{Notice, render_centered_notice, render_list}, }, utils::path::display_path, @@ -36,7 +36,7 @@ fn format_age(timestamp: u64) -> String { } /// Render the widget onto the given frame and area. -pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { +pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { let recent_projects: &[RecentProject] = &state.recent_projects; let title = format!(" Recent Projects ({}) ", recent_projects.len()); @@ -48,10 +48,11 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { Notice { title: &title, icon: "🕘", - icon_color: theme::MUTED, + icon_color: palette.muted, heading: "No recent projects", subtext: "Open a project from the Projects tab.", }, + palette, ); return; } @@ -69,7 +70,7 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let line1_spacing = inner_width.saturating_sub(left_width + age_width); let ide = state.get_recent_project_ide(project); - let badge = format!("[{}]", ide.display_name()); + let badge = format!("[{}]", ide.display_name(&state.custom_ides)); let path_str = display_path(&project.path); let path_left = format!(" {}", path_str); @@ -86,18 +87,20 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { Line::from(vec![ Span::styled( left, - Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), + Style::default().fg(palette.text).add_modifier(Modifier::BOLD), ), Span::raw(" ".repeat(line1_spacing)), - Span::styled(age, Style::default().fg(theme::INFO)), + Span::styled(age, Style::default().fg(palette.info)), ]), Line::from(vec![ Span::raw(" "), - Span::styled(path_str, Style::default().fg(theme::MUTED)), + Span::styled(path_str, Style::default().fg(palette.muted)), Span::raw(" ".repeat(line2_spacing)), Span::styled( badge, - Style::default().fg(ide.color()).add_modifier(Modifier::BOLD), + Style::default() + .fg(ide.color(&state.custom_ides)) + .add_modifier(Modifier::BOLD), ), ]), Line::default(), @@ -107,5 +110,5 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let selected = state.selected_index.min(recent_projects.len().saturating_sub(1)); - render_list(frame, area, title, items, Some(selected)); + render_list(frame, area, title, items, Some(selected), palette); } diff --git a/src/tui/widgets/search.rs b/src/tui/widgets/search.rs index b94cc1b..3817de7 100644 --- a/src/tui/widgets/search.rs +++ b/src/tui/widgets/search.rs @@ -8,31 +8,35 @@ use ratatui::{ widgets::{Block, Borders, Paragraph}, }; -use crate::tui::{state::AppState, theme}; +use crate::tui::{state::AppState, theme::Palette}; /// Render the widget onto the given frame and area. -pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { +pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { let text = if state.search_query.is_empty() { Line::from(vec![ - Span::styled("🔍 ", Style::default().fg(theme::PRIMARY)), + Span::styled("🔍 ", Style::default().fg(palette.primary)), + Span::styled(" ", Style::default().bg(palette.primary).fg(palette.background)), Span::styled( "Search projects...", - Style::default().fg(theme::MUTED).add_modifier(Modifier::ITALIC), + Style::default().fg(palette.muted).add_modifier(Modifier::ITALIC), ), ]) } else { + let (before, cursor_char, after) = + crate::tui::widgets::list::split_at_cursor(&state.search_query, state.search_cursor); Line::from(vec![ - Span::styled("🔍 ", Style::default().fg(theme::PRIMARY)), - Span::styled(&state.search_query, Style::default().fg(theme::TEXT)), - Span::styled("█", Style::default().fg(theme::PRIMARY)), + Span::styled("🔍 ", Style::default().fg(palette.primary)), + Span::styled(before, Style::default().fg(palette.text)), + Span::styled(cursor_char, Style::default().bg(palette.primary).fg(palette.background)), + Span::styled(after, Style::default().fg(palette.text)), ]) }; - let widget = Paragraph::new(text).style(Style::default().bg(theme::BACKGROUND)).block( + let widget = Paragraph::new(text).style(Style::default().bg(palette.background)).block( Block::default() .title(" Search ") .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER)), + .border_style(Style::default().fg(palette.border)), ); frame.render_widget(widget, area); diff --git a/src/tui/widgets/settings.rs b/src/tui/widgets/settings.rs new file mode 100644 index 0000000..7b03777 --- /dev/null +++ b/src/tui/widgets/settings.rs @@ -0,0 +1,227 @@ +//! Settings tab widget. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, ListItem, Paragraph, Wrap}, +}; + +use crate::{ + tui::{ + state::{AppState, InputMode, SettingsTabFocus}, + theme::Palette, + widgets::list::render_list, + }, + utils::path::display_path, +}; + +/// Render the Settings tab. +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let palette = state.palette(); + match state.settings_tab_focus { + SettingsTabFocus::List => render_settings_list(frame, area, state, palette), + SettingsTabFocus::AddRoot => render_split_view(frame, area, state, palette), + } +} + +/// Helper to build list items for project roots. +fn build_root_list_items(state: &AppState, palette: Palette) -> Vec> { + state + .project_roots + .iter() + .map(|root| { + let path_str = display_path(root); + ListItem::new(vec![ + Line::from(vec![ + Span::styled("📁 ", Style::default().fg(palette.warning)), + Span::styled( + path_str, + Style::default().fg(palette.text).add_modifier(Modifier::BOLD), + ), + ]), + Line::default(), + ]) + }) + .collect() +} + +/// Render the settings list with preferences cards. +fn render_settings_list(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let (main_area, status_area) = if let Some(ref msg) = state.settings_status_message { + let chunks = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + (chunks[0], Some((chunks[1], msg))) + } else { + (area, None) + }; + + let vertical_chunks = + Layout::vertical([Constraint::Percentage(45), Constraint::Percentage(55)]).split(main_area); + + // Top: Project Roots list + let title = format!(" Project Roots ({}) ", state.project_roots.len()); + let list_items = build_root_list_items(state, palette); + + let selected = if state.project_roots.is_empty() { + None + } else { + Some(state.selected_index.min(state.project_roots.len().saturating_sub(1))) + }; + + render_list(frame, vertical_chunks[0], title, list_items, selected, palette); + + // Bottom: Preferences (left) + Diagnostics (right) + let bottom_chunks = + Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(vertical_chunks[1]); + + render_preferences_card(frame, bottom_chunks[0], state, palette); + render_diagnostics_card(frame, bottom_chunks[1], state, palette); + + if let Some((area, msg)) = status_area { + let color = if msg.starts_with('✓') { palette.success } else { palette.danger }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color)))), + area, + ); + } +} + +/// Render preferences & search settings card. +fn render_preferences_card(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let ignores_str = if state.ignore_patterns.is_empty() { + "None".to_string() + } else { + state.ignore_patterns.join(", ") + }; + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" [t] Theme Palette: ", Style::default().fg(palette.info)), + Span::styled( + palette.name, + Style::default().fg(palette.primary).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" [m] Max Scan Depth: ", Style::default().fg(palette.info)), + Span::styled(format!("{} levels", state.max_depth), Style::default().fg(palette.text)), + ]), + Line::from(vec![ + Span::styled(" [l] Recents Limit: ", Style::default().fg(palette.info)), + Span::styled( + format!("{} items", state.recent_projects_limit), + Style::default().fg(palette.text), + ), + ]), + Line::from(vec![ + Span::styled(" [r] Auto Refresh: ", Style::default().fg(palette.info)), + Span::styled( + if state.auto_refresh_on_launch { "Enabled" } else { "Disabled" }, + Style::default().fg(if state.auto_refresh_on_launch { + palette.success + } else { + palette.muted + }), + ), + ]), + Line::from(vec![Span::styled(" Ignored Directories:", Style::default().fg(palette.info))]), + Line::from(vec![Span::styled( + format!(" {}", ignores_str), + Style::default().fg(palette.muted), + )]), + ]; + + let block = Block::default() + .title(" Preferences & Scanner ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.border)); + + frame.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: true }), area); +} + +/// Render system & config diagnostics card. +fn render_diagnostics_card(frame: &mut Frame, area: Rect, _state: &AppState, palette: Palette) { + let config_path = crate::config::Config::path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|_| "Unknown".to_string()); + + let os_info = format!("{} ({})", std::env::consts::OS, std::env::consts::ARCH); + + let lines = vec![ + Line::from(""), + Line::from(vec![ + Span::styled(" Version: ", Style::default().fg(palette.info)), + Span::styled( + format!("dev-cli v{}", env!("CARGO_PKG_VERSION")), + Style::default().fg(palette.text).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled(" Platform: ", Style::default().fg(palette.info)), + Span::styled(os_info, Style::default().fg(palette.text)), + ]), + Line::from(vec![Span::styled(" Config File:", Style::default().fg(palette.info))]), + Line::from(vec![Span::styled( + format!(" {}", config_path), + Style::default().fg(palette.muted), + )]), + Line::from(""), + Line::from(vec![Span::styled( + " [X] Reset Settings to Defaults", + Style::default().fg(palette.danger).add_modifier(Modifier::BOLD), + )]), + ]; + + let block = Block::default() + .title(" Diagnostics & System ") + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.border)); + + frame.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: true }), area); +} + +/// Render the split view: list on top, add form on bottom. +fn render_split_view(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let chunks = + Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area); + + // Top: project roots list (non-interactive) + let title = format!(" Project Roots ({}) ", state.project_roots.len()); + let list_items = build_root_list_items(state, palette); + render_list(frame, chunks[0], title, list_items, None, palette); + + // Bottom: add form + render_add_root_form(frame, chunks[1], state, palette); +} + +/// Render the "Add Project Root" form. +fn render_add_root_form(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let is_editing = state.input_mode == InputMode::Editing; + + let mut line_path_spans = vec![Span::styled(" Path: ", Style::default().fg(palette.info))]; + line_path_spans.extend(crate::tui::widgets::list::render_field_spans( + &state.settings_add_root, + state.settings_add_root_cursor, + true, + is_editing, + palette, + )); + + let mut lines = vec![Line::from(""), Line::from(line_path_spans)]; + crate::tui::widgets::list::append_form_footer( + &mut lines, + state.settings_status_message.as_deref(), + palette, + ); + + let form = Paragraph::new(lines).block( + Block::default().title(" Add Project Root ").borders(Borders::ALL).border_style( + Style::default().fg(if is_editing { palette.primary } else { palette.border }), + ), + ); + + frame.render_widget(form, area); +} diff --git a/src/tui/widgets/tabs.rs b/src/tui/widgets/tabs.rs index e5c2caf..747762b 100644 --- a/src/tui/widgets/tabs.rs +++ b/src/tui/widgets/tabs.rs @@ -10,11 +10,11 @@ use ratatui::{ use crate::tui::{ state::{AppState, Tab}, - theme, + theme::Palette, }; /// Render the widget onto the given frame and area. -pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { +pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { let titles = [ Line::from(" Recent "), Line::from(" Projects "), @@ -31,9 +31,12 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let tabs = Tabs::new(titles) .select(selected) - .style(Style::default().fg(theme::MUTED)) + .style(Style::default().fg(palette.muted)) .highlight_style( - Style::default().fg(theme::BACKGROUND).bg(theme::PRIMARY).add_modifier(Modifier::BOLD), + Style::default() + .fg(palette.background) + .bg(palette.primary) + .add_modifier(Modifier::BOLD), ) .divider(" "); diff --git a/tests/common/temp_config.rs b/tests/common/temp_config.rs index 765dd9d..a5cf628 100644 --- a/tests/common/temp_config.rs +++ b/tests/common/temp_config.rs @@ -1,4 +1,7 @@ -use dev_cli::{config::Config, models::ide::Ide}; +use dev_cli::{ + config::Config, + models::ide::{Ide, IdeSelection}, +}; use temp_env::with_var; use tempfile::TempDir; @@ -6,8 +9,10 @@ use tempfile::TempDir; pub fn test_config() -> Config { Config { projects_root: vec![TempDir::new().unwrap().path().to_path_buf()], - default_ide: Ide::Vscode, + default_ide: IdeSelection::BuiltIn(Ide::Vscode), recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() } } diff --git a/tests/config.rs b/tests/config.rs index f10a09e..6056664 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,5 +1,8 @@ use dev_cli::models::recent_project::RecentProject; -use dev_cli::{config::Config, models::ide::Ide}; +use dev_cli::{ + config::Config, + models::ide::{Ide, IdeSelection}, +}; use serial_test::serial; use std::{ path::PathBuf, @@ -32,15 +35,17 @@ fn default_config_has_project_root() { #[test] fn config_round_trip_serialization() { let config = Config { - default_ide: Ide::Vscode, + default_ide: IdeSelection::BuiltIn(Ide::Vscode), projects_root: vec![PathBuf::from("C:/Projects")], recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() }; let toml = toml::to_string(&config).unwrap(); let decoded: Config = toml::from_str(&toml).unwrap(); - assert_eq!(decoded.default_ide, Ide::Vscode); + assert_eq!(decoded.default_ide, IdeSelection::BuiltIn(Ide::Vscode)); assert_eq!(decoded.projects_root.len(), 1); assert_eq!(decoded.projects_root[0], PathBuf::from("C:/Projects")); } @@ -48,13 +53,15 @@ fn config_round_trip_serialization() { #[test] fn config_multiple_roots_round_trip() { let config = Config { - default_ide: Ide::Vscode, + default_ide: IdeSelection::BuiltIn(Ide::Vscode), projects_root: vec![ PathBuf::from("C:/Projects"), PathBuf::from("D:/Work"), PathBuf::from("/tmp/dev"), ], recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() }; let toml = toml::to_string(&config).unwrap(); @@ -66,7 +73,7 @@ fn config_multiple_roots_round_trip() { #[test] fn invalid_toml_returns_error() { - let bad = "default_ide = 'banana'"; + let bad = "default_ide = { invalid = 'structure' }"; let parsed = toml::from_str::(bad); @@ -97,7 +104,7 @@ fn load_creates_defaults_when_file_missing() { let config = Config::load().expect("load should succeed"); assert!(!config.projects_root.is_empty()); - assert_eq!(config.default_ide, Ide::Vscode); + assert_eq!(config.default_ide, IdeSelection::BuiltIn(Ide::Vscode)); // Load should have persisted the defaults. assert!(path.exists(), "load() should create the config file when missing"); @@ -138,7 +145,7 @@ fn corrupted_config_is_recreated_with_defaults() { let config = Config::load().unwrap(); - assert_eq!(config.default_ide, Ide::Vscode); + assert_eq!(config.default_ide, IdeSelection::BuiltIn(Ide::Vscode)); assert_eq!(config.projects_root.len(), 1); // Ensure the file was rewritten with valid TOML. @@ -214,13 +221,15 @@ fn recent_projects_round_trip_serialization() -> Result<()> { let config = Config { projects_root: vec![PathBuf::from("/projects")], - default_ide: Ide::Cursor, + default_ide: IdeSelection::BuiltIn(Ide::Cursor), recent_projects: vec![RecentProject { name: "weather-app".into(), path: PathBuf::from("/projects/weather-app"), last_opened: now, ide: None, }], + custom_ides: Vec::new(), + ..Config::default() }; config.save()?; diff --git a/tests/ide_detect.rs b/tests/ide_detect.rs index 2021f81..1da36cc 100644 --- a/tests/ide_detect.rs +++ b/tests/ide_detect.rs @@ -79,6 +79,48 @@ fn cursor_not_added_twice() { assert_eq!(list.iter().filter(|i| i.ide == Ide::Cursor).count(), 1); } +#[test] +fn detects_antigravity_cli_in_macos_location() { + use dev_cli::ide::detect::detect_common_macos_locations_in; + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let exe = home.join(".antigravity/bin/agy"); + fs::create_dir_all(exe.parent().unwrap()).unwrap(); + fs::write(&exe, "").unwrap(); + + let mut list = Vec::new(); + detect_common_macos_locations_in(&mut list, home); + assert!(list.iter().any(|i| i.ide == Ide::AntigravityCli)); +} + +#[test] +fn detects_antigravity_gui_in_macos_location() { + use dev_cli::ide::detect::detect_common_macos_locations_in; + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let exe = home.join("Applications/Antigravity.app/Contents/Resources/app/bin/antigravity"); + fs::create_dir_all(exe.parent().unwrap()).unwrap(); + fs::write(&exe, "").unwrap(); + + let mut list = Vec::new(); + detect_common_macos_locations_in(&mut list, home); + assert!(list.iter().any(|i| i.ide == Ide::Antigravity)); +} + +#[test] +fn detects_antigravity_cli_in_linux_location() { + use dev_cli::ide::detect::detect_common_linux_locations_in; + let dir = TempDir::new().unwrap(); + let home = dir.path(); + let exe = home.join(".antigravity/bin/antigravity"); + fs::create_dir_all(exe.parent().unwrap()).unwrap(); + fs::write(&exe, "").unwrap(); + + let mut list = Vec::new(); + detect_common_linux_locations_in(&mut list, home); + assert!(list.iter().any(|i| i.ide == Ide::AntigravityCli)); +} + #[test] fn detect_ides_runs_without_panic() { let ides = dev_cli::ide::detect::detect_ides(); diff --git a/tests/ide_model.rs b/tests/ide_model.rs index d081b8b..7098934 100644 --- a/tests/ide_model.rs +++ b/tests/ide_model.rs @@ -2,8 +2,29 @@ use dev_cli::models::ide::Ide; #[test] fn test_ide_display_name_and_icon() { - let ides = - [Ide::Cursor, Ide::Vscode, Ide::Claude, Ide::Terminal, Ide::Idea, Ide::Rider, Ide::Zed]; + let ides = [ + Ide::Antigravity, + Ide::AntigravityCli, + Ide::Cursor, + Ide::Vscode, + Ide::Windsurf, + Ide::Claude, + Ide::Sublime, + Ide::Neovim, + Ide::Zed, + Ide::Idea, + Ide::PyCharm, + Ide::WebStorm, + Ide::CLion, + Ide::RustRover, + Ide::GoLand, + Ide::Fleet, + Ide::Rider, + Ide::AndroidStudio, + Ide::VisualStudio, + Ide::Helix, + Ide::Terminal, + ]; for ide in ides { assert!(!ide.display_name().is_empty()); assert!(!ide.icon().is_empty()); @@ -14,11 +35,25 @@ fn test_ide_display_name_and_icon() { #[test] fn test_ide_next_cycle() { + assert_eq!(Ide::Antigravity.next(), Ide::AntigravityCli); + assert_eq!(Ide::AntigravityCli.next(), Ide::Cursor); assert_eq!(Ide::Cursor.next(), Ide::Vscode); - assert_eq!(Ide::Vscode.next(), Ide::Claude); - assert_eq!(Ide::Claude.next(), Ide::Terminal); - assert_eq!(Ide::Terminal.next(), Ide::Idea); - assert_eq!(Ide::Idea.next(), Ide::Rider); - assert_eq!(Ide::Rider.next(), Ide::Zed); - assert_eq!(Ide::Zed.next(), Ide::Cursor); + assert_eq!(Ide::Vscode.next(), Ide::Windsurf); + assert_eq!(Ide::Windsurf.next(), Ide::Claude); + assert_eq!(Ide::Claude.next(), Ide::Sublime); + assert_eq!(Ide::Sublime.next(), Ide::Neovim); + assert_eq!(Ide::Neovim.next(), Ide::Zed); + assert_eq!(Ide::Zed.next(), Ide::Idea); + assert_eq!(Ide::Idea.next(), Ide::PyCharm); + assert_eq!(Ide::PyCharm.next(), Ide::WebStorm); + assert_eq!(Ide::WebStorm.next(), Ide::CLion); + assert_eq!(Ide::CLion.next(), Ide::RustRover); + assert_eq!(Ide::RustRover.next(), Ide::GoLand); + assert_eq!(Ide::GoLand.next(), Ide::Fleet); + assert_eq!(Ide::Fleet.next(), Ide::Rider); + assert_eq!(Ide::Rider.next(), Ide::AndroidStudio); + assert_eq!(Ide::AndroidStudio.next(), Ide::VisualStudio); + assert_eq!(Ide::VisualStudio.next(), Ide::Helix); + assert_eq!(Ide::Helix.next(), Ide::Terminal); + assert_eq!(Ide::Terminal.next(), Ide::Antigravity); } diff --git a/tests/launcher.rs b/tests/launcher.rs index 37026c6..09661b1 100644 --- a/tests/launcher.rs +++ b/tests/launcher.rs @@ -41,51 +41,63 @@ where fs::create_dir_all(&bin_dir).unwrap(); // .bat has been used instead of .exe to prevent Windows defender/smartscreen flagging and breaking tests. - let exe_name = match ide { + let exe_names: Vec<&str> = match ide { Ide::Cursor => { if cfg!(windows) { - "cursor.bat" + vec!["cursor.bat"] } else { - "cursor" + vec!["cursor"] } } Ide::Claude => { if cfg!(windows) { - "claude.bat" + vec!["claude.bat"] } else { - "claude" + vec!["claude"] } } Ide::Terminal => { if cfg!(windows) { - "wt.bat" + vec!["wt.bat", "cmd.exe", "powershell.exe"] } else { - "wt" + vec![ + "gnome-terminal", + "konsole", + "alacritty", + "kitty", + "wezterm", + "xterm", + "x-terminal-emulator", + "open", + "wt", + ] } } _ => { if cfg!(windows) { - "code.bat" + vec!["code.bat"] } else { - "code" + vec!["code"] } } }; - let path = bin_dir.join(exe_name); - - if cfg!(windows) { - fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap(); - } else { - let script_content = "#!/bin/sh\nexit 0\n"; - fs::write(&path, script_content).unwrap(); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&path, perms).unwrap(); + for exe_name in exe_names { + let path = bin_dir.join(exe_name); + + if cfg!(windows) { + fs::write(&path, "@echo off\r\nexit /b 0\r\n").unwrap(); + } else { + let script_content = "#!/bin/sh\nexit 0\n"; + fs::write(&path, script_content).unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&path, perms).unwrap(); + } } } @@ -164,6 +176,15 @@ fn launch_spawn_claude() { assert!(result.is_ok()); } +#[test] +#[serial] +fn launch_spawn_antigravity_cli() { + let executable = fake_executable(); + let result = + launcher::launch_spawn(Ide::AntigravityCli, Path::new("."), Path::new(&executable)); + assert!(result.is_ok()); +} + #[test] #[serial] fn launch_spawn_terminal() { diff --git a/tests/onboarding.rs b/tests/onboarding.rs index 5d36755..2da577a 100644 --- a/tests/onboarding.rs +++ b/tests/onboarding.rs @@ -71,9 +71,11 @@ fn ensure_onboarded_returns_ok_when_config_missing_but_no_terminal() { fn ensure_onboarded_does_not_overwrite_existing_config() { with_temp_config(|| { let original = Config { - default_ide: Ide::Vscode, + default_ide: dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode), projects_root: vec![PathBuf::from("C:/OriginalProjects")], recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() }; original.save().unwrap(); @@ -87,7 +89,7 @@ fn ensure_onboarded_does_not_overwrite_existing_config() { assert_eq!(before, after); let loaded = Config::load().unwrap(); - assert_eq!(loaded.default_ide, Ide::Vscode); + assert_eq!(loaded.default_ide, dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode)); assert_eq!(loaded.projects_root, vec![PathBuf::from("C:/OriginalProjects")]); }); } diff --git a/tests/project_commands.rs b/tests/project_commands.rs index c56b8fb..cb4e57b 100644 --- a/tests/project_commands.rs +++ b/tests/project_commands.rs @@ -79,33 +79,61 @@ fn run_open_test(ide: &str) { let bin_dir = temp.root().join("bin"); std::fs::create_dir_all(&bin_dir).ok(); - #[cfg(windows)] - let exe = match ide { - "cursor" => "cursor.bat", - "terminal" => "wt.bat", - "claude" => "claude.bat", - _ => "code.bat", - }; - #[cfg(not(windows))] - let exe = match ide { - "cursor" => "cursor", - "terminal" => "wt", - "claude" => "claude", - _ => "code", + let exe_names: Vec<&str> = match ide { + "cursor" => { + if cfg!(windows) { + vec!["cursor.bat"] + } else { + vec!["cursor"] + } + } + "terminal" => { + if cfg!(windows) { + vec!["wt.bat", "cmd.exe", "powershell.exe"] + } else { + vec![ + "gnome-terminal", + "konsole", + "alacritty", + "kitty", + "wezterm", + "xterm", + "x-terminal-emulator", + "open", + "wt", + ] + } + } + "claude" => { + if cfg!(windows) { + vec!["claude.bat"] + } else { + vec!["claude"] + } + } + _ => { + if cfg!(windows) { + vec!["code.bat"] + } else { + vec!["code"] + } + } }; - let exe_path = bin_dir.join(exe); - if cfg!(windows) { - std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").ok(); - } else { - std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = std::fs::metadata(&exe_path) { - let mut perms = meta.permissions(); - perms.set_mode(0o755); - let _ = std::fs::set_permissions(&exe_path, perms); + for exe in exe_names { + let exe_path = bin_dir.join(exe); + if cfg!(windows) { + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").ok(); + } else { + std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").ok(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&exe_path) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&exe_path, perms); + } } } } diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs index 6e280ba..bece2fb 100644 --- a/tests/tui_actions.rs +++ b/tests/tui_actions.rs @@ -3,7 +3,11 @@ use serial_test::serial; use temp_env::with_var; use tempfile::TempDir; -use dev_cli::{config::Config, models::ide::Ide, tui::actions::open_project_with}; +use dev_cli::{ + config::Config, + models::ide::{Ide, IdeSelection}, + tui::actions::open_project_with, +}; mod common; use common::factories::fake_project as project; @@ -20,8 +24,14 @@ where #[serial] fn open_project_uses_default_ide() -> Result<()> { with_temp_config(|| -> Result<()> { - Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } - .save()?; + Config { + projects_root: vec![], + default_ide: IdeSelection::BuiltIn(Ide::Vscode), + recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() + } + .save()?; let project = project("demo"); @@ -29,7 +39,7 @@ fn open_project_uses_default_ide() -> Result<()> { open_project_with(&project, |ide, path| { called = true; - assert_eq!(ide, Ide::Vscode); + assert_eq!(ide, IdeSelection::BuiltIn(Ide::Vscode)); assert_eq!(path, &project.path); Ok(()) })?; @@ -43,8 +53,14 @@ fn open_project_uses_default_ide() -> Result<()> { #[serial] fn open_project_propagates_launcher_error() -> Result<()> { with_temp_config(|| -> Result<()> { - Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } - .save()?; + Config { + projects_root: vec![], + default_ide: IdeSelection::BuiltIn(Ide::Vscode), + recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() + } + .save()?; let project = project("demo"); diff --git a/tests/tui_app.rs b/tests/tui_app.rs index e91eb22..a53d427 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -176,7 +176,7 @@ fn repeated_draw_calls_are_safe() -> Result<()> { #[test] fn test_process_pending_launch_none_and_some() { - use dev_cli::models::ide::Ide; + use dev_cli::models::ide::{Ide, IdeSelection}; use dev_cli::tui::app::process_pending_launch; let mut state = AppState::new(); @@ -185,6 +185,6 @@ fn test_process_pending_launch_none_and_some() { // Test with Some let proj = common::factories::fake_project("test-proj"); - state.pending_launch = Some((Ide::Claude, proj)); + state.pending_launch = Some((IdeSelection::BuiltIn(Ide::Claude), proj)); process_pending_launch(&state); } diff --git a/tests/tui_data.rs b/tests/tui_data.rs index ee77255..fbe7fe7 100644 --- a/tests/tui_data.rs +++ b/tests/tui_data.rs @@ -1,5 +1,9 @@ use anyhow::Result; -use dev_cli::{config::Config, models::ide::Ide, tui::data::load_projects}; +use dev_cli::{ + config::Config, + models::ide::{Ide, IdeSelection}, + tui::data::load_projects, +}; use serial_test::serial; use temp_env::with_var; use tempfile::TempDir; @@ -24,8 +28,10 @@ fn load_projects_discovers_git_repository() -> Result<()> { let config = Config { projects_root: vec![root.path().to_path_buf()], - default_ide: Ide::Vscode, + default_ide: IdeSelection::BuiltIn(Ide::Vscode), recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() }; config.save()?; @@ -47,8 +53,10 @@ fn load_projects_returns_empty_when_root_has_no_git_repos() -> Result<()> { let config = Config { projects_root: vec![root.path().to_path_buf()], - default_ide: Ide::Vscode, + default_ide: IdeSelection::BuiltIn(Ide::Vscode), recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() }; config.save()?; diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 1d1e0e8..3e12d37 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -141,6 +141,7 @@ fn typing_adds_character() { fn backspace_removes_character() { let mut state = AppState::new(); state.search_query = "cursor".into(); + state.search_cursor = state.search_query.chars().count(); handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &mut state); @@ -164,8 +165,10 @@ fn open_project_calls_launcher() -> Result<()> { // Give the test its own isolated config. Config { projects_root: vec![std::env::temp_dir()], - default_ide: Ide::Vscode, + default_ide: dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode), recent_projects: Vec::new(), + custom_ides: Vec::new(), + ..Config::default() } .save()?; @@ -180,7 +183,7 @@ fn open_project_calls_launcher() -> Result<()> { let (ide, path) = launched.expect("launcher should be called"); - assert_eq!(ide, Ide::Vscode); + assert_eq!(ide, dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode)); assert!(path.ends_with("demo")); Ok(()) @@ -414,13 +417,55 @@ fn tab_key_cycles_selected_project_ide() { state.installed_ides = vec![Ide::Cursor, Ide::Vscode, Ide::Claude]; state.set_projects(vec![project("alpha")]); state.selected_index = 1; - state.default_ide = Ide::Cursor; + state.default_ide = dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Cursor); - assert_eq!(state.get_project_ide(&state.filtered_projects()[0].path), Ide::Cursor); + assert_eq!( + state.get_project_ide(&state.filtered_projects()[0].path), + dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Cursor) + ); + + handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + + assert_eq!( + state.get_project_ide(&state.filtered_projects()[0].path), + dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode) + ); +} + +#[test] +#[serial] +fn tab_key_cycles_includes_custom_ides() { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Cursor]; + state.custom_ides = vec![dev_cli::models::custom_ide::CustomIde { + id: "neovim".into(), + display_name: "Neovim".into(), + executable: std::path::PathBuf::from("nvim"), + args_template: None, + verified: true, + }]; + state.set_projects(vec![project("alpha")]); + state.selected_index = 1; + state.default_ide = dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Cursor); + + assert_eq!( + state.get_project_ide(&state.filtered_projects()[0].path), + dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Cursor) + ); + // Cycle 1: Cursor -> Custom("neovim") handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + assert_eq!( + state.get_project_ide(&state.filtered_projects()[0].path), + dev_cli::models::ide::IdeSelection::Custom("neovim".into()) + ); - assert_eq!(state.get_project_ide(&state.filtered_projects()[0].path), Ide::Vscode); + // Cycle 2: Custom("neovim") -> Cursor + handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + assert_eq!( + state.get_project_ide(&state.filtered_projects()[0].path), + dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Cursor) + ); } #[test] @@ -430,7 +475,7 @@ fn enter_key_launches_project_with_selected_ide() { state.installed_ides = vec![Ide::Cursor, Ide::Vscode, Ide::Claude]; state.set_projects(vec![project("alpha")]); state.selected_index = 1; - state.default_ide = Ide::Cursor; + state.default_ide = dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Cursor); // Cycle once to VS Code handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); @@ -446,6 +491,44 @@ fn enter_key_launches_project_with_selected_ide() { }, ); - assert_eq!(*launched_ide.lock().unwrap(), Some(Ide::Vscode)); + assert_eq!( + *launched_ide.lock().unwrap(), + Some(dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode)) + ); assert!(state.should_quit); } + +#[test] +#[serial] +fn text_cursor_navigation_and_editing() { + let mut state = AppState::new(); + + // Type "abcd" + for c in ['a', 'b', 'c', 'd'] { + handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE), &mut state); + } + assert_eq!(state.search_query, "abcd"); + assert_eq!(state.search_cursor, 4); + + // Left arrow moves cursor to 3 + handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &mut state); + assert_eq!(state.search_cursor, 3); + + // Insert 'x' at cursor 3 -> "abcxd" + handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &mut state); + assert_eq!(state.search_query, "abcxd"); + assert_eq!(state.search_cursor, 4); + + // Home key moves cursor to 0 + handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), &mut state); + assert_eq!(state.search_cursor, 0); + + // Delete at cursor 0 removes 'a' -> "bcxd" + handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE), &mut state); + assert_eq!(state.search_query, "bcxd"); + assert_eq!(state.search_cursor, 0); + + // End key moves cursor to end (4) + handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE), &mut state); + assert_eq!(state.search_cursor, 4); +} diff --git a/tests/tui_footer.rs b/tests/tui_footer.rs new file mode 100644 index 0000000..d57bc25 --- /dev/null +++ b/tests/tui_footer.rs @@ -0,0 +1,49 @@ +use anyhow::Result; +use ratatui::{Terminal, backend::TestBackend}; + +use dev_cli::tui::{ + state::{AppState, IdeTabFocus, InputMode, SettingsTabFocus, Tab}, + theme::get_palette, + widgets::footer, +}; + +#[test] +fn test_footer_render_all_states() -> Result<()> { + let palette = get_palette("neon"); + + let tabs = [Tab::Projects, Tab::Recent, Tab::Ide, Tab::Settings]; + + for tab in tabs { + let mut state = AppState::new(); + state.active_tab = tab; + + // Normal mode + if tab == Tab::Ide { + for focus in [IdeTabFocus::List, IdeTabFocus::ConfirmDelete, IdeTabFocus::AddForm] { + state.ide_tab_focus = focus; + let backend = TestBackend::new(120, 2); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| footer::render(f, f.area(), &state, palette))?; + } + } else if tab == Tab::Settings { + for focus in [SettingsTabFocus::List, SettingsTabFocus::AddRoot] { + state.settings_tab_focus = focus; + let backend = TestBackend::new(120, 2); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| footer::render(f, f.area(), &state, palette))?; + } + } else { + let backend = TestBackend::new(120, 2); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| footer::render(f, f.area(), &state, palette))?; + } + + // Editing mode + state.input_mode = InputMode::Editing; + let backend = TestBackend::new(120, 2); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| footer::render(f, f.area(), &state, palette))?; + } + + Ok(()) +} diff --git a/tests/tui_ide_list.rs b/tests/tui_ide_list.rs new file mode 100644 index 0000000..1e7d0a4 --- /dev/null +++ b/tests/tui_ide_list.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +use dev_cli::{ + models::{custom_ide::CustomIde, ide::Ide, ide::IdeSelection}, + tui::{ + state::{AppState, IdeTabFocus, InputMode}, + theme::get_palette, + widgets::ide_list, + }, +}; +use ratatui::{Terminal, backend::TestBackend}; +use std::path::PathBuf; + +#[test] +fn test_render_ide_list_empty() -> Result<()> { + let mut state = AppState::new(); + state.installed_ides = vec![]; + state.custom_ides = vec![]; + + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("neon")))?; + + Ok(()) +} + +#[test] +fn test_render_ide_list_with_installed_and_custom() -> Result<()> { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Vscode, Ide::Cursor]; + state.default_ide = IdeSelection::BuiltIn(Ide::Vscode); + state.custom_ides = vec![ + CustomIde { + id: "my-custom".into(), + display_name: "My Custom IDE".into(), + executable: PathBuf::from("/usr/bin/custom"), + args_template: None, + verified: true, + }, + CustomIde { + id: "unverified-ide".into(), + display_name: "Unverified IDE".into(), + executable: PathBuf::from("/usr/bin/unverified"), + args_template: Some("--new-window".into()), + verified: false, + }, + ]; + + // Select top (installed) + state.selected_index = 0; + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("neon")))?; + + // Select bottom (custom) + state.selected_index = 2; + state.ide_status_message = Some("✓ Action successful".into()); + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("cyberpunk")))?; + + // Warning status message + state.ide_status_message = Some("Are you sure you want to delete?".into()); + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("catppuccin")))?; + + // Error status message + state.ide_status_message = Some("✗ Failed to delete".into()); + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("monokai")))?; + + Ok(()) +} + +#[test] +fn test_render_ide_list_empty_custom_list() -> Result<()> { + let mut state = AppState::new(); + state.installed_ides = vec![Ide::Vscode]; + state.custom_ides = vec![]; + + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("neon")))?; + + Ok(()) +} + +#[test] +fn test_render_ide_add_form_split_view() -> Result<()> { + let mut state = AppState::new(); + state.ide_tab_focus = IdeTabFocus::AddForm; + state.installed_ides = vec![Ide::Vscode]; + state.default_ide = IdeSelection::Custom("my-custom".into()); + state.custom_ides = vec![CustomIde { + id: "my-custom".into(), + display_name: "My Custom IDE".into(), + executable: PathBuf::from("/usr/bin/custom"), + args_template: None, + verified: true, + }]; + + state.input_mode = InputMode::Editing; + state.ide_form_name = "New Custom".into(); + state.ide_form_name_cursor = 3; + state.ide_form_path = "/usr/bin/new".into(); + state.ide_form_path_cursor = 5; + state.ide_form_args = "--flag".into(); + state.ide_form_args_cursor = 2; + state.ide_status_message = Some("✗ Executable path does not exist".into()); + + for field in 0..3 { + state.ide_form_field = field; + let backend = TestBackend::new(100, 30); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| ide_list::render(f, f.area(), &state, get_palette("high-contrast")))?; + } + + Ok(()) +} diff --git a/tests/tui_settings_widget.rs b/tests/tui_settings_widget.rs new file mode 100644 index 0000000..58c1c33 --- /dev/null +++ b/tests/tui_settings_widget.rs @@ -0,0 +1,57 @@ +use anyhow::Result; +use dev_cli::tui::{ + state::{AppState, InputMode, SettingsTabFocus}, + widgets::settings, +}; +use ratatui::{Terminal, backend::TestBackend}; +use std::path::PathBuf; + +#[test] +fn test_render_settings_list() -> Result<()> { + let mut state = AppState::new(); + state.settings_tab_focus = SettingsTabFocus::List; + state.project_roots = vec![PathBuf::from("/projects/rust"), PathBuf::from("/projects/go")]; + state.selected_index = 0; + state.ignore_patterns = vec!["node_modules".into(), "target".into()]; + state.auto_refresh_on_launch = true; + state.settings_status_message = Some("✓ Settings updated".into()); + + let backend = TestBackend::new(120, 35); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| settings::render(f, f.area(), &state))?; + + // Error status message, empty ignore patterns, disabled auto refresh + state.settings_status_message = Some("✗ Error saving setting".into()); + state.ignore_patterns = vec![]; + state.auto_refresh_on_launch = false; + terminal.draw(|f| settings::render(f, f.area(), &state))?; + + // Empty project roots + state.project_roots = vec![]; + state.settings_status_message = None; + terminal.draw(|f| settings::render(f, f.area(), &state))?; + + Ok(()) +} + +#[test] +fn test_render_settings_add_root_split_view() -> Result<()> { + let mut state = AppState::new(); + state.settings_tab_focus = SettingsTabFocus::AddRoot; + state.project_roots = vec![PathBuf::from("/projects/demo")]; + state.input_mode = InputMode::Editing; + state.settings_add_root = "/projects/new".into(); + state.settings_add_root_cursor = 5; + state.settings_status_message = Some("✗ Path does not exist".into()); + + let backend = TestBackend::new(120, 35); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|f| settings::render(f, f.area(), &state))?; + + // Non-editing mode + state.input_mode = InputMode::Normal; + state.settings_status_message = None; + terminal.draw(|f| settings::render(f, f.area(), &state))?; + + Ok(()) +} diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 19a7d34..8e92532 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -1,5 +1,7 @@ use crate::common::factories::fake_project as project; +use common::temp_config::with_temp_config; use dev_cli::{models::ide::Ide, tui::state::AppState}; +use serial_test::serial; mod common; @@ -92,6 +94,7 @@ fn pop_char_removes_last_character() { let mut state = AppState::new(); state.search_query = "cursor".into(); + state.search_cursor = state.search_query.chars().count(); state.pop_char(); state.pop_char(); @@ -181,55 +184,65 @@ fn refresh_reloads_projects_and_recents() { #[test] fn cycle_selected_ide_updates_override() { + use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); + state.default_ide = IdeSelection::BuiltIn(Ide::Vscode); + state.custom_ides = Vec::new(); state.installed_ides = vec![Ide::Vscode, Ide::Cursor, Ide::Claude]; let p = project("demo"); state.set_projects(vec![p.clone()]); state.selected_index = 1; // Highlight demo project under root - assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Vscode)); state.cycle_selected_ide(); - assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Cursor)); state.cycle_selected_ide(); - assert_eq!(state.get_project_ide(&p.path), Ide::Claude); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Claude)); state.cycle_selected_ide(); - assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Vscode)); } #[test] fn cycle_selected_ide_skips_uninstalled_ides() { + use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); + state.default_ide = IdeSelection::BuiltIn(Ide::Vscode); + state.custom_ides = Vec::new(); state.installed_ides = vec![Ide::Vscode, Ide::Terminal]; let p = project("demo"); state.set_projects(vec![p.clone()]); state.selected_index = 1; - assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Vscode)); state.cycle_selected_ide(); - assert_eq!(state.get_project_ide(&p.path), Ide::Terminal); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Terminal)); state.cycle_selected_ide(); - assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Vscode)); } #[test] fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { + use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); - state.default_ide = Ide::Idea; + state.custom_ides = Vec::new(); + state.default_ide = IdeSelection::BuiltIn(Ide::Idea); state.installed_ides = vec![Ide::Cursor, Ide::Claude]; let p = project("demo"); - assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); + assert_eq!(state.get_project_ide(&p.path), IdeSelection::BuiltIn(Ide::Cursor)); } #[test] fn cycle_selected_ide_on_recent_tab() { + use dev_cli::models::ide::IdeSelection; use dev_cli::models::recent_project::RecentProject; use dev_cli::tui::state::Tab; use std::path::PathBuf; let mut state = AppState::new(); + state.custom_ides = Vec::new(); state.active_tab = Tab::Recent; state.installed_ides = vec![Ide::Vscode, Ide::Claude]; let path = PathBuf::from("/tmp/recent_demo"); @@ -237,25 +250,35 @@ fn cycle_selected_ide_on_recent_tab() { name: "recent_demo".into(), path: path.clone(), last_opened: 100, - ide: Some(Ide::Vscode), + ide: Some(IdeSelection::BuiltIn(Ide::Vscode)), }]; - assert_eq!(state.get_recent_project_ide(&state.recent_projects[0]), Ide::Vscode); + assert_eq!( + state.get_recent_project_ide(&state.recent_projects[0]), + IdeSelection::BuiltIn(Ide::Vscode) + ); state.cycle_selected_ide(); - assert_eq!(state.get_recent_project_ide(&state.recent_projects[0]), Ide::Claude); + assert_eq!( + state.get_recent_project_ide(&state.recent_projects[0]), + IdeSelection::BuiltIn(Ide::Claude) + ); state.cycle_selected_ide(); - assert_eq!(state.get_recent_project_ide(&state.recent_projects[0]), Ide::Vscode); + assert_eq!( + state.get_recent_project_ide(&state.recent_projects[0]), + IdeSelection::BuiltIn(Ide::Vscode) + ); } #[test] fn projects_tab_uses_default_ide_not_recent_ide() { + use dev_cli::models::ide::IdeSelection; use dev_cli::models::recent_project::RecentProject; use std::path::PathBuf; let mut state = AppState::new(); - state.default_ide = Ide::Vscode; + state.default_ide = IdeSelection::BuiltIn(Ide::Vscode); state.installed_ides = vec![Ide::Vscode, Ide::Claude]; let path = PathBuf::from("/tmp/shared_demo"); @@ -263,13 +286,226 @@ fn projects_tab_uses_default_ide_not_recent_ide() { name: "shared_demo".into(), path: path.clone(), last_opened: 100, - ide: Some(Ide::Claude), + ide: Some(IdeSelection::BuiltIn(Ide::Claude)), }; state.recent_projects = vec![recent.clone()]; // Recent tab uses recent.ide (Claude) - assert_eq!(state.get_recent_project_ide(&recent), Ide::Claude); + assert_eq!(state.get_recent_project_ide(&recent), IdeSelection::BuiltIn(Ide::Claude)); // Projects tab uses default_ide (VS Code), ignoring recent.ide - assert_eq!(state.get_project_ide(&path), Ide::Vscode); + assert_eq!(state.get_project_ide(&path), IdeSelection::BuiltIn(Ide::Vscode)); +} + +#[test] +#[serial] +fn test_app_state_cycling_and_toggles() { + with_temp_config(|| { + let mut state = AppState::new(); + + // Theme cycling + let initial_theme = state.theme.clone(); + state.cycle_theme(); + assert_ne!(state.theme, initial_theme); + assert!(state.settings_status_message.is_some()); + + // Max depth cycling + let initial_depth = state.max_depth; + state.cycle_max_depth(); + assert_ne!(state.max_depth, initial_depth); + + // Recent limit cycling + let initial_limit = state.recent_projects_limit; + state.cycle_recent_limit(); + assert_ne!(state.recent_projects_limit, initial_limit); + + // Auto-refresh toggle + let initial_auto = state.auto_refresh_on_launch; + state.toggle_auto_refresh(); + assert_eq!(state.auto_refresh_on_launch, !initial_auto); + + // Reset defaults + state.reset_config_defaults(); + assert!(state.settings_status_message.is_some()); + }); +} + +#[test] +fn test_search_cursor_and_character_edits() { + let mut state = AppState::new(); + + state.push_char('a'); + state.push_char('b'); + state.push_char('c'); + assert_eq!(state.search_query, "abc"); + assert_eq!(state.search_cursor, 3); + + state.move_search_cursor_left(); + assert_eq!(state.search_cursor, 2); + + state.move_search_cursor_home(); + assert_eq!(state.search_cursor, 0); + + state.move_search_cursor_right(); + assert_eq!(state.search_cursor, 1); + + state.delete_char(); // deletes 'b' at cursor index 1 + assert_eq!(state.search_query, "ac"); + + state.move_search_cursor_end(); + assert_eq!(state.search_cursor, 2); + + state.clear_search(); + assert_eq!(state.search_query, ""); + assert_eq!(state.search_cursor, 0); +} + +#[test] +fn test_editing_mode_field_manipulation() { + use dev_cli::tui::state::{IdeTabFocus, InputMode, Tab}; + + let mut state = AppState::new(); + state.active_tab = Tab::Ide; + state.ide_tab_focus = IdeTabFocus::AddForm; + state.input_mode = InputMode::Editing; + + for field in 0..3 { + state.ide_form_field = field; + assert_eq!(state.active_ide_form_field().to_string(), ""); + } + + state.ide_form_field = 0; + state.editing_insert_char('X'); + state.editing_insert_char('Y'); + state.editing_insert_char('Z'); + assert_eq!(state.ide_form_name, "XYZ"); + assert_eq!(state.ide_form_name_cursor, 3); + + state.editing_move_cursor_left(); + assert_eq!(state.ide_form_name_cursor, 2); + + state.editing_move_cursor_home(); + assert_eq!(state.ide_form_name_cursor, 0); + + state.editing_move_cursor_right(); + assert_eq!(state.ide_form_name_cursor, 1); + + state.editing_delete(); // deletes 'Y' + assert_eq!(state.ide_form_name, "XZ"); + + state.editing_backspace(); // deletes 'X' + assert_eq!(state.ide_form_name, "Z"); + + state.editing_move_cursor_end(); + assert_eq!(state.ide_form_name_cursor, 1); + + state.cycle_ide_form_field(); + assert_eq!(state.ide_form_field, 1); +} + +#[test] +#[serial] +fn test_ide_tab_custom_ide_management() { + with_temp_config(|| { + use dev_cli::models::custom_ide::CustomIde; + use dev_cli::tui::state::{IdeTabFocus, Tab}; + use std::path::PathBuf; + + let mut state = AppState::new(); + state.active_tab = Tab::Ide; + state.custom_ides = vec![CustomIde { + id: "custom-one".into(), + display_name: "Custom One".into(), + executable: PathBuf::from("/bin/custom-one"), + args_template: None, + verified: true, + }]; + + let items = state.ide_tab_items(); + assert!(!items.is_empty()); + + // Select installed vs custom + state.selected_index = 0; + state.set_default_selected_ide(); + assert!(state.ide_status_message.is_some()); + + let installed_len = state.installed_ides.len(); + state.selected_index = installed_len; // point to custom-one + state.set_default_selected_ide(); + assert!(state.ide_status_message.is_some()); + + // Request delete + state.request_delete_selected_custom_ide(); + assert_eq!(state.ide_tab_focus, IdeTabFocus::ConfirmDelete); + + // Cancel delete + state.cancel_delete_custom_ide(); + assert_eq!(state.ide_tab_focus, IdeTabFocus::List); + + // Remove custom IDE + state.request_delete_selected_custom_ide(); + state.remove_selected_custom_ide(); + assert!(state.custom_ides.is_empty()); + + // Submit IDE form validation errors + state.ide_form_name = "".into(); + assert!(state.submit_ide_form().is_err()); + + state.ide_form_name = "Custom Two".into(); + state.ide_form_path = "".into(); + assert!(state.submit_ide_form().is_err()); + + state.ide_form_path = "/nonexistent/path/to/exe".into(); + assert!(state.submit_ide_form().is_err()); + }); +} + +#[test] +#[serial] +fn test_settings_tab_root_management() { + with_temp_config(|| { + use dev_cli::tui::state::{SettingsTabFocus, Tab}; + + let mut state = AppState::new(); + state.active_tab = Tab::Settings; + state.settings_tab_focus = SettingsTabFocus::AddRoot; + + // submit empty + state.settings_add_root = "".into(); + assert!(state.submit_add_root().is_err()); + + // submit invalid path + state.settings_add_root = "/nonexistent/dir/12345".into(); + assert!(state.submit_add_root().is_err()); + + // add valid root + let temp_dir = std::env::temp_dir(); + state.settings_add_root = temp_dir.to_string_lossy().to_string(); + assert!(state.submit_add_root().is_ok()); + assert_eq!(state.settings_item_count(), state.project_roots.len()); + + // remove root + state.selected_index = state.project_roots.len() - 1; + state.remove_selected_root(); + }); +} + +#[test] +fn test_tree_toggle_and_scroll_offset() { + use dev_cli::tui::state::Tab; + use dev_cli::tui::tree::TreeNode; + + let mut state = AppState::new(); + state.active_tab = Tab::Projects; + state.set_projects(vec![project("proj1")]); + + state.selected_index = 0; // Root folder + state.toggle_selected(); + if let TreeNode::Folder { is_expanded, .. } = &state.project_tree[0] { + assert!(!is_expanded); + } + + assert_eq!(state.scroll_offset(10), 0); + state.selected_index = 15; + assert_eq!(state.scroll_offset(10), 6); } diff --git a/tests/tui_theme.rs b/tests/tui_theme.rs new file mode 100644 index 0000000..6d5898e --- /dev/null +++ b/tests/tui_theme.rs @@ -0,0 +1,19 @@ +use dev_cli::tui::theme::get_palette; + +#[test] +fn test_all_theme_palettes() { + let themes = [ + "neon", + "cyberpunk", + "catppuccin", + "monokai", + "high-contrast", + "unknown_theme", + "CYBERPUNK", + ]; + + for theme in themes { + let palette = get_palette(theme); + assert!(!palette.name.is_empty()); + } +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 47e1737..f1b842c 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -38,7 +38,7 @@ fn header_renders() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - header::render(frame, frame.area()); + header::render(frame, frame.area(), dev_cli::tui::theme::get_palette("neon")); })?; Ok(()) @@ -52,7 +52,7 @@ fn search_renders() -> Result<()> { let state = AppState::new(); terminal.draw(|frame| { - search::render(frame, frame.area(), &state); + search::render(frame, frame.area(), &state, state.palette()); })?; Ok(()) @@ -66,7 +66,7 @@ fn project_list_renders() -> Result<()> { let state = AppState::new(); terminal.draw(|frame| { - project_list::render(frame, frame.area(), &state); + project_list::render(frame, frame.area(), &state, state.palette()); })?; Ok(()) @@ -83,7 +83,7 @@ fn project_list_renders_narrow() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - project_list::render(frame, frame.area(), &state); + project_list::render(frame, frame.area(), &state, state.palette()); })?; Ok(()) @@ -94,8 +94,9 @@ fn footer_renders() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; + let state = AppState::default(); terminal.draw(|frame| { - footer::render(frame, frame.area()); + footer::render(frame, frame.area(), &state, state.palette()); })?; Ok(()) @@ -116,7 +117,7 @@ fn search_works() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - search::render(frame, frame.area(), &state); + search::render(frame, frame.area(), &state, state.palette()); })?; let text = terminal.backend().buffer().content(); @@ -145,7 +146,12 @@ fn selected_project_is_highlighted() -> Result<()> { let buffer = render_widget( |frame| { - project_list::render(frame, ratatui::layout::Rect::new(0, 0, 50, 16), &state); + project_list::render( + frame, + ratatui::layout::Rect::new(0, 0, 50, 16), + &state, + state.palette(), + ); }, 50, 16, @@ -170,7 +176,7 @@ fn empty_search_state_renders_message() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - project_list::render(frame, frame.area(), &state); + project_list::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -191,7 +197,7 @@ fn search_placeholder_renders() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - search::render(frame, frame.area(), &state); + search::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -207,8 +213,9 @@ fn footer_contains_enter_shortcut() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; + let state = AppState::default(); terminal.draw(|frame| { - footer::render(frame, frame.area()); + footer::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -235,7 +242,7 @@ fn header_is_centered_and_contains_title() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - header::render(frame, frame.area()); + header::render(frame, frame.area(), dev_cli::tui::theme::get_palette("neon")); })?; let rendered: String = @@ -257,7 +264,7 @@ fn tabs_render_projects_tab_selected() -> Result<()> { state.active_tab = Tab::Projects; terminal.draw(|frame| { - tabs::render(frame, frame.area(), &state); + tabs::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -281,7 +288,7 @@ fn tabs_render_settings_tab_selected() -> Result<()> { state.active_tab = Tab::Settings; terminal.draw(|frame| { - tabs::render(frame, frame.area(), &state); + tabs::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -297,8 +304,9 @@ fn footer_contains_all_shortcuts() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; + let state = AppState { active_tab: Tab::Projects, ..AppState::default() }; terminal.draw(|frame| { - footer::render(frame, frame.area()); + footer::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -314,6 +322,25 @@ fn footer_contains_all_shortcuts() -> Result<()> { Ok(()) } +#[test] +fn recent_footer_omits_refresh() -> Result<()> { + let backend = TestBackend::new(80, 2); + let mut terminal = Terminal::new(backend)?; + + let state = AppState { active_tab: Tab::Recent, ..AppState::default() }; + terminal.draw(|frame| { + footer::render(frame, frame.area(), &state, state.palette()); + })?; + + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + + assert!(!rendered.contains("Refresh")); + assert!(!rendered.contains("F1")); + + Ok(()) +} + #[test] #[serial] fn non_projects_tab_shows_placeholder() -> Result<()> { @@ -324,7 +351,7 @@ fn non_projects_tab_shows_placeholder() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - placeholder::render(frame, frame.area(), Tab::Ide); + placeholder::render(frame, frame.area(), Tab::Ide, state.palette()); })?; let rendered: String = @@ -344,7 +371,7 @@ fn recent_widget_renders_empty_state() -> Result<()> { let state = AppState::new(); terminal.draw(|frame| { - recent::render(frame, frame.area(), &state); + recent::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -377,7 +404,7 @@ fn recent_widget_renders_project_name() -> Result<()> { state.load_recent_projects(); terminal.draw(|frame| { - recent::render(frame, frame.area(), &state); + recent::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -412,7 +439,7 @@ fn recent_widget_renders_relative_timestamp() -> Result<()> { state.load_recent_projects(); terminal.draw(|frame| { - recent::render(frame, frame.area(), &state); + recent::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -433,7 +460,7 @@ fn recent_tab_is_selected() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.draw(|frame| { - tabs::render(frame, frame.area(), &state); + tabs::render(frame, frame.area(), &state, state.palette()); })?; let rendered: String = @@ -452,13 +479,15 @@ fn recent_tab_renders_recent_widget() { Config { projects_root: vec![std::env::temp_dir()], - default_ide: Ide::Vscode, + default_ide: dev_cli::models::ide::IdeSelection::BuiltIn(Ide::Vscode), recent_projects: vec![RecentProject { name: "weather-app".into(), path: PathBuf::from("/projects/weather-app"), last_opened: now, ide: None, }], + custom_ides: Vec::new(), + ..Config::default() } .save() .unwrap(); @@ -469,7 +498,12 @@ fn recent_tab_renders_recent_widget() { let buffer = render_widget( |frame| { - recent::render(frame, ratatui::layout::Rect::new(0, 0, 60, 12), &state); + recent::render( + frame, + ratatui::layout::Rect::new(0, 0, 60, 12), + &state, + state.palette(), + ); }, 60, 12,