port upstream/remote modal to fetch functioanlity
- SHA
478506124afcfc84492c1f1398e46dab8ea8d018- Parents
-
38ae8b2 - Tree
ba83fd1
4785061
478506124afcfc84492c1f1398e46dab8ea8d01838ae8b2
ba83fd1| Status | File | + | - |
|---|---|---|---|
| M |
src/app.rs
|
61 | 5 |
| M |
src/git.rs
|
23 | 0 |
| M |
src/main.rs
|
59 | 0 |
| M |
src/types.rs
|
10 | 0 |
| M |
src/ui.rs
|
91 | 1 |
src/app.rsmodified@@ -370,12 +370,68 @@ impl App { | ||
| 370 | 370 | Ok(()) |
| 371 | 371 | } |
| 372 | 372 | |
| 373 | - /// Fetch from remote | |
| 373 | + /// Fetch from remote - shows remote selector if multiple remotes | |
| 374 | 374 | pub fn fetch(&mut self) -> Result<()> { |
| 375 | - self.set_status("Fetching...".to_string()); | |
| 376 | - self.repo.fetch()?; | |
| 377 | - self.set_status("Fetch complete".to_string()); | |
| 378 | - self.refresh_files() | |
| 375 | + let remotes = self.repo.get_remotes(); | |
| 376 | + if remotes.len() <= 1 { | |
| 377 | + // Single or no remote - just fetch | |
| 378 | + self.set_status("Fetching...".to_string()); | |
| 379 | + self.repo.fetch()?; | |
| 380 | + self.set_status("Fetch complete".to_string()); | |
| 381 | + self.refresh_files() | |
| 382 | + } else { | |
| 383 | + // Multiple remotes - show selector | |
| 384 | + self.input_mode = InputMode::Fetch { | |
| 385 | + remotes, | |
| 386 | + selected: 0, | |
| 387 | + status: crate::types::FetchStatus::SelectRemote, | |
| 388 | + }; | |
| 389 | + Ok(()) | |
| 390 | + } | |
| 391 | + } | |
| 392 | + | |
| 393 | + /// Execute fetch from selected remote | |
| 394 | + pub fn fetch_from_remote(&mut self, remote: &str) -> Result<()> { | |
| 395 | + if let InputMode::Fetch { status, .. } = &mut self.input_mode { | |
| 396 | + *status = crate::types::FetchStatus::Fetching; | |
| 397 | + } | |
| 398 | + | |
| 399 | + match self.repo.fetch_from_remote(remote) { | |
| 400 | + Ok(()) => { | |
| 401 | + if let InputMode::Fetch { status, .. } = &mut self.input_mode { | |
| 402 | + *status = crate::types::FetchStatus::Success; | |
| 403 | + } | |
| 404 | + self.set_status(format!("Fetched from {}", remote)); | |
| 405 | + self.refresh_files()?; | |
| 406 | + Ok(()) | |
| 407 | + } | |
| 408 | + Err(e) => { | |
| 409 | + let msg = e.to_string(); | |
| 410 | + if let InputMode::Fetch { status, .. } = &mut self.input_mode { | |
| 411 | + *status = crate::types::FetchStatus::Failed(msg); | |
| 412 | + } | |
| 413 | + Ok(()) | |
| 414 | + } | |
| 415 | + } | |
| 416 | + } | |
| 417 | + | |
| 418 | + /// Close fetch modal | |
| 419 | + pub fn close_fetch(&mut self) { | |
| 420 | + self.input_mode = InputMode::Navigation; | |
| 421 | + } | |
| 422 | + | |
| 423 | + /// Force show fetch modal (for testing) | |
| 424 | + pub fn show_fetch_modal(&mut self) { | |
| 425 | + let remotes = self.repo.get_remotes(); | |
| 426 | + if remotes.is_empty() { | |
| 427 | + self.set_status("No remotes configured".to_string()); | |
| 428 | + return; | |
| 429 | + } | |
| 430 | + self.input_mode = InputMode::Fetch { | |
| 431 | + remotes, | |
| 432 | + selected: 0, | |
| 433 | + status: crate::types::FetchStatus::SelectRemote, | |
| 434 | + }; | |
| 379 | 435 | } |
| 380 | 436 | |
| 381 | 437 | /// Pull from remote - shows remote selector if no upstream configured |
src/git.rsmodified@@ -343,6 +343,29 @@ impl GitRepo { | ||
| 343 | 343 | } |
| 344 | 344 | } |
| 345 | 345 | |
| 346 | + /// Fetch from a specific remote | |
| 347 | + pub fn fetch_from_remote(&self, remote: &str) -> Result<()> { | |
| 348 | + let output = Command::new("git") | |
| 349 | + .args(["fetch", remote]) | |
| 350 | + .stdout(std::process::Stdio::piped()) | |
| 351 | + .stderr(std::process::Stdio::piped()) | |
| 352 | + .output()?; | |
| 353 | + | |
| 354 | + if output.status.success() { | |
| 355 | + Ok(()) | |
| 356 | + } else { | |
| 357 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 358 | + let msg = if stderr.contains("Could not read from remote") { | |
| 359 | + format!("Cannot reach '{}' - check connection/auth", remote) | |
| 360 | + } else if stderr.contains("does not appear to be a git repository") { | |
| 361 | + format!("Remote '{}' not found", remote) | |
| 362 | + } else { | |
| 363 | + format!("Fetch from '{}' failed", remote) | |
| 364 | + }; | |
| 365 | + Err(FussrError::Git(git2::Error::from_str(&msg))) | |
| 366 | + } | |
| 367 | + } | |
| 368 | + | |
| 346 | 369 | /// Pull from remote (captures output to not corrupt TUI) |
| 347 | 370 | pub fn pull(&self) -> Result<()> { |
| 348 | 371 | let output = Command::new("git") |
src/main.rsmodified@@ -93,6 +93,7 @@ fn run_event_loop( | ||
| 93 | 93 | InputMode::Search { .. } => handle_search_key(app, key.code)?, |
| 94 | 94 | InputMode::Push { .. } => handle_push_key(app, key.code)?, |
| 95 | 95 | InputMode::Pull { .. } => handle_pull_key(app, key.code)?, |
| 96 | + InputMode::Fetch { .. } => handle_fetch_key(app, key.code)?, | |
| 96 | 97 | InputMode::Confirm { .. } => handle_confirm_key(app, key.code)?, |
| 97 | 98 | } |
| 98 | 99 | |
@@ -204,6 +205,10 @@ fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) | ||
| 204 | 205 | Err(e) => app.set_status(format!("Fetch failed: {}", e)), |
| 205 | 206 | } |
| 206 | 207 | } |
| 208 | + KeyCode::Char('F') if app.mode == AppMode::Git => { | |
| 209 | + // Debug: Force show fetch modal regardless of remote count | |
| 210 | + app.show_fetch_modal(); | |
| 211 | + } | |
| 207 | 212 | KeyCode::Char('l') if app.mode == AppMode::Git => { |
| 208 | 213 | match app.pull() { |
| 209 | 214 | Ok(()) => {} |
@@ -444,6 +449,60 @@ fn handle_pull_key(app: &mut App, code: KeyCode) -> Result<()> { | ||
| 444 | 449 | Ok(()) |
| 445 | 450 | } |
| 446 | 451 | |
| 452 | +/// Handle keys in fetch mode | |
| 453 | +fn handle_fetch_key(app: &mut App, code: KeyCode) -> Result<()> { | |
| 454 | + use crate::types::FetchStatus; | |
| 455 | + | |
| 456 | + let status = if let InputMode::Fetch { status, .. } = &app.input_mode { | |
| 457 | + status.clone() | |
| 458 | + } else { | |
| 459 | + return Ok(()); | |
| 460 | + }; | |
| 461 | + | |
| 462 | + match status { | |
| 463 | + FetchStatus::SelectRemote => { | |
| 464 | + match code { | |
| 465 | + KeyCode::Esc => app.close_fetch(), | |
| 466 | + KeyCode::Char('j') | KeyCode::Down => { | |
| 467 | + if let InputMode::Fetch { remotes, selected, .. } = &mut app.input_mode { | |
| 468 | + if *selected + 1 < remotes.len() { | |
| 469 | + *selected += 1; | |
| 470 | + } | |
| 471 | + } | |
| 472 | + } | |
| 473 | + KeyCode::Char('k') | KeyCode::Up => { | |
| 474 | + if let InputMode::Fetch { selected, .. } = &mut app.input_mode { | |
| 475 | + if *selected > 0 { | |
| 476 | + *selected -= 1; | |
| 477 | + } | |
| 478 | + } | |
| 479 | + } | |
| 480 | + KeyCode::Enter => { | |
| 481 | + let remote = if let InputMode::Fetch { remotes, selected, .. } = &app.input_mode { | |
| 482 | + remotes.get(*selected).cloned() | |
| 483 | + } else { | |
| 484 | + None | |
| 485 | + }; | |
| 486 | + | |
| 487 | + if let Some(remote) = remote { | |
| 488 | + app.fetch_from_remote(&remote)?; | |
| 489 | + } | |
| 490 | + } | |
| 491 | + _ => {} | |
| 492 | + } | |
| 493 | + } | |
| 494 | + FetchStatus::Fetching => { | |
| 495 | + // Don't respond to keys while fetching | |
| 496 | + } | |
| 497 | + FetchStatus::Success | FetchStatus::Failed(_) => { | |
| 498 | + // Any key closes the modal | |
| 499 | + app.close_fetch(); | |
| 500 | + } | |
| 501 | + } | |
| 502 | + | |
| 503 | + Ok(()) | |
| 504 | +} | |
| 505 | + | |
| 447 | 506 | /// Handle keys in search mode (legacy - not currently used) |
| 448 | 507 | fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> { |
| 449 | 508 | match code { |
src/types.rsmodified@@ -198,6 +198,15 @@ pub enum PullStatus { | ||
| 198 | 198 | Failed(String), |
| 199 | 199 | } |
| 200 | 200 | |
| 201 | +/// Status of fetch operation | |
| 202 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 203 | +pub enum FetchStatus { | |
| 204 | + SelectRemote, | |
| 205 | + Fetching, | |
| 206 | + Success, | |
| 207 | + Failed(String), | |
| 208 | +} | |
| 209 | + | |
| 201 | 210 | /// Input mode for special states |
| 202 | 211 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 203 | 212 | pub enum InputMode { |
@@ -207,6 +216,7 @@ pub enum InputMode { | ||
| 207 | 216 | Commit { buffer: String, cursor: usize, amend: bool, status: CommitStatus }, |
| 208 | 217 | Push { remotes: Vec<String>, selected: usize, status: PushStatus }, |
| 209 | 218 | Pull { remotes: Vec<String>, selected: usize, status: PullStatus }, |
| 219 | + Fetch { remotes: Vec<String>, selected: usize, status: FetchStatus }, | |
| 210 | 220 | Confirm { message: String, action: ConfirmAction }, |
| 211 | 221 | } |
| 212 | 222 | |
src/ui.rsmodified@@ -1,5 +1,5 @@ | ||
| 1 | 1 | use crate::app::App; |
| 2 | -use crate::types::{AppMode, CommitStatus, InputMode, PullStatus, PushStatus, SelectableItem}; | |
| 2 | +use crate::types::{AppMode, CommitStatus, FetchStatus, InputMode, PullStatus, PushStatus, SelectableItem}; | |
| 3 | 3 | use ratatui::{ |
| 4 | 4 | layout::{Constraint, Direction, Layout, Rect}, |
| 5 | 5 | style::{Color, Modifier, Style}, |
@@ -38,6 +38,11 @@ pub fn draw(frame: &mut Frame, app: &App) { | ||
| 38 | 38 | draw_pull_modal(frame, remotes, *selected, status, &app.branch_name); |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | + // Draw modal overlay if in fetch mode | |
| 42 | + if let InputMode::Fetch { remotes, selected, status } = &app.input_mode { | |
| 43 | + draw_fetch_modal(frame, remotes, *selected, status); | |
| 44 | + } | |
| 45 | + | |
| 41 | 46 | // Draw modal overlay if in confirm mode |
| 42 | 47 | if let InputMode::Confirm { message, .. } = &app.input_mode { |
| 43 | 48 | draw_confirm_modal(frame, message); |
@@ -296,6 +301,91 @@ fn draw_pull_modal(frame: &mut Frame, remotes: &[String], selected: usize, statu | ||
| 296 | 301 | frame.render_widget(widget, modal_area); |
| 297 | 302 | } |
| 298 | 303 | |
| 304 | +/// Draw fetch remote selection modal | |
| 305 | +fn draw_fetch_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &FetchStatus) { | |
| 306 | + let area = frame.area(); | |
| 307 | + | |
| 308 | + let modal_height = match status { | |
| 309 | + FetchStatus::SelectRemote => (remotes.len() + 4).min(12) as u16, | |
| 310 | + _ => 5, | |
| 311 | + }; | |
| 312 | + let modal_width = 50.min(area.width.saturating_sub(4)); | |
| 313 | + let x = (area.width.saturating_sub(modal_width)) / 2; | |
| 314 | + let y = (area.height.saturating_sub(modal_height)) / 2; | |
| 315 | + | |
| 316 | + let modal_area = Rect::new(x, y, modal_width, modal_height); | |
| 317 | + | |
| 318 | + frame.render_widget(Clear, modal_area); | |
| 319 | + | |
| 320 | + let (title, border_color) = match status { | |
| 321 | + FetchStatus::SelectRemote => (" Fetch - Select Remote ", Color::Cyan), | |
| 322 | + FetchStatus::Fetching => (" Fetching... ", Color::Yellow), | |
| 323 | + FetchStatus::Success => (" ✓ Fetched ", Color::Green), | |
| 324 | + FetchStatus::Failed(_) => (" ✗ Fetch Failed ", Color::Red), | |
| 325 | + }; | |
| 326 | + | |
| 327 | + let block = Block::default() | |
| 328 | + .title(title) | |
| 329 | + .borders(Borders::ALL) | |
| 330 | + .border_style(Style::default().fg(border_color)); | |
| 331 | + | |
| 332 | + let content: Vec<Line> = match status { | |
| 333 | + FetchStatus::SelectRemote => { | |
| 334 | + let mut lines = vec![ | |
| 335 | + Line::from(Span::styled( | |
| 336 | + " Select remote to fetch from:", | |
| 337 | + Style::default().fg(Color::White), | |
| 338 | + )), | |
| 339 | + Line::from(""), | |
| 340 | + ]; | |
| 341 | + for (i, remote) in remotes.iter().enumerate() { | |
| 342 | + let style = if i == selected { | |
| 343 | + Style::default().add_modifier(Modifier::REVERSED) | |
| 344 | + } else { | |
| 345 | + Style::default() | |
| 346 | + }; | |
| 347 | + lines.push(Line::from(Span::styled(format!(" {}", remote), style))); | |
| 348 | + } | |
| 349 | + lines.push(Line::from("")); | |
| 350 | + lines.push(Line::from(Span::styled( | |
| 351 | + " j/k:select Enter:fetch ESC:cancel", | |
| 352 | + Style::default().fg(Color::DarkGray), | |
| 353 | + ))); | |
| 354 | + lines | |
| 355 | + } | |
| 356 | + FetchStatus::Fetching => { | |
| 357 | + vec![ | |
| 358 | + Line::from(""), | |
| 359 | + Line::from(Span::styled( | |
| 360 | + " Fetching from remote...", | |
| 361 | + Style::default().fg(Color::Yellow), | |
| 362 | + )), | |
| 363 | + ] | |
| 364 | + } | |
| 365 | + FetchStatus::Success => { | |
| 366 | + vec![ | |
| 367 | + Line::from(""), | |
| 368 | + Line::from(Span::styled( | |
| 369 | + " ✓ Fetch complete!", | |
| 370 | + Style::default().fg(Color::Green), | |
| 371 | + )), | |
| 372 | + ] | |
| 373 | + } | |
| 374 | + FetchStatus::Failed(msg) => { | |
| 375 | + vec![ | |
| 376 | + Line::from(""), | |
| 377 | + Line::from(Span::styled( | |
| 378 | + format!(" ✗ {}", msg), | |
| 379 | + Style::default().fg(Color::Red), | |
| 380 | + )), | |
| 381 | + ] | |
| 382 | + } | |
| 383 | + }; | |
| 384 | + | |
| 385 | + let widget = Paragraph::new(content).block(block); | |
| 386 | + frame.render_widget(widget, modal_area); | |
| 387 | +} | |
| 388 | + | |
| 299 | 389 | /// Draw confirmation modal |
| 300 | 390 | fn draw_confirm_modal(frame: &mut Frame, message: &str) { |
| 301 | 391 | let area = frame.area(); |