From 483d0aea004d514dfb46e1926ea372c8c39e55aa Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 29 Sep 2026 17:31:19 +0100 Subject: [PATCH 1/6] refactor: changed theme name --- src/tui/theme.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 2e6ef9a..8168aa1 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -79,7 +79,7 @@ pub fn get_palette(theme_name: &str) -> Palette { "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, + name: "Neon Blue", primary: PRIMARY, background: BACKGROUND, text: TEXT, muted: MUTED, border: BORDER, success: SUCCESS, warning: WARNING, danger: DANGER, info: INFO, purple: PURPLE, highlight_bg: HIGHLIGHT_BG, }, } From 4d113d8baf7a4db4185d3a8d8e4dfa19b71e1042 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 29 Sep 2026 18:20:38 +0100 Subject: [PATCH 2/6] feature: git cloning added to cli --- src/cli.rs | 18 ++++++++++ src/commands/clone.rs | 76 +++++++++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 1 + tests/cli_clone.rs | 72 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 src/commands/clone.rs create mode 100644 tests/cli_clone.rs diff --git a/src/cli.rs b/src/cli.rs index 83afee6..75a69df 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,6 +25,9 @@ pub enum Commands { /// IDE Management Ide(IdeCommand), + /// Clone a Git repository. + Clone(CloneArgs), + /// Open a project (equivalent to `project open`). Open(OpenArgs), } @@ -40,6 +43,21 @@ pub struct OpenArgs { pub ide: Option, } +/// Arguments for cloning a Git repository. +#[derive(Args)] +pub struct CloneArgs { + /// The Git repository URL to clone. + pub url: String, + + /// Specify which project root to use by index or exact path (defaults to the first root). + #[arg(short, long)] + pub root: Option, + + /// Clone into a specific directory name instead of inferring from the URL. + #[arg(short, long)] + pub name: Option, +} + /// Subcommands for `dev project`. #[derive(Subcommand)] pub enum ProjectSubcommand { diff --git a/src/commands/clone.rs b/src/commands/clone.rs new file mode 100644 index 0000000..6551be5 --- /dev/null +++ b/src/commands/clone.rs @@ -0,0 +1,76 @@ +//! Clone command implementation. + +use anyhow::{Result, anyhow}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use crate::cli::CloneArgs; +use crate::config::Config; + +/// Executes the clone command. +pub fn execute(args: CloneArgs) -> Result<()> { + let cfg = Config::load()?; + if cfg.projects_root.is_empty() { + return Err(anyhow!("No project roots configured. Please add a root first.")); + } + + // Determine target root + let target_root = match args.root { + Some(ref root_arg) => { + // Check if it's an index + if let Ok(idx) = root_arg.parse::() { + if idx < cfg.projects_root.len() { + cfg.projects_root[idx].clone() + } else { + return Err(anyhow!( + "Root index {} is out of bounds. You have {} configured roots.", + idx, + cfg.projects_root.len() + )); + } + } else { + // Otherwise treat as path string + PathBuf::from(root_arg) + } + } + None => cfg.projects_root[0].clone(), + }; + + if !target_root.exists() { + std::fs::create_dir_all(&target_root)?; + } + + // Extract repo name if not provided + let repo_name = match args.name { + Some(name) => name, + None => { + let path = std::path::Path::new(&args.url); + let file_name = path.file_name().unwrap_or_default().to_string_lossy(); + let name = if file_name.is_empty() { "repository" } else { file_name.as_ref() }; + name.strip_suffix(".git").unwrap_or(name).to_string() + } + }; + + let target_path = target_root.join(repo_name); + + println!("Cloning '{}' into '{}'...", args.url, target_path.display()); + + let mut cmd = Command::new("git"); + cmd.arg("clone"); + cmd.arg(&args.url); + cmd.arg(&target_path); + + // Stream output directly to terminal so user can interact if auth is needed + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd.stdin(Stdio::inherit()); + + let status = cmd.status().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?; + + if status.success() { + println!("Successfully cloned into {}", target_path.display()); + Ok(()) + } else { + Err(anyhow!("git clone failed with exit code: {}", status)) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ef51776..cedbd44 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,6 +9,7 @@ //! - [`ide`] — IDE management (`dev ide`) //! - [`project`] — Project management (`dev project`) +pub mod clone; pub mod config; pub mod ide; pub mod project; diff --git a/src/main.rs b/src/main.rs index 52c30a0..2b8d051 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ fn main() -> Result<()> { Some(Commands::Project(cmd)) => commands::project::execute(cmd)?, Some(Commands::Config(cmd)) => commands::config::execute(cmd)?, Some(Commands::Ide(cmd)) => commands::ide::execute(cmd)?, + Some(Commands::Clone(args)) => commands::clone::execute(args)?, Some(Commands::Open(args)) => commands::project::open_shortcut(args)?, None => tui::run()?, diff --git a/tests/cli_clone.rs b/tests/cli_clone.rs new file mode 100644 index 0000000..164bb91 --- /dev/null +++ b/tests/cli_clone.rs @@ -0,0 +1,72 @@ +mod common; + +use assert_cmd::Command as AssertCommand; +use predicates::prelude::*; +use serial_test::serial; +use std::process::Command; +use tempfile::TempDir; + +fn setup_test_env() -> (AssertCommand, TempDir, TempDir) { + let config_dir = tempfile::tempdir().unwrap(); + let roots_dir = tempfile::tempdir().unwrap(); + + let config = format!( + r#" +default_ide = "Cursor" +projects_root = ["{}"] +"#, + roots_dir.path().display().to_string().replace('\\', "\\\\") + ); + + std::fs::write(config_dir.path().join("config.toml"), config).unwrap(); + + let mut cmd = AssertCommand::cargo_bin("dev").unwrap(); + cmd.env("DEVCLI_CONFIG_DIR", config_dir.path()); + cmd.env("DEVCLI_SKIP_ONBOARDING", "1"); + + (cmd, config_dir, roots_dir) +} + +#[test] +fn clone_command_fails_without_url() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + cmd.args(["clone"]).assert().failure().stderr(predicate::str::contains("Usage")); +} + +#[test] +#[serial] +fn clone_command_clones_local_repo() { + let (mut cmd, _cfg, roots) = setup_test_env(); + + // Create a real local git repo to clone from + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url]).assert().success(); + + // The cloned repo should exist in roots_dir + let repo_name = source_dir.path().file_name().unwrap().to_string_lossy(); + let target_path = roots.path().join(repo_name.as_ref()); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_with_custom_name() { + let (mut cmd, _cfg, roots) = setup_test_env(); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url, "--name", "CustomRepoName"]).assert().success(); + + let target_path = roots.path().join("CustomRepoName"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} From c4e98198dae19eeb086eb6053ecca1ab3df97784 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 29 Sep 2026 18:34:05 +0100 Subject: [PATCH 3/6] fix: flaky test --- tests/tui_state.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/tui_state.rs b/tests/tui_state.rs index 8e92532..6dd7469 100644 --- a/tests/tui_state.rs +++ b/tests/tui_state.rs @@ -6,6 +6,7 @@ use serial_test::serial; mod common; #[test] +#[serial] fn app_state_starts_empty() { let state = AppState::new(); @@ -16,6 +17,7 @@ fn app_state_starts_empty() { } #[test] +#[serial] fn quit_sets_should_quit() { let mut state = AppState::new(); @@ -25,6 +27,7 @@ fn quit_sets_should_quit() { } #[test] +#[serial] fn visible_items_returns_all_when_query_empty() { let mut state = AppState::new(); @@ -36,6 +39,7 @@ fn visible_items_returns_all_when_query_empty() { } #[test] +#[serial] fn visible_items_filters_case_insensitively() { let mut state = AppState::new(); @@ -51,6 +55,7 @@ fn visible_items_filters_case_insensitively() { } #[test] +#[serial] fn move_down_stops_at_last_project() { let mut state = AppState::new(); @@ -64,6 +69,7 @@ fn move_down_stops_at_last_project() { } #[test] +#[serial] fn move_up_stops_at_zero() { let mut state = AppState::new(); @@ -77,6 +83,7 @@ fn move_up_stops_at_zero() { } #[test] +#[serial] fn push_char_appends_to_search_and_resets_selection() { let mut state = AppState::new(); @@ -90,6 +97,7 @@ fn push_char_appends_to_search_and_resets_selection() { } #[test] +#[serial] fn pop_char_removes_last_character() { let mut state = AppState::new(); @@ -103,6 +111,7 @@ fn pop_char_removes_last_character() { } #[test] +#[serial] fn clamp_selection_resets_when_filtered_list_is_empty() { let mut state = AppState::new(); @@ -116,6 +125,7 @@ fn clamp_selection_resets_when_filtered_list_is_empty() { } #[test] +#[serial] fn clamp_selection_moves_selection_to_last_item() { let mut state = AppState::new(); @@ -129,6 +139,7 @@ fn clamp_selection_moves_selection_to_last_item() { } #[test] +#[serial] fn search_filters_projects() { let mut state = AppState::new(); @@ -142,6 +153,7 @@ fn search_filters_projects() { } #[test] +#[serial] fn move_down_on_recent_tab_stops_at_end() { use dev_cli::models::recent_project::RecentProject; use dev_cli::tui::state::Tab; @@ -163,6 +175,7 @@ fn move_down_on_recent_tab_stops_at_end() { } #[test] +#[serial] fn typing_resets_selection() { let mut state = AppState::new(); @@ -175,6 +188,7 @@ fn typing_resets_selection() { } #[test] +#[serial] fn refresh_reloads_projects_and_recents() { let mut state = AppState::new(); state.selected_index = 100; @@ -183,6 +197,7 @@ fn refresh_reloads_projects_and_recents() { } #[test] +#[serial] fn cycle_selected_ide_updates_override() { use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); @@ -203,6 +218,7 @@ fn cycle_selected_ide_updates_override() { } #[test] +#[serial] fn cycle_selected_ide_skips_uninstalled_ides() { use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); @@ -223,6 +239,7 @@ fn cycle_selected_ide_skips_uninstalled_ides() { } #[test] +#[serial] fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { use dev_cli::models::ide::IdeSelection; let mut state = AppState::new(); @@ -235,6 +252,7 @@ fn get_project_ide_falls_back_to_first_installed_if_default_uninstalled() { } #[test] +#[serial] fn cycle_selected_ide_on_recent_tab() { use dev_cli::models::ide::IdeSelection; use dev_cli::models::recent_project::RecentProject; @@ -272,6 +290,7 @@ fn cycle_selected_ide_on_recent_tab() { } #[test] +#[serial] fn projects_tab_uses_default_ide_not_recent_ide() { use dev_cli::models::ide::IdeSelection; use dev_cli::models::recent_project::RecentProject; @@ -331,6 +350,7 @@ fn test_app_state_cycling_and_toggles() { } #[test] +#[serial] fn test_search_cursor_and_character_edits() { let mut state = AppState::new(); @@ -361,6 +381,7 @@ fn test_search_cursor_and_character_edits() { } #[test] +#[serial] fn test_editing_mode_field_manipulation() { use dev_cli::tui::state::{IdeTabFocus, InputMode, Tab}; @@ -491,6 +512,7 @@ fn test_settings_tab_root_management() { } #[test] +#[serial] fn test_tree_toggle_and_scroll_offset() { use dev_cli::tui::state::Tab; use dev_cli::tui::tree::TreeNode; From 7788c8c826438bd45ada54432ee60c3d1c88b455 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Tue, 29 Sep 2026 18:45:01 +0100 Subject: [PATCH 4/6] fix: flaky test sonar --- tests/cli_clone.rs | 128 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/cli_clone.rs b/tests/cli_clone.rs index 164bb91..0d80875 100644 --- a/tests/cli_clone.rs +++ b/tests/cli_clone.rs @@ -3,6 +3,7 @@ mod common; use assert_cmd::Command as AssertCommand; use predicates::prelude::*; use serial_test::serial; +use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; @@ -27,13 +28,53 @@ projects_root = ["{}"] (cmd, config_dir, roots_dir) } +fn setup_test_env_with_roots(roots: &[PathBuf]) -> (AssertCommand, TempDir) { + let config_dir = tempfile::tempdir().unwrap(); + let roots_toml = roots + .iter() + .map(|r| format!("\"{}\"", r.display().to_string().replace('\\', "\\\\"))) + .collect::>() + .join(", "); + + let config = format!( + r#" +default_ide = "Cursor" +projects_root = [{roots_toml}] +"# + ); + + std::fs::write(config_dir.path().join("config.toml"), config).unwrap(); + + let mut cmd = AssertCommand::cargo_bin("dev").unwrap(); + cmd.env("DEVCLI_CONFIG_DIR", config_dir.path()); + cmd.env("DEVCLI_SKIP_ONBOARDING", "1"); + + (cmd, config_dir) +} + #[test] +#[serial] fn clone_command_fails_without_url() { let (mut cmd, _cfg, _roots) = setup_test_env(); cmd.args(["clone"]).assert().failure().stderr(predicate::str::contains("Usage")); } +#[test] +#[serial] +fn clone_command_fails_when_no_roots_configured() { + let (mut cmd, _cfg) = setup_test_env_with_roots(&[]); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url]) + .assert() + .failure() + .stderr(predicate::str::contains("No project roots configured")); +} + #[test] #[serial] fn clone_command_clones_local_repo() { @@ -70,3 +111,90 @@ fn clone_command_with_custom_name() { assert!(target_path.exists()); assert!(target_path.join(".git").exists()); } + +#[test] +#[serial] +fn clone_command_strips_git_suffix() { + let (mut cmd, _cfg, roots) = setup_test_env(); + + let source_parent = tempfile::tempdir().unwrap(); + let source_dir = source_parent.path().join("sample-repo.git"); + std::fs::create_dir_all(&source_dir).unwrap(); + Command::new("git").arg("init").current_dir(&source_dir).status().unwrap(); + + let source_url = source_dir.to_string_lossy().to_string(); + + cmd.args(["clone", &source_url]).assert().success(); + + let target_path = roots.path().join("sample-repo"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_with_root_index() { + let root1 = tempfile::tempdir().unwrap(); + let root2 = tempfile::tempdir().unwrap(); + + let (mut cmd, _cfg) = + setup_test_env_with_roots(&[root1.path().to_path_buf(), root2.path().to_path_buf()]); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url, "--root", "1", "--name", "IndexedRepo"]).assert().success(); + + let target_path = root2.path().join("IndexedRepo"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); + assert!(!root1.path().join("IndexedRepo").exists()); +} + +#[test] +#[serial] +fn clone_command_with_out_of_bounds_root_index() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + let source_url = source_dir.path().to_string_lossy().to_string(); + + cmd.args(["clone", &source_url, "--root", "5"]) + .assert() + .failure() + .stderr(predicate::str::contains("Root index 5 is out of bounds")); +} + +#[test] +#[serial] +fn clone_command_with_custom_root_path_creates_directory() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + let parent_dir = tempfile::tempdir().unwrap(); + let nonexistent_root = parent_dir.path().join("new_subfolder"); + + let source_dir = tempfile::tempdir().unwrap(); + Command::new("git").arg("init").current_dir(source_dir.path()).status().unwrap(); + let source_url = source_dir.path().to_string_lossy().to_string(); + + let root_str = nonexistent_root.to_string_lossy().to_string(); + cmd.args(["clone", &source_url, "--root", &root_str, "--name", "PathRepo"]).assert().success(); + + let target_path = nonexistent_root.join("PathRepo"); + assert!(target_path.exists()); + assert!(target_path.join(".git").exists()); +} + +#[test] +#[serial] +fn clone_command_fails_on_git_clone_error() { + let (mut cmd, _cfg, _roots) = setup_test_env(); + + cmd.args(["clone", "file:///nonexistent/invalid/repo.git"]) + .assert() + .failure() + .stderr(predicate::str::contains("git clone failed")); +} From 57c2108012e5ba6635a359b47c00aa77aa16d9d2 Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Wed, 30 Sep 2026 00:08:54 +0100 Subject: [PATCH 5/6] feature:git --- src/commands/clone.rs | 256 +++++++++++++++++++++++++++----- src/tui/event.rs | 36 ++++- src/tui/state.rs | 230 +++++++++++++++++++++++++++- src/tui/ui.rs | 7 +- src/tui/widgets/clone_modal.rs | 156 +++++++++++++++++++ src/tui/widgets/footer.rs | 12 ++ src/tui/widgets/project_list.rs | 207 +++++++++++++++++++++++++- tests/tui_clone_modal.rs | 125 ++++++++++++++++ 8 files changed, 979 insertions(+), 50 deletions(-) create mode 100644 src/tui/widgets/clone_modal.rs create mode 100644 tests/tui_clone_modal.rs diff --git a/src/commands/clone.rs b/src/commands/clone.rs index 6551be5..4a20507 100644 --- a/src/commands/clone.rs +++ b/src/commands/clone.rs @@ -1,13 +1,222 @@ //! Clone command implementation. use anyhow::{Result, anyhow}; -use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::{ + io::BufReader, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::mpsc, + thread, +}; use crate::cli::CloneArgs; use crate::config::Config; -/// Executes the clone command. +/// Helper to extract repository directory name from a Git URL or file path. +pub fn extract_repo_name(url: &str) -> String { + let trimmed = url.trim().trim_end_matches('/').trim_end_matches('\\'); + let last_segment = trimmed.rsplit(['/', '\\', ':']).next().unwrap_or("repository"); + + let name = last_segment.strip_suffix(".git").unwrap_or(last_segment); + if name.is_empty() { "repository".to_string() } else { name.to_string() } +} + +/// Core git clone helper function. +pub fn clone_repository( + url: &str, + target_root: &Path, + custom_name: Option<&str>, + inherit_stdio: bool, +) -> Result { + if !target_root.exists() { + std::fs::create_dir_all(target_root)?; + } + + let repo_name = match custom_name { + Some(name) if !name.trim().is_empty() => name.trim().to_string(), + _ => extract_repo_name(url), + }; + + let target_path = target_root.join(&repo_name); + + let mut cmd = Command::new("git"); + cmd.arg("clone"); + cmd.arg(url); + cmd.arg(&target_path); + + if inherit_stdio { + cmd.stdout(Stdio::inherit()); + cmd.stderr(Stdio::inherit()); + cmd.stdin(Stdio::inherit()); + let status = cmd.status().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?; + if status.success() { + Ok(target_path) + } else { + Err(anyhow!("git clone failed with exit code: {}", status)) + } + } else { + cmd.env("GIT_TERMINAL_PROMPT", "0"); + let output = cmd.output().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?; + if output.status.success() { + Ok(target_path) + } else { + let err_msg = String::from_utf8_lossy(&output.stderr); + let clean_msg = err_msg + .lines() + .find(|l| l.contains("fatal:") || l.contains("error:")) + .unwrap_or_else(|| err_msg.lines().next().unwrap_or("git clone failed")); + Err(anyhow!("{}", clean_msg.trim())) + } + } +} + +/// Channels returned by [`clone_repository_streamed`]. +pub struct CloneChannels { + /// Receives individual log lines from git stderr as they arrive. + pub log_rx: mpsc::Receiver, + /// Receives the final result — the cloned path on success or an error message. + pub done_rx: mpsc::Receiver>, +} + +impl std::fmt::Debug for CloneChannels { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CloneChannels").finish_non_exhaustive() + } +} + +/// Spawn a background thread that runs `git clone --progress` and streams stderr +/// lines back via channels so the TUI can render live progress without blocking. +pub fn clone_repository_streamed( + url: String, + target_root: PathBuf, + custom_name: Option, +) -> CloneChannels { + let (log_tx, log_rx) = mpsc::channel::(); + let (done_tx, done_rx) = mpsc::channel::>(); + + thread::spawn(move || { + if let Err(e) = std::fs::create_dir_all(&target_root) { + let _ = done_tx.send(Err(e.to_string())); + return; + } + + let repo_name = match custom_name.as_deref() { + Some(n) if !n.trim().is_empty() => n.trim().to_string(), + _ => extract_repo_name(&url), + }; + + let target_path = target_root.join(&repo_name); + + let mut cmd = Command::new("git"); + // --progress forces git to emit progress even when stderr is not a TTY. + cmd.args(["clone", "--progress", &url, &target_path.to_string_lossy()]); + cmd.env("GIT_TERMINAL_PROMPT", "0"); + // Pipe stderr so we can read it; stdout is unused for clone. + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::piped()); + cmd.stdin(Stdio::null()); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let _ = done_tx.send(Err(format!("Failed to spawn git: {e}"))); + return; + } + }; + + // Read stderr byte by byte, splitting on both \r and \n. + // git --progress uses \r to overwrite progress lines in a terminal; + // we treat each \r-terminated segment as a separate log entry so the + // TUI can show them sequentially. + if let Some(stderr) = child.stderr.take() { + use std::io::Read; + let mut reader = BufReader::new(stderr); + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + loop { + match reader.read(&mut byte) { + Ok(0) | Err(_) => { + // Flush any trailing buffer. + if let Ok(s) = std::str::from_utf8(&buf) { + let clean = strip_ansi(s); + for part in clean.split('\n') { + let t = part.trim(); + if !t.is_empty() { + let _ = log_tx.send(t.to_string()); + } + } + } + break; + } + Ok(_) => { + if byte[0] == b'\r' || byte[0] == b'\n' { + if let Ok(s) = std::str::from_utf8(&buf) { + let clean = strip_ansi(s); + let t = clean.trim().to_string(); + if !t.is_empty() { + let _ = log_tx.send(t); + } + } + buf.clear(); + } else { + buf.push(byte[0]); + } + } + } + } + } + + match child.wait() { + Ok(status) if status.success() => { + let _ = done_tx.send(Ok(target_path)); + } + Ok(status) => { + let _ = done_tx.send(Err(format!("git clone failed (exit {})", status))); + } + Err(e) => { + let _ = done_tx.send(Err(e.to_string())); + } + } + }); + + CloneChannels { log_rx, done_rx } +} + +/// Remove ANSI escape sequences from a string. +/// Handles `ESC[...m` colour codes and carriage-return progress lines. +fn strip_ansi(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out = String::with_capacity(s.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\x1b' && i + 1 < bytes.len() && bytes[i + 1] == b'[' { + // Skip until we hit the final byte of the escape sequence (a letter or '~'). + i += 2; + while i < bytes.len() && !bytes[i].is_ascii_alphabetic() && bytes[i] != b'~' { + i += 1; + } + i += 1; // skip the terminating letter + } else if bytes[i] == b'\r' { + // Carriage return — git uses these for in-place progress; treat as newline. + out.push('\n'); + i += 1; + } else { + // Safety: we only push valid UTF-8 subslices. + let start = i; + i += 1; + while i < bytes.len() && bytes[i] != b'\x1b' && bytes[i] != b'\r' { + i += 1; + } + if let Ok(chunk) = std::str::from_utf8(&bytes[start..i]) { + out.push_str(chunk); + } + } + } + // Trim trailing whitespace/newlines on each virtual line. + out.lines().map(str::trim_end).collect::>().join("\n") +} + +/// Executes the CLI clone command. pub fn execute(args: CloneArgs) -> Result<()> { let cfg = Config::load()?; if cfg.projects_root.is_empty() { @@ -36,41 +245,8 @@ pub fn execute(args: CloneArgs) -> Result<()> { None => cfg.projects_root[0].clone(), }; - if !target_root.exists() { - std::fs::create_dir_all(&target_root)?; - } - - // Extract repo name if not provided - let repo_name = match args.name { - Some(name) => name, - None => { - let path = std::path::Path::new(&args.url); - let file_name = path.file_name().unwrap_or_default().to_string_lossy(); - let name = if file_name.is_empty() { "repository" } else { file_name.as_ref() }; - name.strip_suffix(".git").unwrap_or(name).to_string() - } - }; - - let target_path = target_root.join(repo_name); - - println!("Cloning '{}' into '{}'...", args.url, target_path.display()); - - let mut cmd = Command::new("git"); - cmd.arg("clone"); - cmd.arg(&args.url); - cmd.arg(&target_path); - - // Stream output directly to terminal so user can interact if auth is needed - cmd.stdout(Stdio::inherit()); - cmd.stderr(Stdio::inherit()); - cmd.stdin(Stdio::inherit()); - - let status = cmd.status().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?; - - if status.success() { - println!("Successfully cloned into {}", target_path.display()); - Ok(()) - } else { - Err(anyhow!("git clone failed with exit code: {}", status)) - } + println!("Cloning '{}' into '{}'...", args.url, target_root.display()); + let target_path = clone_repository(&args.url, &target_root, args.name.as_deref(), true)?; + println!("Successfully cloned into {}", target_path.display()); + Ok(()) } diff --git a/src/tui/event.rs b/src/tui/event.rs index a8dae76..e1003fa 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -29,6 +29,9 @@ where } } + // Pump the background clone task channels every tick. + state.tick_clone_task(); + Ok(()) } @@ -55,7 +58,11 @@ where { match key.code { KeyCode::Esc => { - if state.active_tab == Tab::Ide && state.ide_tab_focus == IdeTabFocus::ConfirmDelete { + if state.is_clone_form_active() { + state.close_clone_modal(); + } else 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(); @@ -67,6 +74,11 @@ where KeyCode::F(1) => { let _ = state.refresh(); } + KeyCode::F(2) => { + if state.active_tab == Tab::Projects { + state.open_clone_modal(); + } + } KeyCode::Tab => match state.active_tab { Tab::Projects | Tab::Recent => state.cycle_selected_ide(), _ => {} @@ -146,9 +158,9 @@ where state.push_char(c); state.clamp_selection(); } + Tab::Recent => {} Tab::Ide => handle_ide_tab_char(c, state), Tab::Settings => handle_settings_tab_char(c, state), - _ => {} }, _ => {} @@ -257,6 +269,26 @@ fn handle_settings_tab_char(c: char, state: &mut AppState) { /// Handle keys in Editing mode (form input). fn handle_editing_key(key: KeyEvent, state: &mut AppState) { + if state.is_clone_form_active() { + match key.code { + KeyCode::Esc => state.close_clone_modal(), + KeyCode::Enter => { + if let Err(msg) = state.submit_clone() { + state.clone_status_message = Some(format!("✗ {msg}")); + } + } + KeyCode::Tab | KeyCode::Down => state.cycle_clone_field(), + KeyCode::BackTab | KeyCode::Up => state.cycle_clone_field_backwards(), + KeyCode::Left => state.clone_move_left(), + KeyCode::Right => state.clone_move_right(), + KeyCode::Backspace => state.clone_pop_char(), + KeyCode::Delete => state.clone_delete_char(), + KeyCode::Char(c) => state.clone_push_char(c), + _ => {} + } + return; + } + match key.code { KeyCode::Esc => { // Cancel editing and return to Normal mode. diff --git a/src/tui/state.rs b/src/tui/state.rs index ab83602..ce49b79 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -1,6 +1,9 @@ //! Application state for the dashboard. -use std::{collections::HashMap, path::PathBuf}; +use std::{ + collections::{HashMap, VecDeque}, + path::PathBuf, +}; use crate::{ config::Config, @@ -47,6 +50,15 @@ pub enum SettingsTabFocus { AddRoot, } +/// Which element has focus on the Projects tab. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectsTabFocus { + /// Browsing the project list. + List, + /// Filling in the Clone form. + CloneForm, +} + /// An entry in the IDE tab list — either a built-in detected IDE or a custom one. #[derive(Debug, Clone)] pub enum IdeListEntry { @@ -136,6 +148,20 @@ pub struct AppState { /// Settings tab status message. pub settings_status_message: Option, + // --- Clone form state (Projects tab) --- + pub projects_tab_focus: ProjectsTabFocus, + pub clone_url: String, + pub clone_url_cursor: usize, + pub clone_root_index: usize, + pub clone_name: String, + pub clone_name_cursor: usize, + pub clone_field: usize, + pub clone_status_message: Option, + /// Streaming log lines received from the background git process. + pub clone_log_lines: VecDeque, + /// Background clone task channels (None when idle). + pub clone_task: Option, + /// Maximum search depth for repository scanner. pub max_depth: usize, @@ -211,6 +237,17 @@ impl Default for AppState { project_roots, settings_status_message: None, + projects_tab_focus: ProjectsTabFocus::List, + clone_url: String::new(), + clone_url_cursor: 0, + clone_root_index: 0, + clone_name: String::new(), + clone_name_cursor: 0, + clone_field: 0, + clone_status_message: None, + clone_log_lines: VecDeque::new(), + clone_task: None, + max_depth: config.max_depth, ignore_patterns: config.ignore_patterns, theme: config.theme, @@ -879,4 +916,195 @@ impl AppState { let _ = self.refresh(); } } + + // ────────────────────────────────────────────────────── + // Clone Form Methods (Projects tab) + // ────────────────────────────────────────────────────── + + /// Opens the clone form on the Projects tab. + pub fn open_clone_modal(&mut self) { + self.projects_tab_focus = ProjectsTabFocus::CloneForm; + self.clone_url.clear(); + self.clone_url_cursor = 0; + self.clone_root_index = 0; + self.clone_name.clear(); + self.clone_name_cursor = 0; + self.clone_field = 0; + self.clone_status_message = None; + self.clone_log_lines.clear(); + self.clone_task = None; + self.input_mode = InputMode::Editing; + } + + /// Closes the clone form and restores normal input mode. + pub fn close_clone_modal(&mut self) { + self.projects_tab_focus = ProjectsTabFocus::List; + self.input_mode = InputMode::Normal; + } + + /// Returns true if the clone form is active. + pub fn is_clone_form_active(&self) -> bool { + self.projects_tab_focus == ProjectsTabFocus::CloneForm + } + + /// Cycle active field in clone modal (0 = URL, 1 = Root Selector, 2 = Custom Name). + pub fn cycle_clone_field(&mut self) { + self.clone_field = (self.clone_field + 1) % 3; + } + + /// Cycle active field backwards in clone modal. + pub fn cycle_clone_field_backwards(&mut self) { + self.clone_field = if self.clone_field == 0 { 2 } else { self.clone_field - 1 }; + } + + /// Push character into active clone input field. + pub fn clone_push_char(&mut self, c: char) { + match self.clone_field { + 0 => { + self.clone_url.insert(self.clone_url_cursor, c); + self.clone_url_cursor += 1; + } + 2 => { + self.clone_name.insert(self.clone_name_cursor, c); + self.clone_name_cursor += 1; + } + _ => {} + } + } + + /// Backspace character from active clone input field. + pub fn clone_pop_char(&mut self) { + match self.clone_field { + 0 if self.clone_url_cursor > 0 => { + self.clone_url_cursor -= 1; + self.clone_url.remove(self.clone_url_cursor); + } + 2 if self.clone_name_cursor > 0 => { + self.clone_name_cursor -= 1; + self.clone_name.remove(self.clone_name_cursor); + } + _ => {} + } + } + + /// Delete character at cursor from active clone input field. + pub fn clone_delete_char(&mut self) { + match self.clone_field { + 0 if self.clone_url_cursor < self.clone_url.len() => { + self.clone_url.remove(self.clone_url_cursor); + } + 2 if self.clone_name_cursor < self.clone_name.len() => { + self.clone_name.remove(self.clone_name_cursor); + } + _ => {} + } + } + + /// Move cursor left in active clone input field / select previous root. + pub fn clone_move_left(&mut self) { + match self.clone_field { + 0 if self.clone_url_cursor > 0 => { + self.clone_url_cursor -= 1; + } + 1 if self.clone_root_index > 0 => { + self.clone_root_index -= 1; + } + 2 if self.clone_name_cursor > 0 => { + self.clone_name_cursor -= 1; + } + _ => {} + } + } + + /// Move cursor right in active clone input field / select next root. + pub fn clone_move_right(&mut self) { + match self.clone_field { + 0 if self.clone_url_cursor < self.clone_url.len() => { + self.clone_url_cursor += 1; + } + 1 if !self.project_roots.is_empty() + && self.clone_root_index + 1 < self.project_roots.len() => + { + self.clone_root_index += 1; + } + 2 if self.clone_name_cursor < self.clone_name.len() => { + self.clone_name_cursor += 1; + } + _ => {} + } + } + + /// Submit the clone form — validates inputs and spawns a background git thread. + pub fn submit_clone(&mut self) -> Result<(), String> { + let url = self.clone_url.trim().to_string(); + if url.is_empty() { + return Err("URL cannot be empty".to_string()); + } + + if self.project_roots.is_empty() { + return Err("No project roots configured".to_string()); + } + + // Already cloning — ignore duplicate submits. + if self.clone_task.is_some() { + return Ok(()); + } + + let root_index = self.clone_root_index.min(self.project_roots.len() - 1); + let target_root = self.project_roots[root_index].clone(); + + let custom_name = if self.clone_name.trim().is_empty() { + None + } else { + Some(self.clone_name.trim().to_string()) + }; + + self.clone_log_lines.clear(); + self.clone_status_message = Some("⏳ Cloning...".to_string()); + // Switch to Normal mode so the user can navigate away but keep form open. + self.input_mode = InputMode::Normal; + + let channels = + crate::commands::clone::clone_repository_streamed(url, target_root, custom_name); + self.clone_task = Some(channels); + Ok(()) + } + + /// Poll the background clone task channels and update state. + /// Call this every tick from the event loop. + pub fn tick_clone_task(&mut self) { + // Keep at most 200 log lines. + const MAX_LINES: usize = 200; + + if let Some(ref task) = self.clone_task { + // Drain all pending log lines (non-blocking). + while let Ok(line) = task.log_rx.try_recv() { + if self.clone_log_lines.len() >= MAX_LINES { + self.clone_log_lines.pop_front(); + } + self.clone_log_lines.push_back(line); + } + + // Check for completion. + match task.done_rx.try_recv() { + Ok(Ok(cloned_path)) => { + let msg = format!("✓ Cloned into {}", cloned_path.display()); + self.clone_status_message = Some(msg.clone()); + self.clone_log_lines.push_back(format!("✓ Done → {}", cloned_path.display())); + self.clone_task = None; + // Immediately refresh the project tree. + let _ = self.refresh(); + // Automatically close the clone split window. + self.close_clone_modal(); + } + Ok(Err(err)) => { + self.clone_status_message = Some(format!("✗ {err}")); + self.clone_log_lines.push_back(format!("✗ Error: {err}")); + self.clone_task = None; + self.input_mode = InputMode::Editing; + } + Err(_) => {} // Still running. + } + } + } } diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 227b946..39910a9 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -30,7 +30,12 @@ pub fn render(frame: &mut Frame, state: &AppState) { match state.active_tab { Tab::Projects => { - search::render(frame, chunks[2], state, palette); + // Hide search bar when clone form is open (it occupies the content area) + if state.is_clone_form_active() { + frame.render_widget(Block::default(), chunks[2]); + } else { + search::render(frame, chunks[2], state, palette); + } project_list::render(frame, chunks[3], state, palette); } Tab::Recent => { diff --git a/src/tui/widgets/clone_modal.rs b/src/tui/widgets/clone_modal.rs new file mode 100644 index 0000000..a592514 --- /dev/null +++ b/src/tui/widgets/clone_modal.rs @@ -0,0 +1,156 @@ +//! Clone repository modal widget. + +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::{ + tui::{state::AppState, theme::Palette}, + utils::path::display_path, +}; + +/// Renders the clone modal dialog if active. +pub fn render(frame: &mut Frame, state: &AppState, palette: Palette) { + if !state.show_clone_modal { + return; + } + + let area = centered_rect(65, 14, frame.area()); + + // Clear background for modal + frame.render_widget(Clear, area); + + let block = Block::default() + .title(Span::styled( + " 📥 Clone Repository ", + Style::default().fg(palette.primary).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(palette.border)); + + frame.render_widget(block, area); + + // Inner layout + let inner_area = area.inner(ratatui::layout::Margin { + vertical: 1, + horizontal: 2, + }); + + let chunks = Layout::vertical([ + Constraint::Length(3), // URL input + Constraint::Length(3), // Target Root Selector + Constraint::Length(3), // Custom Name input + Constraint::Min(1), // Status / Hints + ]) + .split(inner_area); + + // Field 0: Repository URL + let url_style = if state.clone_field == 0 { + Style::default().fg(palette.primary) + } else { + Style::default().fg(palette.muted) + }; + let url_block = Block::default() + .title(" Git Repository URL ") + .borders(Borders::ALL) + .border_style(url_style); + + let mut url_text = state.clone_url.clone(); + if state.clone_field == 0 { + url_text.insert(state.clone_url_cursor.min(url_text.len()), '│'); + } + frame.render_widget( + Paragraph::new(url_text).block(url_block).style(Style::default().fg(palette.text)), + chunks[0], + ); + + // Field 1: Target Root Selector + let root_style = if state.clone_field == 1 { + Style::default().fg(palette.primary) + } else { + Style::default().fg(palette.muted) + }; + let root_block = Block::default() + .title(" Target Root (← / → to select) ") + .borders(Borders::ALL) + .border_style(root_style); + + let root_display = if state.project_roots.is_empty() { + "No project roots configured".to_string() + } else { + let current_root = + &state.project_roots[state.clone_root_index.min(state.project_roots.len() - 1)]; + format!( + "[{}/{}] 📁 {}", + state.clone_root_index + 1, + state.project_roots.len(), + display_path(current_root) + ) + }; + + frame.render_widget( + Paragraph::new(root_display).block(root_block).style(Style::default().fg(palette.text)), + chunks[1], + ); + + // Field 2: Custom Name (Optional) + let name_style = if state.clone_field == 2 { + Style::default().fg(palette.primary) + } else { + Style::default().fg(palette.muted) + }; + let name_block = Block::default() + .title(" Custom Folder Name (Optional) ") + .borders(Borders::ALL) + .border_style(name_style); + + let mut name_text = state.clone_name.clone(); + if state.clone_field == 2 { + name_text.insert(state.clone_name_cursor.min(name_text.len()), '│'); + } + frame.render_widget( + Paragraph::new(name_text).block(name_block).style(Style::default().fg(palette.text)), + chunks[2], + ); + + // Bottom Status / Controls + let footer_text = if let Some(ref status) = state.clone_status_message { + let color = if status.starts_with('✓') { + palette.success + } else { + palette.danger + }; + Line::from(Span::styled(status.clone(), Style::default().fg(color))) + } else { + 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)), + ]) + }; + + frame.render_widget(Paragraph::new(footer_text).alignment(Alignment::Center), chunks[3]); +} + +/// Helper function to center a Rect popup modal. +fn centered_rect(width: u16, height: u16, r: Rect) -> Rect { + let popup_layout = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height), + Constraint::Fill(1), + ]) + .split(r); + + Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(width), + Constraint::Fill(1), + ]) + .split(popup_layout[1])[1] +} diff --git a/src/tui/widgets/footer.rs b/src/tui/widgets/footer.rs index e1bd3b3..8046c46 100644 --- a/src/tui/widgets/footer.rs +++ b/src/tui/widgets/footer.rs @@ -27,6 +27,16 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) /// Build the footer line based on the active tab and input mode. fn build_footer_line(state: &AppState, palette: Palette) -> Line<'static> { + if state.is_clone_form_active() { + return 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)), + ]); + } + if state.input_mode == InputMode::Editing { return match state.active_tab { Tab::Ide => Line::from(vec![ @@ -57,6 +67,8 @@ fn build_footer_line(state: &AppState, palette: Palette) -> Line<'static> { Span::raw(" "), Span::styled("F1 Refresh", Style::default().fg(palette.purple)), Span::raw(" "), + Span::styled("F2 Clone", Style::default().fg(palette.success)), + Span::raw(" "), Span::styled("Esc Quit", Style::default().fg(palette.danger)), ]), Tab::Recent => Line::from(vec![ diff --git a/src/tui/widgets/project_list.rs b/src/tui/widgets/project_list.rs index c4bae99..e129a63 100644 --- a/src/tui/widgets/project_list.rs +++ b/src/tui/widgets/project_list.rs @@ -1,18 +1,18 @@ -//! Project list widget. +//! Project list widget — shows project tree or clone form split-view. use ratatui::{ Frame, - layout::Rect, + layout::{Constraint, Layout, Rect}, style::{Modifier, Style}, text::{Line, Span}, - widgets::ListItem, + widgets::{Block, Borders, ListItem, Paragraph}, }; use crate::{ tui::{ - state::AppState, + state::{AppState, InputMode, ProjectsTabFocus}, theme::Palette, - widgets::list::{Notice, render_centered_notice, render_list}, + widgets::list::{Notice, render_centered_notice, render_field_spans, render_list}, }, utils::path::display_path, }; @@ -20,7 +20,14 @@ use unicode_width::UnicodeWidthStr; /// Render the widget onto the given frame and area. pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { - // Route to the correct tab. + match state.projects_tab_focus { + ProjectsTabFocus::List => render_project_list(frame, area, state, palette), + ProjectsTabFocus::CloneForm => render_split_view(frame, area, state, palette), + } +} + +/// Render the project tree list. +fn render_project_list(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { let items = state.visible_items(); let count = state.filtered_projects().len(); let title = format!(" Projects ({count}) "); @@ -101,3 +108,191 @@ pub fn render(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) render_list(frame, area, title, list_items, Some(state.selected_index), palette); } + +/// Render the split view: project list (top) + clone form (bottom). +fn render_split_view(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let chunks = + Layout::vertical([Constraint::Percentage(55), Constraint::Percentage(45)]).split(area); + + render_project_list(frame, chunks[0], state, palette); + render_clone_form(frame, chunks[1], state, palette); +} + +/// Render the Clone Repository form (matches add-root / add-custom-IDE style). +fn render_clone_form(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let is_cloning = state.clone_task.is_some(); + let has_logs = !state.clone_log_lines.is_empty(); + + // When cloning is active or logs are present, split the form vertically: + // top half = input fields, bottom half = git log output. + let (fields_area, log_area) = if is_cloning || has_logs { + let chunks = Layout::vertical([Constraint::Min(8), Constraint::Min(4)]).split(area); + (chunks[0], Some(chunks[1])) + } else { + (area, None) + }; + + render_clone_fields(frame, fields_area, state, palette); + + if let Some(log_rect) = log_area { + render_clone_log(frame, log_rect, state, palette); + } +} + +/// Render the input fields section of the clone form. +fn render_clone_fields(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + let is_editing = state.input_mode == InputMode::Editing; + let is_cloning = state.clone_task.is_some(); + + // Field 0: Repository URL + let url_active = state.clone_field == 0 && !is_cloning; + let mut line_url = vec![Span::styled(" URL: ", Style::default().fg(palette.info))]; + line_url.extend(render_field_spans( + &state.clone_url, + state.clone_url_cursor, + url_active, + is_editing && !is_cloning, + palette, + )); + + // Field 1: Target Root Selector (not a text field — use arrow keys) + let root_active = state.clone_field == 1 && !is_cloning; + let root_label_style = if root_active && is_editing { + Style::default().fg(palette.primary).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.info) + }; + let root_value = if state.project_roots.is_empty() { + "No roots configured".to_string() + } else { + let idx = state.clone_root_index.min(state.project_roots.len() - 1); + format!( + "← [{}/{}] {} →", + idx + 1, + state.project_roots.len(), + display_path(&state.project_roots[idx]) + ) + }; + let root_value_style = if root_active && is_editing { + Style::default().fg(palette.text) + } else { + Style::default().fg(palette.muted) + }; + let line_root = vec![ + Span::styled(" Root: ", root_label_style), + Span::styled(root_value, root_value_style), + ]; + + // Field 2: Custom Name (optional) + let name_active = state.clone_field == 2 && !is_cloning; + let mut line_name = vec![Span::styled(" Name: ", Style::default().fg(palette.info))]; + line_name.extend(render_field_spans( + &state.clone_name, + state.clone_name_cursor, + name_active, + is_editing && !is_cloning, + palette, + )); + if state.clone_name.is_empty() && !(name_active && is_editing) { + line_name.push(Span::styled("(inferred from URL)", Style::default().fg(palette.muted))); + } + + let mut lines = + vec![Line::from(""), Line::from(line_url), Line::from(line_root), Line::from(line_name)]; + + if is_cloning { + // While cloning, show a "cloning…" notice instead of the action buttons. + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "⏳ Cloning in progress…", + Style::default().fg(palette.warning).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("Esc", Style::default().fg(palette.danger)), + Span::raw(" Cancel"), + ])); + } else { + // Idle: show normal form buttons. + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("Tab", Style::default().fg(palette.primary)), + Span::raw(" Next Field "), + 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(ref msg) = state.clone_status_message { + let color = if msg.starts_with('✓') { + palette.success + } else if msg.starts_with('⏳') { + palette.warning + } else { + palette.danger + }; + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(format!(" {msg}"), Style::default().fg(color)))); + } + } + + let border_color = if is_cloning { + palette.warning + } else if is_editing { + palette.primary + } else { + palette.border + }; + + let title = if is_cloning { " Clone Repository — Cloning… " } else { " Clone Repository " }; + + let form = Paragraph::new(lines).block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)), + ); + + frame.render_widget(form, area); +} + +/// Render the streaming git log output panel. +fn render_clone_log(frame: &mut Frame, area: Rect, state: &AppState, palette: Palette) { + // Show the most recent lines that fit in the area. + let inner_height = area.height.saturating_sub(2) as usize; // minus border rows + let total = state.clone_log_lines.len(); + let start = total.saturating_sub(inner_height); + + let log_lines: Vec = state + .clone_log_lines + .iter() + .skip(start) + .map(|l| { + let color = if l.starts_with('✓') || l.starts_with("✓") { + palette.success + } else if l.starts_with('✗') { + palette.danger + } else { + palette.muted + }; + Line::from(Span::styled(format!(" {l}"), Style::default().fg(color))) + }) + .collect(); + + let is_cloning = state.clone_task.is_some(); + let log_widget = Paragraph::new(log_lines).block( + Block::default() + .title(if is_cloning { " Git Output — Live " } else { " Git Output " }) + .borders(Borders::ALL) + .border_style(Style::default().fg(if is_cloning { + palette.warning + } else { + palette.success + })), + ); + + frame.render_widget(log_widget, area); +} diff --git a/tests/tui_clone_modal.rs b/tests/tui_clone_modal.rs new file mode 100644 index 0000000..15d4870 --- /dev/null +++ b/tests/tui_clone_modal.rs @@ -0,0 +1,125 @@ +mod common; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use dev_cli::tui::{ + event::handle_key, + state::{AppState, InputMode, Tab}, +}; +use serial_test::serial; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +#[serial] +fn open_and_close_clone_form() { + let mut state = AppState::new(); + assert!(!state.is_clone_form_active()); + assert_eq!(state.input_mode, InputMode::Normal); + + state.open_clone_modal(); + assert!(state.is_clone_form_active()); + assert_eq!(state.input_mode, InputMode::Editing); + + handle_key(key(KeyCode::Esc), &mut state); + assert!(!state.is_clone_form_active()); + assert_eq!(state.input_mode, InputMode::Normal); +} + +#[test] +#[serial] +fn clone_form_fields_and_navigation() { + let mut state = AppState::new(); + state.open_clone_modal(); + + // Field 0: URL + assert_eq!(state.clone_field, 0); + handle_key(key(KeyCode::Char('h')), &mut state); + handle_key(key(KeyCode::Char('t')), &mut state); + assert_eq!(state.clone_url, "ht"); + + // Cycle to Field 1: Target Root Selector via Tab + handle_key(key(KeyCode::Tab), &mut state); + assert_eq!(state.clone_field, 1); + + // Cycle to Field 2: Custom Name via Tab + handle_key(key(KeyCode::Tab), &mut state); + assert_eq!(state.clone_field, 2); + handle_key(key(KeyCode::Char('m')), &mut state); + handle_key(key(KeyCode::Char('y')), &mut state); + assert_eq!(state.clone_name, "my"); + + // Cycle backwards to Field 1 via BackTab + handle_key(key(KeyCode::BackTab), &mut state); + assert_eq!(state.clone_field, 1); + + // Cycle backwards to Field 0 via BackTab + handle_key(key(KeyCode::BackTab), &mut state); + assert_eq!(state.clone_field, 0); +} + +#[test] +#[serial] +fn clone_form_submit_empty_url_fails() { + let mut state = AppState::new(); + state.open_clone_modal(); + + handle_key(key(KeyCode::Enter), &mut state); + + assert!(state.is_clone_form_active()); + assert!(state.clone_status_message.is_some()); + assert!(state.clone_status_message.unwrap().contains("URL cannot be empty")); +} + +#[test] +#[serial] +fn clone_shortcut_is_f2_on_projects_tab() { + let mut state = AppState::new(); + + // On Recent tab: F2 does not open clone form + state.active_tab = Tab::Recent; + handle_key(key(KeyCode::F(2)), &mut state); + assert!(!state.is_clone_form_active()); + + // On Projects tab: F2 opens clone form + state.active_tab = Tab::Projects; + handle_key(key(KeyCode::F(2)), &mut state); + assert!(state.is_clone_form_active()); +} + +#[test] +#[serial] +fn c_key_goes_to_search_not_clone() { + let mut state = AppState::new(); + state.active_tab = Tab::Projects; + + // Pressing 'c' should feed into the search bar, not open clone form + handle_key(key(KeyCode::Char('c')), &mut state); + assert!(!state.is_clone_form_active()); + assert_eq!(state.search_query, "c"); +} + +#[test] +#[serial] +fn clone_form_closes_automatically_on_successful_clone() { + use dev_cli::commands::clone::CloneChannels; + use std::path::PathBuf; + use std::sync::mpsc; + + let mut state = AppState::new(); + state.open_clone_modal(); + assert!(state.is_clone_form_active()); + + let (log_tx, log_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + state.clone_task = Some(CloneChannels { log_rx, done_rx }); + + log_tx.send("Cloning...".to_string()).unwrap(); + done_tx.send(Ok(PathBuf::from("/tmp/myrepo"))).unwrap(); + + state.tick_clone_task(); + + assert!(!state.is_clone_form_active()); + assert_eq!(state.input_mode, InputMode::Normal); +} From 5c29cd218edd91cc086d04fdf6abf96e8ab0a62f Mon Sep 17 00:00:00 2001 From: Parth Prajapati Date: Wed, 30 Sep 2026 01:11:19 +0100 Subject: [PATCH 6/6] fix: tests --- src/commands/clone.rs | 86 +++++++++++++++++++++++++++++++ tests/tui_clone_modal.rs | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/src/commands/clone.rs b/src/commands/clone.rs index 4a20507..43226b3 100644 --- a/src/commands/clone.rs +++ b/src/commands/clone.rs @@ -250,3 +250,89 @@ pub fn execute(args: CloneArgs) -> Result<()> { println!("Successfully cloned into {}", target_path.display()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_extract_repo_name() { + assert_eq!(extract_repo_name("https://github.com/user/my-repo.git"), "my-repo"); + assert_eq!(extract_repo_name("git@github.com:user/another-repo.git"), "another-repo"); + assert_eq!(extract_repo_name("https://example.com/simple"), "simple"); + assert_eq!(extract_repo_name("repo.git"), "repo"); + assert_eq!(extract_repo_name("https://example.com/"), "example.com"); + assert_eq!(extract_repo_name("///"), "repository"); + assert_eq!(extract_repo_name(""), "repository"); + } + + #[test] + fn test_strip_ansi() { + let text = "\x1b[31mError Message\x1b[0m"; + assert_eq!(strip_ansi(text), "Error Message"); + + let cr_text = "Progress: 50%\rProgress: 100%"; + assert_eq!(strip_ansi(cr_text), "Progress: 50%\nProgress: 100%"); + } + + #[test] + fn test_clone_repository_and_streamed_local() { + let temp = TempDir::new().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir_all(&source).unwrap(); + + let status = + Command::new("git").args(["init", &source.to_string_lossy()]).output().unwrap(); + assert!(status.status.success()); + + let _ = Command::new("git") + .args(["-C", &source.to_string_lossy(), "config", "user.name", "Test"]) + .output(); + let _ = Command::new("git") + .args(["-C", &source.to_string_lossy(), "config", "user.email", "test@test.com"]) + .output(); + std::fs::write(source.join("file.txt"), "hello").unwrap(); + let _ = Command::new("git").args(["-C", &source.to_string_lossy(), "add", "."]).output(); + let _ = Command::new("git") + .args(["-C", &source.to_string_lossy(), "commit", "-m", "initial"]) + .output(); + + let target_root = temp.path().join("roots"); + + // Test non-streamed clone + let cloned = + clone_repository(&source.to_string_lossy(), &target_root, Some("custom_name"), false) + .unwrap(); + assert!(cloned.exists()); + assert_eq!(cloned.file_name().unwrap(), "custom_name"); + + // Test streamed clone + let channels = clone_repository_streamed( + source.to_string_lossy().to_string(), + target_root.clone(), + Some("streamed_name".to_string()), + ); + + let res = channels.done_rx.recv().unwrap(); + assert!(res.is_ok()); + let streamed_path = res.unwrap(); + assert!(streamed_path.exists()); + assert_eq!(streamed_path.file_name().unwrap(), "streamed_name"); + } + + #[test] + fn test_clone_repository_streamed_failure() { + let temp = TempDir::new().unwrap(); + let target_root = temp.path().join("roots"); + + let channels = clone_repository_streamed( + "https://invalid-url-that-does-not-exist-12345.com/repo.git".to_string(), + target_root, + None, + ); + + let res = channels.done_rx.recv().unwrap(); + assert!(res.is_err()); + } +} diff --git a/tests/tui_clone_modal.rs b/tests/tui_clone_modal.rs index 15d4870..dc4b3e9 100644 --- a/tests/tui_clone_modal.rs +++ b/tests/tui_clone_modal.rs @@ -123,3 +123,111 @@ fn clone_form_closes_automatically_on_successful_clone() { assert!(!state.is_clone_form_active()); assert_eq!(state.input_mode, InputMode::Normal); } + +#[test] +#[serial] +fn clone_form_cursor_and_character_editing() { + let mut state = AppState::new(); + state.open_clone_modal(); + + // URL field (0) + state.clone_push_char('a'); + state.clone_push_char('b'); + state.clone_push_char('c'); + assert_eq!(state.clone_url, "abc"); + assert_eq!(state.clone_url_cursor, 3); + + state.clone_move_left(); + assert_eq!(state.clone_url_cursor, 2); + + state.clone_delete_char(); // deletes 'c' + assert_eq!(state.clone_url, "ab"); + + state.clone_pop_char(); // deletes 'b' + assert_eq!(state.clone_url, "a"); + assert_eq!(state.clone_url_cursor, 1); + + state.clone_move_right(); + assert_eq!(state.clone_url_cursor, 1); + + // Switch to Root selection field (1) + state.cycle_clone_field(); + state.project_roots = + vec![std::path::PathBuf::from("/root1"), std::path::PathBuf::from("/root2")]; + state.clone_move_right(); + assert_eq!(state.clone_root_index, 1); + state.clone_move_left(); + assert_eq!(state.clone_root_index, 0); + + // Switch to Name field (2) + state.cycle_clone_field(); + state.clone_push_char('x'); + state.clone_push_char('y'); + assert_eq!(state.clone_name, "xy"); + assert_eq!(state.clone_name_cursor, 2); + + state.clone_move_left(); + state.clone_delete_char(); + assert_eq!(state.clone_name, "x"); + state.clone_pop_char(); + assert_eq!(state.clone_name, ""); +} + +#[test] +#[serial] +fn clone_form_submit_no_roots_fails() { + let mut state = AppState::new(); + state.open_clone_modal(); + state.project_roots.clear(); + state.clone_url = "https://github.com/user/repo.git".to_string(); + + let res = state.submit_clone(); + assert!(res.is_err()); + assert_eq!(res.unwrap_err(), "No project roots configured"); +} + +#[test] +#[serial] +fn clone_form_failure_keeps_form_open_with_error() { + use dev_cli::commands::clone::CloneChannels; + use std::sync::mpsc; + + let mut state = AppState::new(); + state.open_clone_modal(); + assert!(state.is_clone_form_active()); + + let (_log_tx, log_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + state.clone_task = Some(CloneChannels { log_rx, done_rx }); + + done_tx.send(Err("Authentication failed".to_string())).unwrap(); + + state.tick_clone_task(); + + assert!(state.is_clone_form_active()); + assert!(state.clone_status_message.is_some()); + assert!(state.clone_status_message.unwrap().contains("Authentication failed")); +} + +#[test] +#[serial] +fn clone_task_max_log_lines_cap() { + use dev_cli::commands::clone::CloneChannels; + use std::sync::mpsc; + + let mut state = AppState::new(); + state.open_clone_modal(); + + let (log_tx, log_rx) = mpsc::channel(); + let (_done_tx, done_rx) = mpsc::channel(); + state.clone_task = Some(CloneChannels { log_rx, done_rx }); + + for i in 0..250 { + log_tx.send(format!("Log line {i}")).unwrap(); + } + + state.tick_clone_task(); + + assert_eq!(state.clone_log_lines.len(), 200); + assert_eq!(state.clone_log_lines.back().unwrap(), "Log line 249"); +}