tenseleyflow/fussr / 75b8f70

Browse files

push modal for remote choosing

Authored by espadonne
SHA
75b8f708b467bdf68fe33ef085e0650a7238353c
Parents
2da7494
Tree
0923555

5 changed files

StatusFile+-
M src/app.rs 53 5
M src/git.rs 93 3
M src/main.rs 57 0
M src/types.rs 10 0
M src/ui.rs 95 1
src/app.rsmodified
@@ -337,12 +337,60 @@ impl App {
337337
         self.refresh_files()
338338
     }
339339
 
340
-    /// Push to remote
340
+    /// Push to remote - shows remote selector if no upstream configured
341341
     pub fn push(&mut self) -> Result<()> {
342
-        self.set_status("Pushing...".to_string());
343
-        self.repo.push()?;
344
-        self.set_status("Push complete".to_string());
345
-        Ok(())
342
+        // Check if upstream is configured
343
+        if self.repo.has_upstream() {
344
+            // Normal push
345
+            self.set_status("Pushing...".to_string());
346
+            self.repo.push()?;
347
+            self.set_status("Push complete".to_string());
348
+            Ok(())
349
+        } else {
350
+            // No upstream - show remote selector
351
+            let remotes = self.repo.get_remotes();
352
+            if remotes.is_empty() {
353
+                return Err(crate::error::FussrError::Git(
354
+                    git2::Error::from_str("No remotes configured. Add with: git remote add origin <url>")
355
+                ));
356
+            }
357
+            self.input_mode = InputMode::Push {
358
+                remotes,
359
+                selected: 0,
360
+                status: crate::types::PushStatus::SelectRemote,
361
+            };
362
+            Ok(())
363
+        }
364
+    }
365
+
366
+    /// Execute push with selected remote
367
+    pub fn push_to_remote(&mut self, remote: &str) -> Result<()> {
368
+        // Update status to pushing
369
+        if let InputMode::Push { status, .. } = &mut self.input_mode {
370
+            *status = crate::types::PushStatus::Pushing;
371
+        }
372
+
373
+        match self.repo.push_with_upstream(remote) {
374
+            Ok(()) => {
375
+                if let InputMode::Push { status, .. } = &mut self.input_mode {
376
+                    *status = crate::types::PushStatus::Success;
377
+                }
378
+                self.set_status(format!("Pushed to {}/{}", remote, self.branch_name));
379
+                Ok(())
380
+            }
381
+            Err(e) => {
382
+                let msg = e.to_string();
383
+                if let InputMode::Push { status, .. } = &mut self.input_mode {
384
+                    *status = crate::types::PushStatus::Failed(msg);
385
+                }
386
+                Ok(()) // Don't propagate - show in modal
387
+            }
388
+        }
389
+    }
390
+
391
+    /// Close push modal
392
+    pub fn close_push(&mut self) {
393
+        self.input_mode = InputMode::Navigation;
346394
     }
347395
 
348396
     /// Create a commit
src/git.rsmodified
@@ -331,7 +331,15 @@ impl GitRepo {
331331
         if output.status.success() {
332332
             Ok(())
333333
         } else {
334
-            Err(FussrError::Git(git2::Error::from_str("Failed to fetch")))
334
+            let stderr = String::from_utf8_lossy(&output.stderr);
335
+            let msg = if stderr.contains("Could not read from remote") {
336
+                "Cannot reach remote - check connection/auth".to_string()
337
+            } else if stderr.contains("does not appear to be a git repository") {
338
+                "Remote not found. Add with: git remote add origin <url>".to_string()
339
+            } else {
340
+                "Fetch failed".to_string()
341
+            };
342
+            Err(FussrError::Git(git2::Error::from_str(&msg)))
335343
         }
336344
     }
337345
 
