diff --git a/src/ide/launcher.rs b/src/ide/launcher.rs index 9f6db9e..1ddfe53 100644 --- a/src/ide/launcher.rs +++ b/src/ide/launcher.rs @@ -56,7 +56,7 @@ pub fn launch_spawn(ide: Ide, project: &Path, executable: &Path) -> Result<()> { Ide::Claude => { Command::new(executable) .current_dir(project) - .spawn() + .status() .context("Couldn't start Claude Code")?; } Ide::Terminal => { diff --git a/src/models/ide.rs b/src/models/ide.rs index 5a0e7d6..1bae9b4 100644 --- a/src/models/ide.rs +++ b/src/models/ide.rs @@ -26,3 +26,57 @@ pub enum Ide { /// Zed — High-performance editor Zed, } + +impl Ide { + /// Human-readable display label for TUI badges. + pub fn display_name(&self) -> &'static str { + 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", + } + } + + /// 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 => "⚡", + } + } + + /// 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 + } + } + + /// Return the next IDE variant in cycling sequence. + pub fn next(&self) -> Self { + match self { + 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, + } + } +} diff --git a/src/tui/actions.rs b/src/tui/actions.rs index 201db6f..cbfbfe3 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -14,19 +14,28 @@ pub fn open_path(path: &Path) -> anyhow::Result<()> { open_project(&project) } -/// Open a project using the real launcher. +/// Open a project using the real launcher and default IDE. pub fn open_project(project: &Project) -> Result<()> { open_project_with(project, launcher::launch) } -/// Testable version that accepts an injected launcher. -pub fn open_project_with(project: &Project, mut launch: F) -> Result<()> +/// 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<()> where F: FnMut(Ide, &Path) -> Result<()>, { let mut config = Config::load()?; - launch(config.default_ide, &project.path)?; + launch(ide, &project.path)?; config.add_recent_project(project); config.save()?; Ok(()) } + +/// Testable version that accepts an injected launcher. +pub fn open_project_with(project: &Project, launch: F) -> Result<()> +where + F: FnMut(Ide, &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 13e342a..86fb761 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -24,13 +24,27 @@ pub fn run() -> Result<()> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - let result = run_loop(&mut terminal, event::handle_events); + let mut state = AppState::new(); + state.set_projects(super::data::load_projects()?); + state.load_recent_projects(); + state.active_tab = super::state::Tab::Recent; + + let result = run_loop_with_state(&mut terminal, &mut state, event::handle_events); restore_terminal(terminal)?; + process_pending_launch(&state); + 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); + } +} + /// Creates a fresh state and runs the event loop. pub fn run_loop(terminal: &mut Terminal, handle_events: F) -> Result<()> where diff --git a/src/tui/event.rs b/src/tui/event.rs index 4072ecf..e160397 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::{actions, state::AppState}; +use super::state::AppState; /// Poll and handle terminal events. pub fn handle_events(state: &mut AppState) -> Result<()> { @@ -34,16 +34,22 @@ where /// Handle standard key events. pub fn handle_key(key: KeyEvent, state: &mut AppState) { - handle_key_with_launcher(key, state, actions::open_project) + handle_key_with_launcher(key, state, |_ide, _project| Ok(())) } /// 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(&Project) -> Result<()>, + L: Fn(crate::models::ide::Ide, &Project) -> Result<()>, { match key.code { KeyCode::Esc => state.quit(), + KeyCode::F(1) => { + let _ = state.refresh(); + } + KeyCode::Tab => { + state.cycle_selected_ide(); + } KeyCode::Down => state.move_down(), KeyCode::Up => state.move_up(), @@ -68,8 +74,12 @@ where if is_folder { state.toggle_selected(); - } else if state.selected_project().is_some_and(|p| launcher(p).is_ok()) { - state.quit(); + } 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(); + } } } @@ -79,7 +89,9 @@ where if !recent.name.is_empty() { project.name = recent.name.clone(); } - if launcher(&project).is_ok() { + let ide = state.get_project_ide(&project.path); + if launcher(ide, &project).is_ok() { + state.pending_launch = Some((ide, project)); state.quit(); } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 22a6d4e..4cc54f3 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,8 +1,10 @@ //! Application state for the dashboard. +use std::{collections::HashMap, path::PathBuf}; + use crate::{ config::Config, - models::{project::Project, recent_project::RecentProject}, + models::{ide::Ide, project::Project, recent_project::RecentProject}, tui::tree::{DisplayNode, TreeNode, build_project_tree, flatten_filtered_tree, flatten_tree}, }; @@ -38,10 +40,41 @@ pub struct AppState { /// Currently active tab. pub active_tab: Tab, + + /// Configured default IDE. + pub default_ide: Ide, + + /// Installed IDEs available on the system. + pub installed_ides: Vec, + + /// Per-project IDE selection overrides. + pub ide_overrides: HashMap, + + /// Project and IDE pending launch after TUI exit. + pub pending_launch: Option<(Ide, Project)>, } impl Default for AppState { fn default() -> Self { + let default_ide = Config::load().map(|config| config.default_ide).unwrap_or(Ide::Vscode); + + let detected: Vec = + crate::ide::detect::detect_ides().into_iter().map(|i| i.ide).collect(); + + let installed_ides = if detected.is_empty() { + vec![ + Ide::Cursor, + Ide::Vscode, + Ide::Claude, + Ide::Terminal, + Ide::Idea, + Ide::Rider, + Ide::Zed, + ] + } else { + detected + }; + Self { projects: Vec::new(), project_tree: Vec::new(), @@ -50,6 +83,10 @@ impl Default for AppState { selected_index: 0, should_quit: false, active_tab: Tab::Projects, + default_ide, + installed_ides, + ide_overrides: HashMap::new(), + pending_launch: None, } } } @@ -72,10 +109,45 @@ impl AppState { Config::load().map(|config| config.recent_projects).unwrap_or_default(); } + /// Rescan projects from disk, reload recent history, and clamp current selection. + pub fn refresh(&mut self) -> anyhow::Result<()> { + let projects = crate::tui::data::load_projects()?; + self.set_projects(projects); + self.load_recent_projects(); + self.clamp_selection(); + Ok(()) + } + pub fn quit(&mut self) { self.should_quit = true; } + /// 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); + if !self.installed_ides.is_empty() && !self.installed_ides.contains(&ide) { + self.installed_ides[0] + } else { + ide + } + } + + /// Cycle through available installed IDEs for the currently selected project. + pub fn cycle_selected_ide(&mut self) { + if self.active_tab != Tab::Projects || self.installed_ides.is_empty() { + return; + } + + if let Some(project) = self.selected_project() { + let current_ide = self.get_project_ide(&project.path); + 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], + }; + self.ide_overrides.insert(project.path.clone(), next_ide); + } + } + pub fn filtered_projects(&self) -> Vec<&Project> { if self.search_query.is_empty() { return self.projects.iter().collect(); diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 5322c56..c70993e 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -31,3 +31,6 @@ pub const HIGHLIGHT_BG: Color = Color::Rgb(0, 120, 180); /// Info text. pub const INFO: Color = Color::Rgb(125, 180, 255); + +/// Secondary accent / purple actions. +pub const PURPLE: Color = Color::Rgb(180, 120, 255); diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index 7441f05..fb640a8 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -17,8 +17,12 @@ pub fn render(frame: &mut Frame, area: Rect) { 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)), ]); diff --git a/src/tui/widgets/list.rs b/src/tui/widgets/list.rs index 39b7acd..8064a4d 100644 --- a/src/tui/widgets/list.rs +++ b/src/tui/widgets/list.rs @@ -25,9 +25,7 @@ pub fn render_list( .borders(Borders::ALL) .border_style(Style::default().fg(theme::BORDER)), ) - .highlight_style( - Style::default().bg(theme::HIGHLIGHT_BG).fg(theme::TEXT).add_modifier(Modifier::BOLD), - ) + .highlight_style(Style::default().bg(theme::HIGHLIGHT_BG).add_modifier(Modifier::BOLD)) .highlight_symbol("❯ "); let mut state = ListState::default(); diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index b157b42..82e1426 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -16,6 +16,7 @@ use crate::{ }, utils::path::display_path, }; +use unicode_width::UnicodeWidthStr; /// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { @@ -39,6 +40,8 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { return; } + let available_width = area.width.saturating_sub(4) as usize; + let list_items: Vec = items .iter() .map(|node| { @@ -51,20 +54,45 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { ("📦 ", theme::INFO, " ") }; + let path_str = display_path(node.path); + + let mut line2_spans = vec![ + Span::raw(indent.clone()), + Span::raw(path_padding), + Span::styled(path_str.clone(), Style::default().fg(theme::MUTED)), + ]; + + if !node.is_folder { + let ide = state.get_project_ide(node.path); + let badge = format!("[{}]", ide.display_name()); + let left_len = UnicodeWidthStr::width(indent.as_str()) + + UnicodeWidthStr::width(path_padding) + + UnicodeWidthStr::width(path_str.as_str()); + let badge_len = UnicodeWidthStr::width(badge.as_str()); + + let spaces = if available_width > left_len + badge_len { + available_width - left_len - badge_len + } else { + 2 + }; + + line2_spans.push(Span::raw(" ".repeat(spaces))); + line2_spans.push(Span::styled( + badge, + Style::default().fg(ide.color()).add_modifier(Modifier::BOLD), + )); + } + ListItem::new(vec![ Line::from(vec![ - Span::raw(indent.clone()), + Span::raw(indent), Span::styled(icon, Style::default().fg(icon_color)), Span::styled( node.name, Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), ), ]), - Line::from(vec![ - Span::raw(indent), - Span::raw(path_padding), - Span::styled(display_path(node.path), Style::default().fg(theme::MUTED)), - ]), + Line::from(line2_spans), Line::default(), ]) }) diff --git a/tests/cli_config.rs b/tests/cli_config.rs index c3b4689..e892474 100644 --- a/tests/cli_config.rs +++ b/tests/cli_config.rs @@ -16,15 +16,13 @@ fn isolated_cmd(dir: &TempDir) -> Command { std::fs::create_dir_all(&bin_dir).ok(); #[cfg(windows)] - let exe_name = "cursor.exe"; + let exe_name = "cursor.bat"; #[cfg(not(windows))] let exe_name = "cursor"; let exe_path = bin_dir.join(exe_name); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).ok(); + 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)] diff --git a/tests/cli_open.rs b/tests/cli_open.rs index 05b1509..f58e38d 100644 --- a/tests/cli_open.rs +++ b/tests/cli_open.rs @@ -77,15 +77,13 @@ projects_root = ["{}"] std::fs::create_dir_all(&bin_dir).unwrap(); #[cfg(windows)] - let exe_name = "cursor.exe"; + let exe_name = "cursor.bat"; #[cfg(not(windows))] let exe_name = "cursor"; let exe_path = bin_dir.join(exe_name); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).unwrap(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").unwrap(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] diff --git a/tests/ide_model.rs b/tests/ide_model.rs new file mode 100644 index 0000000..d081b8b --- /dev/null +++ b/tests/ide_model.rs @@ -0,0 +1,24 @@ +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]; + for ide in ides { + assert!(!ide.display_name().is_empty()); + assert!(!ide.icon().is_empty()); + let _ = ide.color(); + let _ = ide.next(); + } +} + +#[test] +fn test_ide_next_cycle() { + 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); +} diff --git a/tests/launcher.rs b/tests/launcher.rs index 1d5bd0a..37026c6 100644 --- a/tests/launcher.rs +++ b/tests/launcher.rs @@ -9,23 +9,18 @@ use temp_env::with_var; static FAKE_EXE_COUNTER: AtomicU64 = AtomicU64::new(0); fn fake_executable() -> String { - // Choose appropriate script name and content based on OS let (suffix, content) = if cfg!(windows) { ("bat", "@echo off\r\nexit /b 0\r\n") } else { ("sh", "#!/bin/sh\nexit 0\n") }; - // Each call mints a fresh, unique path so a run of serial tests cannot - // collide on the same inode. pid + counter guarantees uniqueness even - // if `cargo test` is re-run with stale files lying around. let n = FAKE_EXE_COUNTER.fetch_add(1, Ordering::Relaxed); let path: PathBuf = env::temp_dir().join(format!("devcli_fake_launcher.{}.{n}.{suffix}", std::process::id())); fs::write(&path, content).expect("failed to create fake launcher"); - // On Unix, the script must be executable; Windows ignores the bit. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -45,31 +40,32 @@ where let bin_dir = dir.path().join("bin"); 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 { Ide::Cursor => { if cfg!(windows) { - "cursor.exe" + "cursor.bat" } else { "cursor" } } Ide::Claude => { if cfg!(windows) { - "claude.exe" + "claude.bat" } else { "claude" } } Ide::Terminal => { if cfg!(windows) { - "wt.exe" + "wt.bat" } else { "wt" } } _ => { if cfg!(windows) { - "code.exe" + "code.bat" } else { "code" } @@ -79,9 +75,7 @@ where let path = bin_dir.join(exe_name); if cfg!(windows) { - let comspec = - env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - fs::copy(comspec, &path).unwrap(); + 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(); diff --git a/tests/project_commands.rs b/tests/project_commands.rs index 77007a6..c56b8fb 100644 --- a/tests/project_commands.rs +++ b/tests/project_commands.rs @@ -81,10 +81,10 @@ fn run_open_test(ide: &str) { #[cfg(windows)] let exe = match ide { - "cursor" => "cursor.exe", - "terminal" => "wt.exe", - "claude" => "claude.exe", - _ => "code.exe", + "cursor" => "cursor.bat", + "terminal" => "wt.bat", + "claude" => "claude.bat", + _ => "code.bat", }; #[cfg(not(windows))] let exe = match ide { @@ -96,9 +96,7 @@ fn run_open_test(ide: &str) { let exe_path = bin_dir.join(exe); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).ok(); + 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)] @@ -153,15 +151,13 @@ fn open_shortcut_command_runs() { std::fs::create_dir_all(&bin_dir).ok(); #[cfg(windows)] - let exe = "cursor.exe"; + let exe = "cursor.bat"; #[cfg(not(windows))] let exe = "cursor"; let exe_path = bin_dir.join(exe); if cfg!(windows) { - let comspec = - std::env::var("COMSPEC").unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).ok(); + 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)] diff --git a/tests/tui_actions.rs b/tests/tui_actions.rs index 8174495..6e280ba 100644 --- a/tests/tui_actions.rs +++ b/tests/tui_actions.rs @@ -1,4 +1,5 @@ use anyhow::{Result, anyhow}; +use serial_test::serial; use temp_env::with_var; use tempfile::TempDir; @@ -16,6 +17,7 @@ where } #[test] +#[serial] fn open_project_uses_default_ide() -> Result<()> { with_temp_config(|| -> Result<()> { Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } @@ -38,6 +40,7 @@ fn open_project_uses_default_ide() -> Result<()> { } #[test] +#[serial] fn open_project_propagates_launcher_error() -> Result<()> { with_temp_config(|| -> Result<()> { Config { projects_root: vec![], default_ide: Ide::Vscode, recent_projects: Vec::new() } @@ -53,7 +56,7 @@ fn open_project_propagates_launcher_error() -> Result<()> { } #[test] -#[serial_test::serial] +#[serial] fn open_path_launches_and_updates_recents() { with_temp_config(|| { let temp = tempfile::TempDir::new().unwrap(); @@ -61,12 +64,10 @@ fn open_path_launches_and_updates_recents() { let bin_dir = temp.path().join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); - let exe = if cfg!(windows) { "code.exe" } else { "code" }; + let exe = if cfg!(windows) { "code.bat" } else { "code" }; let exe_path = bin_dir.join(exe); if cfg!(windows) { - let comspec = std::env::var("COMSPEC") - .unwrap_or_else(|_| r"C:\Windows\System32\cmd.exe".to_string()); - std::fs::copy(comspec, &exe_path).unwrap(); + std::fs::write(&exe_path, "@echo off\r\nexit /b 0\r\n").unwrap(); } else { std::fs::write(&exe_path, "#!/bin/sh\nexit 0\n").unwrap(); #[cfg(unix)] diff --git a/tests/tui_app.rs b/tests/tui_app.rs index 7b434d5..e91eb22 100644 --- a/tests/tui_app.rs +++ b/tests/tui_app.rs @@ -6,6 +6,8 @@ use dev_cli::tui::{ }; use ratatui::{Terminal, backend::TestBackend}; +mod common; + #[test] fn dashboard_draws_successfully() -> Result<()> { let backend = TestBackend::new(80, 24); @@ -171,3 +173,18 @@ fn repeated_draw_calls_are_safe() -> Result<()> { Ok(()) } + +#[test] +fn test_process_pending_launch_none_and_some() { + use dev_cli::models::ide::Ide; + use dev_cli::tui::app::process_pending_launch; + + let mut state = AppState::new(); + // Test with None + process_pending_launch(&state); + + // Test with Some + let proj = common::factories::fake_project("test-proj"); + state.pending_launch = Some((Ide::Claude, proj)); + process_pending_launch(&state); +} diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 483e5e8..69d8dbb 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -37,6 +37,7 @@ fn key(code: KeyCode) -> Event { } #[test] +#[serial] fn does_nothing_when_no_event_available() -> Result<()> { let mut state = AppState::new(); @@ -47,6 +48,7 @@ fn does_nothing_when_no_event_available() -> Result<()> { } #[test] +#[serial] fn quits_on_escape() -> Result<()> { let mut state = AppState::new(); @@ -57,6 +59,7 @@ fn quits_on_escape() -> Result<()> { } #[test] +#[serial] fn ignores_other_keys() -> Result<()> { let mut state = AppState::new(); @@ -67,6 +70,7 @@ fn ignores_other_keys() -> Result<()> { } #[test] +#[serial] fn ignores_resize_events() -> Result<()> { let mut state = AppState::new(); @@ -77,6 +81,7 @@ fn ignores_resize_events() -> Result<()> { } #[test] +#[serial] fn esc_key_quits() { let mut state = AppState::new(); @@ -86,6 +91,7 @@ fn esc_key_quits() { } #[test] +#[serial] fn other_keys_do_nothing() { let mut state = AppState::new(); @@ -97,6 +103,7 @@ fn other_keys_do_nothing() { /// ===== Tests for milestone 4.2.3 ===== #[test] +#[serial] fn down_key_moves_selection() { let mut state = AppState::new(); state.set_projects(vec![project("alpha"), project("beta")]); @@ -107,6 +114,7 @@ fn down_key_moves_selection() { } #[test] +#[serial] fn up_key_moves_selection() { let mut state = AppState::new(); state.set_projects(vec![project("alpha"), project("beta")]); @@ -118,6 +126,7 @@ fn up_key_moves_selection() { } #[test] +#[serial] fn typing_adds_character() { let mut state = AppState::new(); @@ -128,6 +137,7 @@ fn typing_adds_character() { } #[test] +#[serial] fn backspace_removes_character() { let mut state = AppState::new(); state.search_query = "cursor".into(); @@ -138,6 +148,7 @@ fn backspace_removes_character() { } #[test] +#[serial] fn enter_key_does_nothing_without_projects() { let mut state = AppState::default(); @@ -147,6 +158,7 @@ fn enter_key_does_nothing_without_projects() { } #[test] +#[serial] fn open_project_calls_launcher() -> Result<()> { with_temp_config(|| -> Result<()> { // Give the test its own isolated config. @@ -176,6 +188,7 @@ fn open_project_calls_launcher() -> Result<()> { } #[test] +#[serial] fn ignores_mouse_events() -> Result<()> { let mut state = AppState::new(); @@ -198,6 +211,7 @@ fn ignores_mouse_events() -> Result<()> { } #[test] +#[serial] fn open_project_returns_launcher_error() -> Result<()> { let project = project("demo"); @@ -211,18 +225,24 @@ fn open_project_returns_launcher_error() -> Result<()> { } #[test] +#[serial] fn enter_does_nothing_when_filtered_list_is_empty() { let mut state = AppState { search_query: "weather".into(), ..Default::default() }; state.set_projects(vec![project("cursor")]); - handle_key_with_launcher(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, |_| { - panic!("launcher should not be called"); - }); + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + |_, _| { + panic!("launcher should not be called"); + }, + ); assert!(!state.should_quit); } #[test] +#[serial] fn enter_does_not_quit_when_open_project_fails() { let mut state = AppState::default(); state.set_projects(vec![project("demo")]); @@ -230,13 +250,14 @@ fn enter_does_not_quit_when_open_project_fails() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |_project| Err(anyhow::anyhow!("launch failed")), + |_ide, _project| Err(anyhow::anyhow!("launch failed")), ); assert!(!state.should_quit); } #[test] +#[serial] fn poll_false_does_not_read_event() -> Result<()> { let mut state = AppState::new(); @@ -248,6 +269,7 @@ fn poll_false_does_not_read_event() -> Result<()> { } #[test] +#[serial] fn right_arrow_switches_tabs() { let mut state = AppState::new(); state.active_tab = Tab::Recent; @@ -263,6 +285,7 @@ fn right_arrow_switches_tabs() { } #[test] +#[serial] fn left_arrow_switches_tabs() { let mut state = AppState::new(); state.active_tab = Tab::Settings; @@ -278,6 +301,7 @@ fn left_arrow_switches_tabs() { } #[test] +#[serial] fn left_arrow_does_not_go_before_projects() { let mut state = AppState::new(); @@ -287,6 +311,7 @@ fn left_arrow_does_not_go_before_projects() { } #[test] +#[serial] fn right_arrow_does_not_go_past_settings() { let mut state = AppState::new(); state.active_tab = Tab::Settings; @@ -297,6 +322,7 @@ fn right_arrow_does_not_go_past_settings() { } #[test] +#[serial] fn enter_key_on_recent_tab_launches_project() { let mut state = AppState::new(); state.active_tab = Tab::Recent; @@ -310,7 +336,7 @@ fn enter_key_on_recent_tab_launches_project() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |project| { + |_ide, project| { launched.store(true, std::sync::atomic::Ordering::Relaxed); assert_eq!(project.name, "recent_app"); Ok(()) @@ -322,6 +348,7 @@ fn enter_key_on_recent_tab_launches_project() { } #[test] +#[serial] fn enter_key_on_recent_tab_does_not_quit_on_error() { let mut state = AppState::new(); state.active_tab = Tab::Recent; @@ -334,7 +361,7 @@ fn enter_key_on_recent_tab_does_not_quit_on_error() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |_project| Err(anyhow::anyhow!("failed to launch")), + |_ide, _project| Err(anyhow::anyhow!("failed to launch")), ); assert!(!state.should_quit); @@ -362,10 +389,61 @@ fn enter_key_on_recent_tab_reupdates_recent_project_in_config() { handle_key_with_launcher( KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &mut state, - |p| actions::open_project_with(p, |_, _| Ok(())), + |ide, p| actions::open_project_with_ide(p, ide, |_, _| Ok(())), ); let updated_config = Config::load().unwrap(); assert_eq!(updated_config.recent_projects[0].name, "target_app"); }); } + +#[test] +#[serial] +fn f1_key_triggers_refresh() { + let mut state = AppState::new(); + handle_key(KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE), &mut state); + assert!(!state.should_quit); +} + +#[test] +#[serial] +fn tab_key_cycles_selected_project_ide() { + let mut state = AppState::new(); + 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; + + assert_eq!(state.get_project_ide(&state.filtered_projects()[0].path), Ide::Cursor); + + handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + + assert_eq!(state.get_project_ide(&state.filtered_projects()[0].path), Ide::Vscode); +} + +#[test] +#[serial] +fn enter_key_launches_project_with_selected_ide() { + let mut state = AppState::new(); + 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; + + // Cycle once to VS Code + handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &mut state); + + let launched_ide = std::sync::Arc::new(std::sync::Mutex::new(None)); + let launched_ide_clone = launched_ide.clone(); + handle_key_with_launcher( + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + &mut state, + move |ide, _project| { + *launched_ide_clone.lock().unwrap() = Some(ide); + Ok(()) + }, + ); + + assert_eq!(*launched_ide.lock().unwrap(), Some(Ide::Vscode)); + assert!(state.should_quit); +} diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 35ffdaf..7ad729c 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -1,5 +1,5 @@ use crate::common::factories::fake_project as project; -use dev_cli::tui::state::AppState; +use dev_cli::{models::ide::Ide, tui::state::AppState}; mod common; @@ -170,3 +170,55 @@ fn typing_resets_selection() { assert_eq!(state.selected_index, 0); assert_eq!(state.search_query, "a"); } + +#[test] +fn refresh_reloads_projects_and_recents() { + let mut state = AppState::new(); + state.selected_index = 100; + assert!(state.refresh().is_ok()); + assert!(state.selected_index < state.visible_items().len().max(1)); +} + +#[test] +fn cycle_selected_ide_updates_override() { + let mut state = AppState::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); + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Claude); + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); +} + +#[test] +fn cycle_selected_ide_skips_uninstalled_ides() { + let mut state = AppState::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); + + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Terminal); + + state.cycle_selected_ide(); + assert_eq!(state.get_project_ide(&p.path), Ide::Vscode); +} + +#[test] +fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { + let mut state = AppState::new(); + state.default_ide = Ide::Idea; + state.installed_ides = vec![Ide::Cursor, Ide::Claude]; + + let p = project("demo"); + assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index 677b202..f2f782e 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -45,6 +45,7 @@ fn header_renders() -> Result<()> { } #[test] +#[serial] fn search_renders() -> Result<()> { let backend = TestBackend::new(80, 3); let mut terminal = Terminal::new(backend)?; @@ -58,6 +59,7 @@ fn search_renders() -> Result<()> { } #[test] +#[serial] fn project_list_renders() -> Result<()> { let backend = TestBackend::new(80, 10); let mut terminal = Terminal::new(backend)?; @@ -70,6 +72,23 @@ fn project_list_renders() -> Result<()> { Ok(()) } +#[test] +#[serial] +fn project_list_renders_narrow() -> Result<()> { + let mut state = AppState::new(); + state.active_tab = Tab::Projects; + state.set_projects(vec![project("alpha")]); + + let backend = TestBackend::new(12, 10); + let mut terminal = Terminal::new(backend)?; + + terminal.draw(|frame| { + project_list::render(frame, frame.area(), &state); + })?; + + Ok(()) +} + #[test] fn footer_renders() -> Result<()> { let backend = TestBackend::new(80, 2); @@ -83,6 +102,7 @@ fn footer_renders() -> Result<()> { } #[test] +#[serial] fn search_works() -> Result<()> { let mut state = AppState::new(); @@ -114,6 +134,7 @@ fn search_works() -> Result<()> { } #[test] +#[serial] fn selected_project_is_highlighted() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Projects; // <-- Add this @@ -138,6 +159,7 @@ fn selected_project_is_highlighted() -> Result<()> { } #[test] +#[serial] fn empty_search_state_renders_message() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Projects; @@ -161,6 +183,7 @@ fn empty_search_state_renders_message() -> Result<()> { } #[test] +#[serial] fn search_placeholder_renders() -> Result<()> { let state = AppState::new(); @@ -198,6 +221,7 @@ fn footer_contains_enter_shortcut() -> Result<()> { } #[test] +#[serial] fn scroll_offset_keeps_selection_visible() { let mut state = AppState::new(); state.selected_index = 15; @@ -224,6 +248,7 @@ fn header_is_centered_and_contains_title() -> Result<()> { } #[test] +#[serial] fn tabs_render_projects_tab_selected() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; @@ -247,6 +272,7 @@ fn tabs_render_projects_tab_selected() -> Result<()> { } #[test] +#[serial] fn tabs_render_settings_tab_selected() -> Result<()> { let backend = TestBackend::new(80, 2); let mut terminal = Terminal::new(backend)?; @@ -281,12 +307,15 @@ fn footer_contains_all_shortcuts() -> Result<()> { assert!(rendered.contains("Enter")); assert!(rendered.contains("Navigate")); assert!(rendered.contains("Search")); + assert!(rendered.contains("F1")); + assert!(rendered.contains("Refresh")); assert!(rendered.contains("Quit")); Ok(()) } #[test] +#[serial] fn non_projects_tab_shows_placeholder() -> Result<()> { let mut state = AppState::new(); state.active_tab = Tab::Ide; @@ -309,20 +338,22 @@ fn non_projects_tab_shows_placeholder() -> Result<()> { #[test] #[serial] fn recent_widget_renders_empty_state() -> Result<()> { - let backend = TestBackend::new(60, 10); - let mut terminal = Terminal::new(backend)?; - let state = AppState::new(); + with_temp_config(|| -> Result<()> { + let backend = TestBackend::new(60, 10); + let mut terminal = Terminal::new(backend)?; + let state = AppState::new(); - terminal.draw(|frame| { - recent::render(frame, frame.area(), &state); - })?; + terminal.draw(|frame| { + recent::render(frame, frame.area(), &state); + })?; - let rendered: String = - terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); + let rendered: String = + terminal.backend().buffer().content().iter().map(|c| c.symbol()).collect(); - assert!(rendered.contains("No recent projects")); + assert!(rendered.contains("No recent projects")); - Ok(()) + Ok(()) + }) } #[test] @@ -418,7 +449,7 @@ fn recent_tab_renders_recent_widget() { let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); Config { - projects_root: vec![], + projects_root: vec![std::env::temp_dir()], default_ide: Ide::Vscode, recent_projects: vec![RecentProject { name: "weather-app".into(),