diff --git a/Cargo.lock b/Cargo.lock index 32a4213..9689d12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "dev-cli" -version = "2.0.0" +version = "2.1.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index f60aac9..0d3bfb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [package] name = "dev-cli" -version = "2.0.0" +version = "2.1.0" edition = "2024" license = "MIT" repository = "https://github.com/0xParthP/dev-cli" diff --git a/src/config.rs b/src/config.rs index 2fb7b64..a2d4c52 100644 --- a/src/config.rs +++ b/src/config.rs @@ -117,6 +117,10 @@ impl Config { } pub fn add_recent_project(&mut self, project: &Project) { + self.add_recent_project_with_ide(project, self.default_ide); + } + + pub fn add_recent_project_with_ide(&mut self, project: &Project, ide: Ide) { self.recent_projects.retain(|p| p.path != project.path); self.recent_projects.insert( @@ -125,6 +129,7 @@ impl Config { name: project.name.clone(), path: project.path.clone(), last_opened: Self::now_timestamp(), + ide: Some(ide), }, ); diff --git a/src/models/recent_project.rs b/src/models/recent_project.rs index 1cca8e3..45816de 100644 --- a/src/models/recent_project.rs +++ b/src/models/recent_project.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::models::ide::Ide; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RecentProject { /// Project name. @@ -14,4 +16,8 @@ pub struct RecentProject { /// Unix timestamp (seconds). pub last_opened: u64, + + /// Last IDE used to open the project. + #[serde(default)] + pub ide: Option, } diff --git a/src/tui/actions.rs b/src/tui/actions.rs index cbfbfe3..0d54ef3 100644 --- a/src/tui/actions.rs +++ b/src/tui/actions.rs @@ -26,7 +26,7 @@ where { let mut config = Config::load()?; launch(ide, &project.path)?; - config.add_recent_project(project); + config.add_recent_project_with_ide(project, ide); config.save()?; Ok(()) } diff --git a/src/tui/event.rs b/src/tui/event.rs index e160397..fc315c5 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -89,7 +89,7 @@ where if !recent.name.is_empty() { project.name = recent.name.clone(); } - let ide = state.get_project_ide(&project.path); + let ide = state.get_recent_project_ide(recent); 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 4cc54f3..0591547 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -125,6 +125,20 @@ impl AppState { /// 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) + } + + /// 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 + } else { + recent.ide.unwrap_or(self.default_ide) + }; + self.ensure_installed_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 { @@ -132,20 +146,30 @@ impl AppState { } } - /// Cycle through available installed IDEs for the currently selected project. + /// Cycle through available installed IDEs for the currently selected project (Projects or Recent tab). pub fn cycle_selected_ide(&mut self) { - if self.active_tab != Tab::Projects || self.installed_ides.is_empty() { + if 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); - } + let (path, current_ide) = match self.active_tab { + Tab::Projects => { + let Some(project) = self.selected_project() else { return }; + (project.path.clone(), self.get_project_ide(&project.path)) + } + Tab::Recent => { + let Some(recent) = self.selected_recent_project() else { return }; + (recent.path.clone(), self.get_recent_project_ide(recent)) + } + _ => 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], + }; + + self.ide_overrides.insert(path, next_ide); } pub fn filtered_projects(&self) -> Vec<&Project> { diff --git a/src/tui/widgets/recent.rs b/src/tui/widgets/recent.rs index 4b77187..7c03d0e 100644 --- a/src/tui/widgets/recent.rs +++ b/src/tui/widgets/recent.rs @@ -66,7 +66,21 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { let left_width = UnicodeWidthStr::width(left.as_str()); let age_width = UnicodeWidthStr::width(age.as_str()); - let spacing = inner_width.saturating_sub(left_width + age_width); + 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 path_str = display_path(&project.path); + + let path_left = format!(" {}", path_str); + let path_width = UnicodeWidthStr::width(path_left.as_str()); + let badge_width = UnicodeWidthStr::width(badge.as_str()); + + let line2_spacing = if inner_width > path_width + badge_width { + inner_width - path_width - badge_width + } else { + 2 + }; ListItem::new(vec![ Line::from(vec![ @@ -74,12 +88,17 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { left, Style::default().fg(theme::TEXT).add_modifier(Modifier::BOLD), ), - Span::raw(" ".repeat(spacing)), + Span::raw(" ".repeat(line1_spacing)), Span::styled(age, Style::default().fg(theme::INFO)), ]), Line::from(vec![ Span::raw(" "), - Span::styled(display_path(&project.path), Style::default().fg(theme::MUTED)), + Span::styled(path_str, Style::default().fg(theme::MUTED)), + Span::raw(" ".repeat(line2_spacing)), + Span::styled( + badge, + Style::default().fg(ide.color()).add_modifier(Modifier::BOLD), + ), ]), Line::default(), ]) diff --git a/tests/config.rs b/tests/config.rs index 15d6172..f10a09e 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -219,6 +219,7 @@ fn recent_projects_round_trip_serialization() -> Result<()> { name: "weather-app".into(), path: PathBuf::from("/projects/weather-app"), last_opened: now, + ide: None, }], }; diff --git a/tests/tui_event.rs b/tests/tui_event.rs index 69d8dbb..1d1e0e8 100644 --- a/tests/tui_event.rs +++ b/tests/tui_event.rs @@ -330,6 +330,7 @@ fn enter_key_on_recent_tab_launches_project() { name: "recent_app".into(), path: std::path::PathBuf::from("/tmp/recent_app"), last_opened: 100, + ide: None, }]; let launched = std::sync::atomic::AtomicBool::new(false); @@ -356,6 +357,7 @@ fn enter_key_on_recent_tab_does_not_quit_on_error() { name: "recent_app".into(), path: std::path::PathBuf::from("/tmp/recent_app"), last_opened: 100, + ide: None, }]; handle_key_with_launcher( diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 7ad729c..19a7d34 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -148,8 +148,8 @@ fn move_down_on_recent_tab_stops_at_end() { state.active_tab = Tab::Recent; state.recent_projects = vec![ - RecentProject { name: "a".into(), path: PathBuf::from("/a"), last_opened: 0 }, - RecentProject { name: "b".into(), path: PathBuf::from("/b"), last_opened: 0 }, + RecentProject { name: "a".into(), path: PathBuf::from("/a"), last_opened: 0, ide: None }, + RecentProject { name: "b".into(), path: PathBuf::from("/b"), last_opened: 0, ide: None }, ]; state.move_down(); @@ -222,3 +222,54 @@ fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { let p = project("demo"); assert_eq!(state.get_project_ide(&p.path), Ide::Cursor); } + +#[test] +fn cycle_selected_ide_on_recent_tab() { + use dev_cli::models::recent_project::RecentProject; + use dev_cli::tui::state::Tab; + use std::path::PathBuf; + + let mut state = AppState::new(); + state.active_tab = Tab::Recent; + state.installed_ides = vec![Ide::Vscode, Ide::Claude]; + let path = PathBuf::from("/tmp/recent_demo"); + state.recent_projects = vec![RecentProject { + name: "recent_demo".into(), + path: path.clone(), + last_opened: 100, + ide: Some(Ide::Vscode), + }]; + + assert_eq!(state.get_recent_project_ide(&state.recent_projects[0]), Ide::Vscode); + + state.cycle_selected_ide(); + assert_eq!(state.get_recent_project_ide(&state.recent_projects[0]), Ide::Claude); + + state.cycle_selected_ide(); + assert_eq!(state.get_recent_project_ide(&state.recent_projects[0]), Ide::Vscode); +} + +#[test] +fn projects_tab_uses_default_ide_not_recent_ide() { + use dev_cli::models::recent_project::RecentProject; + use std::path::PathBuf; + + let mut state = AppState::new(); + state.default_ide = Ide::Vscode; + state.installed_ides = vec![Ide::Vscode, Ide::Claude]; + + let path = PathBuf::from("/tmp/shared_demo"); + let recent = RecentProject { + name: "shared_demo".into(), + path: path.clone(), + last_opened: 100, + ide: Some(Ide::Claude), + }; + state.recent_projects = vec![recent.clone()]; + + // Recent tab uses recent.ide (Claude) + assert_eq!(state.get_recent_project_ide(&recent), Ide::Claude); + + // Projects tab uses default_ide (VS Code), ignoring recent.ide + assert_eq!(state.get_project_ide(&path), Ide::Vscode); +} diff --git a/tests/tui_widgets.rs b/tests/tui_widgets.rs index f2f782e..47e1737 100644 --- a/tests/tui_widgets.rs +++ b/tests/tui_widgets.rs @@ -366,6 +366,7 @@ fn recent_widget_renders_project_name() -> Result<()> { name: "weather-app".into(), path: PathBuf::from("/projects/weather-app"), last_opened: 0, + ide: None, }); config.save()?; @@ -400,6 +401,7 @@ fn recent_widget_renders_relative_timestamp() -> Result<()> { name: "weather-app".into(), path: PathBuf::from("/projects/weather-app"), last_opened: now, + ide: None, }); config.save()?; @@ -455,6 +457,7 @@ fn recent_tab_renders_recent_widget() { name: "weather-app".into(), path: PathBuf::from("/projects/weather-app"), last_opened: now, + ide: None, }], } .save()