tenseleyflow/fussr / 38ae8b2

Browse files

port upstream modal for pull

Authored by espadonne
SHA
38ae8b267065ca6f844c7450dc62e80c70263c01
Parents
3f145c3
Tree
9b88a95

5 changed files

StatusFile+-
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 {
378378
         self.refresh_files()
379379
     }
380380
 
381
-    /// Pull from remote
381
+    /// Pull from remote - shows remote selector if no upstream configured
382382
     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
+        };
387446
     }
388447
 
389448
     /// Push to remote - shows remote selector if no upstream configured
src/git.rsmodified
@@ -455,6 +455,41 @@ impl GitRepo {
455455
         }
456456
     }
457457
 
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
+
458493
     /// Get diff for a file
459494
     pub fn diff_file(&self, path: &Path, has_incoming: bool) -> Result<String> {
460495
         let path_str = path.to_string_lossy();
src/main.rsmodified
@@ -92,6 +92,7 @@ fn run_event_loop(
9292
                     InputMode::Commit { .. } => handle_commit_key(app, key.code)?,
9393
                     InputMode::Search { .. } => handle_search_key(app, key.code)?,
9494
                     InputMode::Push { .. } => handle_push_key(app, key.code)?,
95
+                    InputMode::Pull { .. } => handle_pull_key(app, key.code)?,
9596
                     InputMode::Confirm { .. } => handle_confirm_key(app, key.code)?,
9697
                 }
9798
 
@@ -209,6 +210,10 @@ fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers)
209210
                 Err(e) => app.set_status(format!("Pull failed: {}", e)),
210211
             }
211212
         }
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
+        }
212217
         KeyCode::Char('p') if app.mode == AppMode::Git => {
213218
             match app.push() {
214219
                 Ok(()) => {}
@@ -385,6 +390,60 @@ fn handle_push_key(app: &mut App, code: KeyCode) -> Result<()> {
385390
     Ok(())
386391
 }
387392
 
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
+
388447
 /// Handle keys in search mode (legacy - not currently used)
389448
 fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> {
390449
     match code {
src/types.rsmodified
@@ -189,6 +189,15 @@ pub enum PushStatus {
189189
     Failed(String),
190190
 }
191191
 
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
+
192201
 /// Input mode for special states
193202
 #[derive(Debug, Clone, PartialEq, Eq)]
194203
 pub enum InputMode {
@@ -197,6 +206,7 @@ pub enum InputMode {
197206
     Search { buffer: String },
198207
     Commit { buffer: String, cursor: usize, amend: bool, status: CommitStatus },
199208
     Push { remotes: Vec<String>, selected: usize, status: PushStatus },
209
+    Pull { remotes: Vec<String>, selected: usize, status: PullStatus },
200210
     Confirm { message: String, action: ConfirmAction },
201211
 }
202212
 
src/ui.rsmodified
@@ -1,5 +1,5 @@
11
 use crate::app::App;
2
-use crate::types::{AppMode, CommitStatus, InputMode, PushStatus, SelectableItem};
2
+use crate::types::{AppMode, CommitStatus, InputMode, PullStatus, PushStatus, SelectableItem};
33
 use ratatui::{
44
     layout::{Constraint, Direction, Layout, Rect},
55
     style::{Color, Modifier, Style},
@@ -33,6 +33,11 @@ pub fn draw(frame: &mut Frame, app: &App) {
3333
         draw_push_modal(frame, remotes, *selected, status, &app.branch_name);
3434
     }
3535
 
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
+
3641
     // Draw modal overlay if in confirm mode
3742
     if let InputMode::Confirm { message, .. } = &app.input_mode {
3843
         draw_confirm_modal(frame, message);
@@ -206,6 +211,91 @@ fn draw_push_modal(frame: &mut Frame, remotes: &[String], selected: usize, statu
206211
     frame.render_widget(widget, modal_area);
207212
 }
208213
 
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
+
209299
 /// Draw confirmation modal
210300
 fn draw_confirm_modal(frame: &mut Frame, message: &str) {
211301
     let area = frame.area();