@@ -346,7 +354,19 @@ impl GitRepo {
346354
         if output.status.success() {
347355
             Ok(())
348356
         } else {
349
-            Err(FussrError::Git(git2::Error::from_str("Failed to pull")))
357
+            let stderr = String::from_utf8_lossy(&output.stderr);
358
+            let msg = if stderr.contains("no tracking information") || stderr.contains("no upstream") {
359
+                format!("No upstream set. Run: git branch --set-upstream-to=origin/{}", self.branch_name())
360
+            } else if stderr.contains("Could not read from remote") {
361
+                "Cannot reach remote - check connection/auth".to_string()
362
+            } else if stderr.contains("CONFLICT") || stderr.contains("Merge conflict") {
363
+                "Pull has conflicts - resolve manually".to_string()
364
+            } else if stderr.contains("not a git repository") {
365
+                "Not in a git repository".to_string()
366
+            } else {
367
+                "Pull failed".to_string()
368
+            };
369
+            Err(FussrError::Git(git2::Error::from_str(&msg)))
350370
         }
351371
     }
352372
 
@@ -361,7 +381,77 @@ impl GitRepo {
361381
         if output.status.success() {
362382
             Ok(())
363383
         } else {
364
-            Err(FussrError::Git(git2::Error::from_str("Failed to push")))
384
+            let stderr = String::from_utf8_lossy(&output.stderr);
385
+            // Detect common push errors and provide helpful messages
386
+            let msg = if stderr.contains("no upstream branch") || stderr.contains("has no upstream") {
387
+                format!("No upstream set. Run: git push -u origin {}", self.branch_name())
388
+            } else if stderr.contains("does not appear to be a git repository") {
389
+                "Remote not found. Check your remote config".to_string()
390
+            } else if stderr.contains("rejected") {
391
+                "Push rejected - pull first or force push".to_string()
392
+            } else if stderr.contains("Could not read from remote") {
393
+                "Cannot reach remote - check connection/auth".to_string()
394
+            } else {
395
+                "Push failed".to_string()
396
+            };
397
+            Err(FussrError::Git(git2::Error::from_str(&msg)))
398
+        }
399
+    }
400
+
401
+    /// Check if current branch has an upstream configured
402
+    pub fn has_upstream(&self) -> bool {
403
+        let output = Command::new("git")
404
+            .args(["rev-parse", "--abbrev-ref", "@{upstream}"])
405
+            .stdout(std::process::Stdio::piped())
406
+            .stderr(std::process::Stdio::piped())
407
+            .output();
408
+
409
+        output.map(|o| o.status.success()).unwrap_or(false)
410
+    }
411
+
412
+    /// Get list of remote names
413
+    pub fn get_remotes(&self) -> Vec<String> {
414
+        let output = Command::new("git")
415
+            .args(["remote"])
416
+            .stdout(std::process::Stdio::piped())
417
+            .stderr(std::process::Stdio::piped())
418
+            .output();
419
+
420
+        match output {
421
+            Ok(o) if o.status.success() => {
422
+                String::from_utf8_lossy(&o.stdout)
423
+                    .lines()
424
+                    .map(|s| s.trim().to_string())
425
+                    .filter(|s| !s.is_empty())
426
+                    .collect()
427
+            }
428
+            _ => Vec::new(),
429
+        }
430
+    }
431
+
432
+    /// Push with upstream set (git push -u <remote> <branch>)
433
+    pub fn push_with_upstream(&self, remote: &str) -> Result<()> {
434
+        let branch = self.branch_name();
435
+        let output = Command::new("git")
436
+            .args(["push", "-u", remote, &branch])
437
+            .stdout(std::process::Stdio::piped())
438
+            .stderr(std::process::Stdio::piped())
439
+            .output()?;
440
+
441
+        if output.status.success() {
442
+            Ok(())
443
+        } else {
444
+            let stderr = String::from_utf8_lossy(&output.stderr);
445
+            let msg = if stderr.contains("Could not read from remote") {
446
+                format!("Cannot reach '{}' - check connection/auth", remote)
447
+            } else if stderr.contains("does not appear to be a git repository") {
448
+                format!("Remote '{}' not found", remote)
449
+            } else if stderr.contains("rejected") {
450
+                "Push rejected - pull first".to_string()
451
+            } else {
452
+                format!("Push to '{}' failed", remote)
453
+            };
454
+            Err(FussrError::Git(git2::Error::from_str(&msg)))
365455
         }
366456
     }
367457
 
