diff --git a/src/commands/clone.rs b/src/commands/clone.rs index 6551be5..43226b3 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,94 @@ pub fn execute(args: CloneArgs) -> Result<()> { None => cfg.projects_root[0].clone(), }; - if !target_root.exists() { - std::fs::create_dir_all(&target_root)?; + 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(()) +} + +#[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"); } - // 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() - } - }; + #[test] + fn test_strip_ansi() { + let text = "\x1b[31mError Message\x1b[0m"; + assert_eq!(strip_ansi(text), "Error Message"); - let target_path = target_root.join(repo_name); + let cr_text = "Progress: 50%\rProgress: 100%"; + assert_eq!(strip_ansi(cr_text), "Progress: 50%\nProgress: 100%"); + } - println!("Cloning '{}' into '{}'...", args.url, target_path.display()); + #[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 mut cmd = Command::new("git"); - cmd.arg("clone"); - cmd.arg(&args.url); - cmd.arg(&target_path); + let status = + Command::new("git").args(["init", &source.to_string_lossy()]).output().unwrap(); + assert!(status.status.success()); - // 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 _ = 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 status = cmd.status().map_err(|e| anyhow!("Failed to spawn git process: {}", e))?; + let target_root = temp.path().join("roots"); - if status.success() { - println!("Successfully cloned into {}", target_path.display()); - Ok(()) - } else { - Err(anyhow!("git clone failed with exit code: {}", status)) + // 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/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..dc4b3e9 --- /dev/null +++ b/tests/tui_clone_modal.rs @@ -0,0 +1,233 @@ +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); +} + +#[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"); +}