push modal for remote choosing
- SHA
75b8f708b467bdf68fe33ef085e0650a7238353c- Parents
-
2da7494 - Tree
0923555
75b8f70
75b8f708b467bdf68fe33ef085e0650a7238353c2da7494
0923555| Status | File | + | - |
|---|---|---|---|
| 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 { | ||
| 337 | 337 | self.refresh_files() |
| 338 | 338 | } |
| 339 | 339 | |
| 340 | - /// Push to remote | |
| 340 | + /// Push to remote - shows remote selector if no upstream configured | |
| 341 | 341 | 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; | |
| 346 | 394 | } |
| 347 | 395 | |
| 348 | 396 | /// Create a commit |
src/git.rsmodified@@ -331,7 +331,15 @@ impl GitRepo { | ||
| 331 | 331 | if output.status.success() { |
| 332 | 332 | Ok(()) |
| 333 | 333 | } 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))) | |
| 335 | 343 | } |
| 336 | 344 | } |
| 337 | 345 | |
@@ -346,7 +354,19 @@ impl GitRepo { | ||
| 346 | 354 | if output.status.success() { |
| 347 | 355 | Ok(()) |
| 348 | 356 | } 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))) | |
| 350 | 370 | } |
| 351 | 371 | } |
| 352 | 372 | |
@@ -361,7 +381,77 @@ impl GitRepo { | ||
| 361 | 381 | if output.status.success() { |
| 362 | 382 | Ok(()) |
| 363 | 383 | } 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))) | |
| 365 | 455 | } |
| 366 | 456 | } |
| 367 | 457 | |
src/main.rsmodified@@ -91,6 +91,7 @@ fn run_event_loop( | ||
| 91 | 91 | InputMode::Rename { .. } => handle_rename_key(app, key.code)?, |
| 92 | 92 | InputMode::Commit { .. } => handle_commit_key(app, key.code)?, |
| 93 | 93 | InputMode::Search { .. } => handle_search_key(app, key.code)?, |
| 94 | + InputMode::Push { .. } => handle_push_key(app, key.code)?, | |
| 94 | 95 | InputMode::Confirm { .. } => handle_confirm_key(app, key.code)?, |
| 95 | 96 | } |
| 96 | 97 | |
@@ -324,6 +325,62 @@ fn handle_commit_key(app: &mut App, code: KeyCode) -> Result<()> { | ||
| 324 | 325 | Ok(()) |
| 325 | 326 | } |
| 326 | 327 | |
| 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 | + | |
| 327 | 384 | /// Handle keys in search mode (legacy - not currently used) |
| 328 | 385 | fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> { |
| 329 | 386 | match code { |
src/types.rsmodified@@ -180,6 +180,15 @@ pub enum CommitStatus { | ||
| 180 | 180 | Failed, |
| 181 | 181 | } |
| 182 | 182 | |
| 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 | + | |
| 183 | 192 | /// Input mode for special states |
| 184 | 193 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 185 | 194 | pub enum InputMode { |
@@ -187,6 +196,7 @@ pub enum InputMode { | ||
| 187 | 196 | Rename { buffer: String, cursor: usize }, |
| 188 | 197 | Search { buffer: String }, |
| 189 | 198 | Commit { buffer: String, cursor: usize, amend: bool, status: CommitStatus }, |
| 199 | + Push { remotes: Vec<String>, selected: usize, status: PushStatus }, | |
| 190 | 200 | Confirm { message: String, action: ConfirmAction }, |
| 191 | 201 | } |
| 192 | 202 | |
src/ui.rsmodified@@ -1,5 +1,5 @@ | ||
| 1 | 1 | use crate::app::App; |
| 2 | -use crate::types::{AppMode, CommitStatus, InputMode, SelectableItem}; | |
| 2 | +use crate::types::{AppMode, CommitStatus, InputMode, PushStatus, SelectableItem}; | |
| 3 | 3 | use ratatui::{ |
| 4 | 4 | layout::{Constraint, Direction, Layout, Rect}, |
| 5 | 5 | style::{Color, Modifier, Style}, |
@@ -27,6 +27,11 @@ pub fn draw(frame: &mut Frame, app: &App) { | ||
| 27 | 27 | if let InputMode::Commit { buffer, cursor, amend, status } = &app.input_mode { |
| 28 | 28 | draw_commit_modal(frame, buffer, *cursor, *amend, status); |
| 29 | 29 | } |
| 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 | + } | |
| 30 | 35 | } |
| 31 | 36 | |
| 32 | 37 | /// Draw commit message modal |
@@ -107,6 +112,95 @@ fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool | ||
| 107 | 112 | frame.render_widget(input, modal_area); |
| 108 | 113 | } |
| 109 | 114 | |
| 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 | + | |
| 110 | 204 | /// Draw header with repo:branch info |
| 111 | 205 | fn draw_header(frame: &mut Frame, app: &App, area: Rect) { |
| 112 | 206 | let mut spans = vec![ |