src/main.rsmodified
@@ -91,6 +91,7 @@ fn run_event_loop(
9191
                     InputMode::Rename { .. } => handle_rename_key(app, key.code)?,
9292
                     InputMode::Commit { .. } => handle_commit_key(app, key.code)?,
9393
                     InputMode::Search { .. } => handle_search_key(app, key.code)?,
94
+                    InputMode::Push { .. } => handle_push_key(app, key.code)?,
9495
                     InputMode::Confirm { .. } => handle_confirm_key(app, key.code)?,
9596
                 }
9697
 
@@ -324,6 +325,62 @@ fn handle_commit_key(app: &mut App, code: KeyCode) -> Result<()> {
324325
     Ok(())
325326
 }
326327
 
328
+/// Handle keys in push mode
329
+fn handle_push_key(app: &mut App, code: KeyCode) -> Result<()> {
330
+    use crate::types::PushStatus;
331
+
332
+    // Get current status
333
+    let status = if let InputMode::Push { status, .. } = &app.input_mode {
334
+        status.clone()
335
+    } else {
336
+        return Ok(());
337
+    };
338
+
339
+    match status {
340
+        PushStatus::SelectRemote => {
341
+            match code {
342
+                KeyCode::Esc => app.close_push(),
343
+                KeyCode::Char('j') | KeyCode::Down => {
344
+                    if let InputMode::Push { remotes, selected, .. } = &mut app.input_mode {
345
+                        if *selected + 1 < remotes.len() {
346
+                            *selected += 1;
347
+                        }
348
+                    }
349
+                }
350
+                KeyCode::Char('k') | KeyCode::Up => {
351
+                    if let InputMode::Push { selected, .. } = &mut app.input_mode {
352
+                        if *selected > 0 {
353
+                            *selected -= 1;
354
+                        }
355
+                    }
356
+                }
357
+                KeyCode::Enter => {
358
+                    // Get the selected remote and push
359
+                    let remote = if let InputMode::Push { remotes, selected, .. } = &app.input_mode {
360
+                        remotes.get(*selected).cloned()
361
+                    } else {
362
+                        None
363
+                    };
364
+
365
+                    if let Some(remote) = remote {
366
+                        app.push_to_remote(&remote)?;
367
+                    }
368
+                }
369
+                _ => {}
370
+            }
371
+        }
372
+        PushStatus::Pushing => {
373
+            // Don't respond to keys while pushing
374
+        }
375
+        PushStatus::Success | PushStatus::Failed(_) => {
376
+            // Any key closes the modal
377
+            app.close_push();
378
+        }
379
+    }
380
+
381
+    Ok(())
382
+}
383
+
327384
 /// Handle keys in search mode (legacy - not currently used)
