port upstream modal for pull
- SHA
38ae8b267065ca6f844c7450dc62e80c70263c01- Parents
-
3f145c3 - Tree
9b88a95
38ae8b2
38ae8b267065ca6f844c7450dc62e80c70263c013f145c3
9b88a95| Status | File | + | - |
|---|---|---|---|
| M |
src/app.rs
|
64 | 5 |
| M |
src/git.rs
|
35 | 0 |
| M |
src/main.rs
|
59 | 0 |
| M |
src/types.rs
|
10 | 0 |
| M |
src/ui.rs
|
91 | 1 |
src/app.rsmodified@@ -378,12 +378,71 @@ impl App { | ||
| 378 | 378 | self.refresh_files() |
| 379 | 379 | } |
| 380 | 380 | |
| 381 | - /// Pull from remote | |
| 381 | + /// Pull from remote - shows remote selector if no upstream configured | |
| 382 | 382 | pub fn pull(&mut self) -> Result<()> { |
| 383 | - self.set_status("Pulling...".to_string()); | |
| 384 | - self.repo.pull()?; | |
| 385 | - self.set_status("Pull complete".to_string()); | |
| 386 | - self.refresh_files() | |
| 383 | + if self.repo.has_upstream() { | |
| 384 | + self.set_status("Pulling...".to_string()); | |
| 385 | + self.repo.pull()?; | |
| 386 | + self.set_status("Pull complete".to_string()); | |
| 387 | + self.refresh_files() | |
| 388 | + } else { | |
| 389 | + let remotes = self.repo.get_remotes(); | |
| 390 | + if remotes.is_empty() { | |
| 391 | + return Err(crate::error::FussrError::Git( | |
| 392 | + git2::Error::from_str("No remotes configured. Add with: git remote add origin <url>") | |
| 393 | + )); | |
| 394 | + } | |
| 395 | + self.input_mode = InputMode::Pull { | |
| 396 | + remotes, | |
| 397 | + selected: 0, | |
| 398 | + status: crate::types::PullStatus::SelectRemote, | |
| 399 | + }; | |
| 400 | + Ok(()) | |
| 401 | + } | |
| 402 | + } | |
| 403 | + | |
| 404 | + /// Execute pull with selected remote | |
| 405 | + pub fn pull_from_remote(&mut self, remote: &str) -> Result<()> { | |
| 406 | + if let InputMode::Pull { status, .. } = &mut self.input_mode { | |
| 407 | + *status = crate::types::PullStatus::Pulling; | |
| 408 | + } | |
| 409 | + | |
| 410 | + match self.repo.pull_from_remote(remote) { | |
| 411 | + Ok(()) => { | |
| 412 | + if let InputMode::Pull { status, .. } = &mut self.input_mode { | |
| 413 | + *status = crate::types::PullStatus::Success; | |
| 414 | + } | |
| 415 | + self.set_status(format!("Pulled from {}/{}", remote, self.branch_name)); | |
| 416 | + self.refresh_files()?; | |
| 417 | + Ok(()) | |
| 418 | + } | |
| 419 | + Err(e) => { | |
| 420 | + let msg = e.to_string(); | |
| 421 | + if let InputMode::Pull { status, .. } = &mut self.input_mode { | |
| 422 | + *status = crate::types::PullStatus::Failed(msg); | |
| 423 | + } | |
| 424 | + Ok(()) | |
| 425 | + } | |
| 426 | + } | |
| 427 | + } | |
| 428 | + | |
| 429 | + /// Close pull modal | |
| 430 | + pub fn close_pull(&mut self) { | |
| 431 | + self.input_mode = InputMode::Navigation; | |
| 432 | + } | |
| 433 | + | |
| 434 | + /// Force show pull modal (for testing) | |
| 435 | + pub fn show_pull_modal(&mut self) { | |
| 436 | + let remotes = self.repo.get_remotes(); | |
| 437 | + if remotes.is_empty() { | |
| 438 | + self.set_status("No remotes configured".to_string()); | |
| 439 | + return; | |
| 440 | + } | |
| 441 | + self.input_mode = InputMode::Pull { | |
| 442 | + remotes, | |
| 443 | + selected: 0, | |
| 444 | + status: crate::types::PullStatus::SelectRemote, | |
| 445 | + }; | |
| 387 | 446 | } |
| 388 | 447 | |
| 389 | 448 | /// Push to remote - shows remote selector if no upstream configured |
src/git.rsmodified@@ -455,6 +455,41 @@ impl GitRepo { | ||
| 455 | 455 | } |
| 456 | 456 | } |
| 457 | 457 | |
| 458 | + /// Pull from a specific remote/branch (and set upstream) | |
| 459 | + pub fn pull_from_remote(&self, remote: &str) -> Result<()> { | |
| 460 | + let branch = self.branch_name(); | |
| 461 | + | |
| 462 | + // First set upstream tracking | |
| 463 | + let _ = Command::new("git") | |
| 464 | + .args(["branch", "--set-upstream-to", &format!("{}/{}", remote, branch)]) | |
| 465 | + .stdout(std::process::Stdio::piped()) | |
| 466 | + .stderr(std::process::Stdio::piped()) | |
| 467 | + .output(); | |
| 468 | + | |
| 469 | + // Then pull | |
| 470 | + let output = Command::new("git") | |
| 471 | + .args(["pull", remote, &branch]) | |
| 472 | + .stdout(std::process::Stdio::piped()) | |
| 473 | + .stderr(std::process::Stdio::piped()) | |
| 474 | + .output()?; | |
| 475 | + | |
| 476 | + if output.status.success() { | |
| 477 | + Ok(()) | |
| 478 | + } else { | |
| 479 | + let stderr = String::from_utf8_lossy(&output.stderr); | |
| 480 | + let msg = if stderr.contains("Could not read from remote") { | |
| 481 | + format!("Cannot reach '{}' - check connection/auth", remote) | |
| 482 | + } else if stderr.contains("CONFLICT") || stderr.contains("Merge conflict") { | |
| 483 | + "Pull has conflicts - resolve manually".to_string() | |
| 484 | + } else if stderr.contains("does not appear to be a git repository") { | |
| 485 | + format!("Remote '{}' not found", remote) | |
| 486 | + } else { | |
| 487 | + format!("Pull from '{}' failed", remote) | |
| 488 | + }; | |
| 489 | + Err(FussrError::Git(git2::Error::from_str(&msg))) | |
| 490 | + } | |
| 491 | + } | |
| 492 | + | |
| 458 | 493 | /// Get diff for a file |
| 459 | 494 | pub fn diff_file(&self, path: &Path, has_incoming: bool) -> Result<String> { |
| 460 | 495 | let path_str = path.to_string_lossy(); |
src/main.rsmodified@@ -92,6 +92,7 @@ fn run_event_loop( | ||
| 92 | 92 | InputMode::Commit { .. } => handle_commit_key(app, key.code)?, |
| 93 | 93 | InputMode::Search { .. } => handle_search_key(app, key.code)?, |
| 94 | 94 | InputMode::Push { .. } => handle_push_key(app, key.code)?, |
| 95 | + InputMode::Pull { .. } => handle_pull_key(app, key.code)?, | |
| 95 | 96 | InputMode::Confirm { .. } => handle_confirm_key(app, key.code)?, |
| 96 | 97 | } |
| 97 | 98 | |
@@ -209,6 +210,10 @@ fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) | ||
| 209 | 210 | Err(e) => app.set_status(format!("Pull failed: {}", e)), |
| 210 | 211 | } |
| 211 | 212 | } |
| 213 | + KeyCode::Char('L') if app.mode == AppMode::Git => { | |
| 214 | + // Debug: Force show pull modal regardless of upstream status | |
| 215 | + app.show_pull_modal(); | |
| 216 | + } | |
| 212 | 217 | KeyCode::Char('p') if app.mode == AppMode::Git => { |
| 213 | 218 | match app.push() { |
| 214 | 219 | Ok(()) => {} |
@@ -385,6 +390,60 @@ fn handle_push_key(app: &mut App, code: KeyCode) -> Result<()> { | ||
| 385 | 390 | Ok(()) |
| 386 | 391 | } |
| 387 | 392 | |
| 393 | +/// Handle keys in pull mode | |
| 394 | +fn handle_pull_key(app: &mut App, code: KeyCode) -> Result<()> { | |
| 395 | + use crate::types::PullStatus; | |
| 396 | + | |
| 397 | + let status = if let InputMode::Pull { status, .. } = &app.input_mode { | |
| 398 | + status.clone() | |
| 399 | + } else { | |
| 400 | + return Ok(()); | |
| 401 | + }; | |
| 402 | + | |
| 403 | + match status { | |
| 404 | + PullStatus::SelectRemote => { | |
| 405 | + match code { | |
| 406 | + KeyCode::Esc => app.close_pull(), | |
| 407 | + KeyCode::Char('j') | KeyCode::Down => { | |
| 408 | + if let InputMode::Pull { remotes, selected, .. } = &mut app.input_mode { | |
| 409 | + if *selected + 1 < remotes.len() { | |
| 410 | + *selected += 1; | |
| 411 | + } | |
| 412 | + } | |
| 413 | + } | |
| 414 | + KeyCode::Char('k') | KeyCode::Up => { | |
| 415 | + if let InputMode::Pull { selected, .. } = &mut app.input_mode { | |
| 416 | + if *selected > 0 { | |
| 417 | + *selected -= 1; | |
| 418 | + } | |
| 419 | + } | |
| 420 | + } | |
| 421 | + KeyCode::Enter => { | |
| 422 | + let remote = if let InputMode::Pull { remotes, selected, .. } = &app.input_mode { | |
| 423 | + remotes.get(*selected).cloned() | |
| 424 | + } else { | |
| 425 | + None | |
| 426 | + }; | |
| 427 | + | |
| 428 | + if let Some(remote) = remote { | |
| 429 | + app.pull_from_remote(&remote)?; | |
| 430 | + } | |
| 431 | + } | |
| 432 | + _ => {} | |
| 433 | + } | |
| 434 | + } | |
| 435 | + PullStatus::Pulling => { | |
| 436 | + // Don't respond to keys while pulling | |
| 437 | + } | |
| 438 | + PullStatus::Success | PullStatus::Failed(_) => { | |
| 439 | + // Any key closes the modal | |
| 440 | + app.close_pull(); | |
| 441 | + } | |
| 442 | + } | |
| 443 | + | |
| 444 | + Ok(()) | |
| 445 | +} | |
| 446 | + | |
| 388 | 447 | /// Handle keys in search mode (legacy - not currently used) |
| 389 | 448 | fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> { |
| 390 | 449 | match code { |
src/types.rsmodified@@ -189,6 +189,15 @@ pub enum PushStatus { | ||
| 189 | 189 | Failed(String), |
| 190 | 190 | } |
| 191 | 191 | |
| 192 | +/// Status of pull operation | |
| 193 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 194 | +pub enum PullStatus { | |
| 195 | + SelectRemote, | |
| 196 | + Pulling, | |
| 197 | + Success, | |
| 198 | + Failed(String), | |
| 199 | +} | |
| 200 | + | |
| 192 | 201 | /// Input mode for special states |
| 193 | 202 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 194 | 203 | pub enum InputMode { |
@@ -197,6 +206,7 @@ pub enum InputMode { | ||
| 197 | 206 | Search { buffer: String }, |
| 198 | 207 | Commit { buffer: String, cursor: usize, amend: bool, status: CommitStatus }, |
| 199 | 208 | Push { remotes: Vec<String>, selected: usize, status: PushStatus }, |
| 209 | + Pull { remotes: Vec<String>, selected: usize, status: PullStatus }, | |
| 200 | 210 | Confirm { message: String, action: ConfirmAction }, |
| 201 | 211 | } |
| 202 | 212 | |
src/ui.rsmodified@@ -1,5 +1,5 @@ | ||
| 1 | 1 | use crate::app::App; |
| 2 | -use crate::types::{AppMode, CommitStatus, InputMode, PushStatus, SelectableItem}; | |
| 2 | +use crate::types::{AppMode, CommitStatus, InputMode, PullStatus, PushStatus, SelectableItem}; | |
| 3 | 3 | use ratatui::{ |
| 4 | 4 | layout::{Constraint, Direction, Layout, Rect}, |
| 5 | 5 | style::{Color, Modifier, Style}, |
@@ -33,6 +33,11 @@ pub fn draw(frame: &mut Frame, app: &App) { | ||
| 33 | 33 | draw_push_modal(frame, remotes, *selected, status, &app.branch_name); |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | + // Draw modal overlay if in pull mode | |
| 37 | + if let InputMode::Pull { remotes, selected, status } = &app.input_mode { | |
| 38 | + draw_pull_modal(frame, remotes, *selected, status, &app.branch_name); | |
| 39 | + } | |
| 40 | + | |
| 36 | 41 | // Draw modal overlay if in confirm mode |
| 37 | 42 | if let InputMode::Confirm { message, .. } = &app.input_mode { |
| 38 | 43 | draw_confirm_modal(frame, message); |
@@ -206,6 +211,91 @@ fn draw_push_modal(frame: &mut Frame, remotes: &[String], selected: usize, statu | ||
| 206 | 211 | frame.render_widget(widget, modal_area); |
| 207 | 212 | } |
| 208 | 213 | |
| 214 | +/// Draw pull remote selection modal | |
| 215 | +fn draw_pull_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &PullStatus, branch: &str) { | |
| 216 | + let area = frame.area(); | |
| 217 | + | |
| 218 | + let modal_height = match status { | |
| 219 | + PullStatus::SelectRemote => (remotes.len() + 4).min(12) as u16, | |
| 220 | + _ => 5, | |
| 221 | + }; | |
| 222 | + let modal_width = 50.min(area.width.saturating_sub(4)); | |
| 223 | + let x = (area.width.saturating_sub(modal_width)) / 2; | |
| 224 | + let y = (area.height.saturating_sub(modal_height)) / 2; | |
| 225 | + | |
| 226 | + let modal_area = Rect::new(x, y, modal_width, modal_height); | |
| 227 | + | |
| 228 | + frame.render_widget(Clear, modal_area); | |
| 229 | + | |
| 230 | + let (title, border_color) = match status { | |
| 231 | + PullStatus::SelectRemote => (" Pull - Select Remote ", Color::Cyan), | |
| 232 | + PullStatus::Pulling => (" Pulling... ", Color::Yellow), | |
| 233 | + PullStatus::Success => (" ✓ Pulled ", Color::Green), | |
| 234 | + PullStatus::Failed(_) => (" ✗ Pull Failed ", Color::Red), | |
| 235 | + }; | |
| 236 | + | |
| 237 | + let block = Block::default() | |
| 238 | + .title(title) | |
| 239 | + .borders(Borders::ALL) | |
| 240 | + .border_style(Style::default().fg(border_color)); | |
| 241 | + | |
| 242 | + let content: Vec<Line> = match status { | |
| 243 | + PullStatus::SelectRemote => { | |
| 244 | + let mut lines = vec![ | |
| 245 | + Line::from(Span::styled( | |
| 246 | + format!(" Set upstream for '{}':", branch), | |
| 247 | + Style::default().fg(Color::White), | |
| 248 | + )), | |
| 249 | + Line::from(""), | |
| 250 | + ]; | |
| 251 | + for (i, remote) in remotes.iter().enumerate() { | |
| 252 | + let style = if i == selected { | |
| 253 | + Style::default().add_modifier(Modifier::REVERSED) | |
| 254 | + } else { | |
| 255 | + Style::default() | |
| 256 | + }; | |
| 257 | + lines.push(Line::from(Span::styled(format!(" {}", remote), style))); | |
| 258 | + } | |
| 259 | + lines.push(Line::from("")); | |
| 260 | + lines.push(Line::from(Span::styled( | |
| 261 | + " j/k:select Enter:pull ESC:cancel", | |
| 262 | + Style::default().fg(Color::DarkGray), | |
| 263 | + ))); | |
| 264 | + lines | |
| 265 | + } | |
| 266 | + PullStatus::Pulling => { | |
| 267 | + vec![ | |
| 268 | + Line::from(""), | |
| 269 | + Line::from(Span::styled( | |
| 270 | + " Pulling changes...", | |
| 271 | + Style::default().fg(Color::Yellow), | |
| 272 | + )), | |
| 273 | + ] | |
| 274 | + } | |
| 275 | + PullStatus::Success => { | |
| 276 | + vec![ | |
| 277 | + Line::from(""), | |
| 278 | + Line::from(Span::styled( | |
| 279 | + " ✓ Changes pulled successfully!", | |
| 280 | + Style::default().fg(Color::Green), | |
| 281 | + )), | |
| 282 | + ] | |
| 283 | + } | |
| 284 | + PullStatus::Failed(msg) => { | |
| 285 | + vec![ | |
| 286 | + Line::from(""), | |
| 287 | + Line::from(Span::styled( | |
| 288 | + format!(" ✗ {}", msg), | |
| 289 | + Style::default().fg(Color::Red), | |
| 290 | + )), | |
| 291 | + ] | |
| 292 | + } | |
| 293 | + }; | |
| 294 | + | |
| 295 | + let widget = Paragraph::new(content).block(block); | |
| 296 | + frame.render_widget(widget, modal_area); | |
| 297 | +} | |
| 298 | + | |
| 209 | 299 | /// Draw confirmation modal |
| 210 | 300 | fn draw_confirm_modal(frame: &mut Frame, message: &str) { |
| 211 | 301 | let area = frame.area(); |