328385
 fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> {
329386
     match code {
src/types.rsmodified
@@ -180,6 +180,15 @@ pub enum CommitStatus {
180180
     Failed,
181181
 }
182182
 
183
+/// Status of push operation
184
+#[derive(Debug, Clone, PartialEq, Eq)]
185
+pub enum PushStatus {
186
+    SelectRemote,
187
+    Pushing,
188
+    Success,
189
+    Failed(String),
190
+}
191
+
183192
 /// Input mode for special states
184193
 #[derive(Debug, Clone, PartialEq, Eq)]
185194
 pub enum InputMode {
@@ -187,6 +196,7 @@ pub enum InputMode {
187196
     Rename { buffer: String, cursor: usize },
188197
     Search { buffer: String },
189198
     Commit { buffer: String, cursor: usize, amend: bool, status: CommitStatus },
199
+    Push { remotes: Vec<String>, selected: usize, status: PushStatus },
190200
     Confirm { message: String, action: ConfirmAction },
191201
 }
192202
 
src/ui.rsmodified
@@ -1,5 +1,5 @@
11
 use crate::app::App;
2
-use crate::types::{AppMode, CommitStatus, InputMode, SelectableItem};
2
+use crate::types::{AppMode, CommitStatus, InputMode, PushStatus, SelectableItem};
33
 use ratatui::{
44
     layout::{Constraint, Direction, Layout, Rect},
55
     style::{Color, Modifier, Style},
@@ -27,6 +27,11 @@ pub fn draw(frame: &mut Frame, app: &App) {
2727
     if let InputMode::Commit { buffer, cursor, amend, status } = &app.input_mode {
2828
         draw_commit_modal(frame, buffer, *cursor, *amend, status);
2929
     }
30
+
31
+    // Draw modal overlay if in push mode
32
+    if let InputMode::Push { remotes, selected, status } = &app.input_mode {
33
+        draw_push_modal(frame, remotes, *selected, status, &app.branch_name);
34
+    }
3035
 }
3136
 
3237
 /// Draw commit message modal
@@ -107,6 +112,95 @@ fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool
107112
     frame.render_widget(input, modal_area);
108113
 }
109114
 
115
+/// Draw push remote selection modal
116
+fn draw_push_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &PushStatus, branch: &str) {
117
+    let area = frame.area();
118
+
119
+    // Modal size based on content
120
+    let modal_height = match status {
121
+        PushStatus::SelectRemote => (remotes.len() + 4).min(12) as u16,
122
+        _ => 5,
123
+    };
124
+    let modal_width = 50.min(area.width.saturating_sub(4));
125
+    let x = (area.width.saturating_sub(modal_width)) / 2;
126
+    let y = (area.height.saturating_sub(modal_height)) / 2;
127
+
128
+    let modal_area = Rect::new(x, y, modal_width, modal_height);
129
+
130
+    // Clear area behind modal
131
+    frame.render_widget(Clear, modal_area);
132
+
133
+    // Title and border color based on status
134
+    let (title, border_color) = match status {
135
+        PushStatus::SelectRemote => (" Push - Select Remote ", Color::Cyan),
136
+        PushStatus::Pushing => (" Pushing... ", Color::Yellow),
137
+        PushStatus::Success => (" ✓ Pushed ", Color::Green),
138
+        PushStatus::Failed(_) => (" ✗ Push Failed ", Color::Red),
139
+    };
140
+
141
+    let block = Block::default()
142
+        .title(title)
143
+        .borders(Borders::ALL)
144
+        .border_style(Style::default().fg(border_color));
145
+
146
+    // Content based on status
147
+    let content: Vec<Line> = match status {
148
+        PushStatus::SelectRemote => {
149
+            let mut lines = vec![
150
+                Line::from(Span::styled(
151
+                    format!("  Set upstream for '{}':", branch),
152
+                    Style::default().fg(Color::White),
153
+                )),
154
+                Line::from(""),
155
+            ];
156
+            for (i, remote) in remotes.iter().enumerate() {
157
+                let style = if i == selected {
158
+                    Style::default().add_modifier(Modifier::REVERSED)
159
+                } else {
160
+                    Style::default()
161
+                };
162
+                lines.push(Line::from(Span::styled(format!("    {}", remote), style)));
163
+            }
164
+            lines.push(Line::from(""));
165
+            lines.push(Line::from(Span::styled(
166
+                "  j/k:select Enter:push ESC:cancel",
167
+                Style::default().fg(Color::DarkGray),
168
+            )));
169
+            lines
170
+        }
171
+        PushStatus::Pushing => {
172
+            vec![
173
+                Line::from(""),
174
+                Line::from(Span::styled(
175
+                    "  Pushing changes...",
176
+                    Style::default().fg(Color::Yellow),
177
+                )),
178
+            ]
179
+        }
180
+        PushStatus::Success => {
181
+            vec![
182
+                Line::from(""),
183
+                Line::from(Span::styled(
184
+                    "  ✓ Changes pushed successfully!",
185
+                    Style::default().fg(Color::Green),
186
+                )),
187
+            ]
188
+        }
189
+        PushStatus::Failed(msg) => {
190
+            vec![
191
+                Line::from(""),
192
+                Line::from(Span::styled(
193
+                    format!("  ✗ {}", msg),
194
+                    Style::default().fg(Color::Red),
195
+                )),
196
+            ]
197
+        }
198
+    };
199
+
200
+    let widget = Paragraph::new(content).block(block);
201
+    frame.render_widget(widget, modal_area);
202
+}
203
+
110204
 /// Draw header with repo:branch info
111205
 fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
112206
     let mut spans = vec![