tenseleyflow/fussr / a85b2a5

Browse files

tag functionality

Authored by espadonne
SHA
a85b2a53df68c6dbe9e7bf4861674735e6463840
Parents
4785061
Tree
bad8248

5 changed files

StatusFile+-
M src/app.rs 86 0
M src/git.rs 89 0
M src/main.rs 138 0
M src/types.rs 13 0
M src/ui.rs 184 1
src/app.rsmodified
@@ -571,6 +571,92 @@ impl App {
571571
         };
572572
     }
573573
 
574
+    /// Enter tag mode - fetches tags and shows modal
575
+    pub fn enter_tag_mode(&mut self) {
576
+        // Fetch tags from remote first (non-blocking, ignore errors)
577
+        let _ = self.repo.fetch_tags();
578
+
579
+        // Get existing tags
580
+        let existing_tags = self.repo.get_tags();
581
+
582
+        self.input_mode = InputMode::Tag {
583
+            name: String::new(),
584
+            message: String::new(),
585
+            cursor: 0,
586
+            existing_tags,
587
+            step: crate::types::TagStep::EnterName,
588
+        };
589
+    }
590
+
591
+    /// Create the tag with current name/message
592
+    pub fn create_tag(&mut self) -> Result<()> {
593
+        let (name, message) = if let InputMode::Tag { name, message, .. } = &self.input_mode {
594
+            (name.clone(), message.clone())
595
+        } else {
596
+            return Ok(());
597
+        };
598
+
599
+        if let InputMode::Tag { step, .. } = &mut self.input_mode {
600
+            *step = crate::types::TagStep::Creating;
601
+        }
602
+
603
+        match self.repo.create_tag(&name, &message) {
604
+            Ok(()) => {
605
+                if let InputMode::Tag { step, .. } = &mut self.input_mode {
606
+                    *step = crate::types::TagStep::AskPush;
607
+                }
608
+                Ok(())
609
+            }
610
+            Err(e) => {
611
+                let msg = e.to_string();
612
+                if let InputMode::Tag { step, .. } = &mut self.input_mode {
613
+                    *step = crate::types::TagStep::Failed(msg);
614
+                }
615
+                Ok(())
616
+            }
617
+        }
618
+    }
619
+
620
+    /// Push the tag to origin
621
+    pub fn push_tag(&mut self) -> Result<()> {
622
+        let name = if let InputMode::Tag { name, .. } = &self.input_mode {
623
+            name.clone()
624
+        } else {
625
+            return Ok(());
626
+        };
627
+
628
+        if let InputMode::Tag { step, .. } = &mut self.input_mode {
629
+            *step = crate::types::TagStep::Pushing;
630
+        }
631
+
632
+        match self.repo.push_tag(&name) {
633
+            Ok(()) => {
634
+                if let InputMode::Tag { step, .. } = &mut self.input_mode {
635
+                    *step = crate::types::TagStep::Success;
636
+                }
637
+                self.set_status(format!("Tag '{}' pushed to origin", name));
638
+                Ok(())
639
+            }
640
+            Err(e) => {
641
+                let msg = e.to_string();
642
+                if let InputMode::Tag { step, .. } = &mut self.input_mode {
643
+                    *step = crate::types::TagStep::Failed(msg);
644
+                }
645
+                Ok(())
646
+            }
647
+        }
648
+    }
649
+
650
+    /// Close tag modal (with success message if tag was created)
651
+    pub fn close_tag(&mut self, was_created: bool) {
652
+        if was_created {
653
+            if let InputMode::Tag { name, .. } = &self.input_mode {
654
+                self.set_status(format!("Tag '{}' created", name));
655
+            }
656
+        }
657
+        self.input_mode = InputMode::Navigation;
658
+    }
659
+
574660
     /// Create a commit
575661
     pub fn commit(&mut self, message: &str) -> Result<()> {
576662
         self.repo.commit(message)?;
src/git.rsmodified
@@ -541,4 +541,93 @@ impl GitRepo {
541541
             Err(FussrError::Git(git2::Error::from_str("Failed to rename file")))
542542
         }
543543
     }
544
+
545
+    /// Fetch tags from remote
546
+    pub fn fetch_tags(&self) -> Result<()> {
547
+        let output = Command::new("git")
548
+            .args(["fetch", "--tags", "--quiet"])
549
+            .stdout(std::process::Stdio::piped())
550
+            .stderr(std::process::Stdio::piped())
551
+            .output()?;
552
+
553
+        if output.status.success() {
554
+            Ok(())
555
+        } else {
556
+            // Silently ignore fetch errors - tags list will still work
557
+            Ok(())
558
+        }
559
+    }
560
+
561
+    /// Get list of existing tags (sorted by version, newest first)
562
+    pub fn get_tags(&self) -> Vec<String> {
563
+        let output = Command::new("git")
564
+            .args(["tag", "--sort=-version:refname"])
565
+            .stdout(std::process::Stdio::piped())
566
+            .stderr(std::process::Stdio::piped())
567
+            .output();
568
+
569
+        match output {
570
+            Ok(o) if o.status.success() => {
571
+                String::from_utf8_lossy(&o.stdout)
572
+                    .lines()
573
+                    .take(10) // Only show last 10 tags
574
+                    .map(|s| s.trim().to_string())
575
+                    .filter(|s| !s.is_empty())
576
+                    .collect()
577
+            }
578
+            _ => Vec::new(),
579
+        }
580
+    }
581
+
582
+    /// Create a new tag
583
+    pub fn create_tag(&self, name: &str, message: &str) -> Result<()> {
584
+        let output = if message.is_empty() {
585
+            // Lightweight tag
586
+            Command::new("git")
587
+                .args(["tag", name])
588
+                .stdout(std::process::Stdio::piped())
589
+                .stderr(std::process::Stdio::piped())
590
+                .output()?
591
+        } else {
592
+            // Annotated tag with message
593
+            Command::new("git")
594
+                .args(["tag", "-a", name, "-m", message])
595
+                .stdout(std::process::Stdio::piped())
596
+                .stderr(std::process::Stdio::piped())
597
+                .output()?
598
+        };
599
+
600
+        if output.status.success() {
601
+            Ok(())
602
+        } else {
603
+            let stderr = String::from_utf8_lossy(&output.stderr);
604
+            let msg = if stderr.contains("already exists") {
605
+                format!("Tag '{}' already exists", name)
606
+            } else {
607
+                format!("Failed to create tag '{}'", name)
608
+            };
609
+            Err(FussrError::Git(git2::Error::from_str(&msg)))
610
+        }
611
+    }
612
+
613
+    /// Push a tag to origin
614
+    pub fn push_tag(&self, name: &str) -> Result<()> {
615
+        let output = Command::new("git")
616
+            .args(["push", "origin", name])
617
+            .stdout(std::process::Stdio::piped())
618
+            .stderr(std::process::Stdio::piped())
619
+            .output()?;
620
+
621
+        if output.status.success() {
622
+            Ok(())
623
+        } else {
624
+            let stderr = String::from_utf8_lossy(&output.stderr);
625
+            let msg = if stderr.contains("Could not read from remote") {
626
+                "Cannot reach origin - check connection/auth".to_string()
627
+            } else {
628
+                format!("Failed to push tag '{}'", name)
629
+            };
630
+            Err(FussrError::Git(git2::Error::from_str(&msg)))
631
+        }
632
+    }
544633
 }
src/main.rsmodified
@@ -94,6 +94,7 @@ fn run_event_loop(
9494
                     InputMode::Push { .. } => handle_push_key(app, key.code)?,
9595
                     InputMode::Pull { .. } => handle_pull_key(app, key.code)?,
9696
                     InputMode::Fetch { .. } => handle_fetch_key(app, key.code)?,
97
+                    InputMode::Tag { .. } => handle_tag_key(app, key.code)?,
9798
                     InputMode::Confirm { .. } => handle_confirm_key(app, key.code)?,
9899
                 }
99100
 
@@ -235,6 +236,9 @@ fn handle_navigation_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers)
235236
         KeyCode::Char('M') if app.mode == AppMode::Git => {
236237
             app.enter_commit_mode(true); // amend
237238
         }
239
+        KeyCode::Char('t') if app.mode == AppMode::Git => {
240
+            app.enter_tag_mode();
241
+        }
238242
 
239243
         _ => {}
240244
     }
@@ -503,6 +507,140 @@ fn handle_fetch_key(app: &mut App, code: KeyCode) -> Result<()> {
503507
     Ok(())
504508
 }
505509
 
510
+/// Handle keys in tag mode
511
+fn handle_tag_key(app: &mut App, code: KeyCode) -> Result<()> {
512
+    use crate::types::TagStep;
513
+
514
+    let step = if let InputMode::Tag { step, .. } = &app.input_mode {
515
+        step.clone()
516
+    } else {
517
+        return Ok(());
518
+    };
519
+
520
+    match step {
521
+        TagStep::EnterName => {
522
+            match code {
523
+                KeyCode::Esc => {
524
+                    app.close_tag(false);
525
+                }
526
+                KeyCode::Enter => {
527
+                    // Check if name is non-empty before proceeding
528
+                    let has_name = if let InputMode::Tag { name, .. } = &app.input_mode {
529
+                        !name.trim().is_empty()
530
+                    } else {
531
+                        false
532
+                    };
533
+
534
+                    if has_name {
535
+                        // Move cursor to message field
536
+                        if let InputMode::Tag { step, cursor, .. } = &mut app.input_mode {
537
+                            *step = TagStep::EnterMessage;
538
+                            *cursor = 0;
539
+                        }
540
+                    }
541
+                }
542
+                KeyCode::Backspace => {
543
+                    if let InputMode::Tag { name, cursor, .. } = &mut app.input_mode {
544
+                        if *cursor > 0 {
545
+                            name.remove(*cursor - 1);
546
+                            *cursor -= 1;
547
+                        }
548
+                    }
549
+                }
550
+                KeyCode::Left => {
551
+                    if let InputMode::Tag { cursor, .. } = &mut app.input_mode {
552
+                        if *cursor > 0 {
553
+                            *cursor -= 1;
554
+                        }
555
+                    }
556
+                }
557
+                KeyCode::Right => {
558
+                    if let InputMode::Tag { name, cursor, .. } = &mut app.input_mode {
559
+                        if *cursor < name.len() {
560
+                            *cursor += 1;
561
+                        }
562
+                    }
563
+                }
564
+                KeyCode::Char(c) => {
565
+                    if let InputMode::Tag { name, cursor, .. } = &mut app.input_mode {
566
+                        name.insert(*cursor, c);
567
+                        *cursor += 1;
568
+                    }
569
+                }
570
+                _ => {}
571
+            }
572
+        }
573
+        TagStep::EnterMessage => {
574
+            match code {
575
+                KeyCode::Esc => {
576
+                    // Go back to name entry
577
+                    if let InputMode::Tag { step, name, cursor, .. } = &mut app.input_mode {
578
+                        *step = TagStep::EnterName;
579
+                        *cursor = name.len();
580
+                    }
581
+                }
582
+                KeyCode::Enter => {
583
+                    // Create the tag
584
+                    app.create_tag()?;
585
+                }
586
+                KeyCode::Backspace => {
587
+                    if let InputMode::Tag { message, cursor, .. } = &mut app.input_mode {
588
+                        if *cursor > 0 {
589
+                            message.remove(*cursor - 1);
590
+                            *cursor -= 1;
591
+                        }
592
+                    }
593
+                }
594
+                KeyCode::Left => {
595
+                    if let InputMode::Tag { cursor, .. } = &mut app.input_mode {
596
+                        if *cursor > 0 {
597
+                            *cursor -= 1;
598
+                        }
599
+                    }
600
+                }
601
+                KeyCode::Right => {
602
+                    if let InputMode::Tag { message, cursor, .. } = &mut app.input_mode {
603
+                        if *cursor < message.len() {
604
+                            *cursor += 1;
605
+                        }
606
+                    }
607
+                }
608
+                KeyCode::Char(c) => {
609
+                    if let InputMode::Tag { message, cursor, .. } = &mut app.input_mode {
610
+                        message.insert(*cursor, c);
611
+                        *cursor += 1;
612
+                    }
613
+                }
614
+                _ => {}
615
+            }
616
+        }
617
+        TagStep::Creating | TagStep::Pushing => {
618
+            // Don't respond to keys while creating/pushing
619
+        }
620
+        TagStep::AskPush => {
621
+            match code {
622
+                KeyCode::Char('y') | KeyCode::Char('Y') => {
623
+                    app.push_tag()?;
624
+                }
625
+                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
626
+                    app.close_tag(true);
627
+                }
628
+                _ => {}
629
+            }
630
+        }
631
+        TagStep::Success => {
632
+            // Any key closes
633
+            app.close_tag(true);
634
+        }
635
+        TagStep::Failed(_) => {
636
+            // Any key closes
637
+            app.close_tag(false);
638
+        }
639
+    }
640
+
641
+    Ok(())
642
+}
643
+
506644
 /// Handle keys in search mode (legacy - not currently used)
507645
 fn handle_search_key(app: &mut App, code: KeyCode) -> Result<()> {
508646
     match code {
src/types.rsmodified
@@ -207,6 +207,18 @@ pub enum FetchStatus {
207207
     Failed(String),
208208
 }
209209
 
210
+/// Status/step of tag operation
211
+#[derive(Debug, Clone, PartialEq, Eq)]
212
+pub enum TagStep {
213
+    EnterName,
214
+    EnterMessage,
215
+    Creating,
216
+    AskPush,
217
+    Pushing,
218
+    Success,
219
+    Failed(String),
220
+}
221
+
210222
 /// Input mode for special states
211223
 #[derive(Debug, Clone, PartialEq, Eq)]
212224
 pub enum InputMode {
@@ -217,6 +229,7 @@ pub enum InputMode {
217229
     Push { remotes: Vec<String>, selected: usize, status: PushStatus },
218230
     Pull { remotes: Vec<String>, selected: usize, status: PullStatus },
219231
     Fetch { remotes: Vec<String>, selected: usize, status: FetchStatus },
232
+    Tag { name: String, message: String, cursor: usize, existing_tags: Vec<String>, step: TagStep },
220233
     Confirm { message: String, action: ConfirmAction },
221234
 }
222235
 
src/ui.rsmodified
@@ -1,5 +1,5 @@
11
 use crate::app::App;
2
-use crate::types::{AppMode, CommitStatus, FetchStatus, InputMode, PullStatus, PushStatus, SelectableItem};
2
+use crate::types::{AppMode, CommitStatus, FetchStatus, InputMode, PullStatus, PushStatus, SelectableItem, TagStep};
33
 use ratatui::{
44
     layout::{Constraint, Direction, Layout, Rect},
55
     style::{Color, Modifier, Style},
@@ -43,6 +43,11 @@ pub fn draw(frame: &mut Frame, app: &App) {
4343
         draw_fetch_modal(frame, remotes, *selected, status);
4444
     }
4545
 
46
+    // Draw modal overlay if in tag mode
47
+    if let InputMode::Tag { name, message, cursor, existing_tags, step } = &app.input_mode {
48
+        draw_tag_modal(frame, name, message, *cursor, existing_tags, step);
49
+    }
50
+
4651
     // Draw modal overlay if in confirm mode
4752
     if let InputMode::Confirm { message, .. } = &app.input_mode {
4853
         draw_confirm_modal(frame, message);
@@ -386,6 +391,184 @@ fn draw_fetch_modal(frame: &mut Frame, remotes: &[String], selected: usize, stat
386391
     frame.render_widget(widget, modal_area);
387392
 }
388393
 
394
+/// Draw tag creation modal
395
+fn draw_tag_modal(
396
+    frame: &mut Frame,
397
+    name: &str,
398
+    message: &str,
399
+    cursor: usize,
400
+    existing_tags: &[String],
401
+    step: &TagStep,
402
+) {
403
+    let area = frame.area();
404
+
405
+    // Modal size based on step
406
+    let modal_height = match step {
407
+        TagStep::EnterName | TagStep::EnterMessage => {
408
+            let tags_height = existing_tags.len().min(5) as u16;
409
+            8 + tags_height
410
+        }
411
+        _ => 5,
412
+    };
413
+    let modal_width = 55.min(area.width.saturating_sub(4));
414
+    let x = (area.width.saturating_sub(modal_width)) / 2;
415
+    let y = (area.height.saturating_sub(modal_height)) / 2;
416
+
417
+    let modal_area = Rect::new(x, y, modal_width, modal_height);
418
+
419
+    frame.render_widget(Clear, modal_area);
420
+
421
+    let (title, border_color) = match step {
422
+        TagStep::EnterName => (" Tag - Enter Name ", Color::Cyan),
423
+        TagStep::EnterMessage => (" Tag - Enter Message ", Color::Cyan),
424
+        TagStep::Creating => (" Creating Tag... ", Color::Yellow),
425
+        TagStep::AskPush => (" Push Tag? ", Color::Yellow),
426
+        TagStep::Pushing => (" Pushing Tag... ", Color::Yellow),
427
+        TagStep::Success => (" ✓ Tag Pushed ", Color::Green),
428
+        TagStep::Failed(_) => (" ✗ Tag Failed ", Color::Red),
429
+    };
430
+
431
+    let block = Block::default()
432
+        .title(title)
433
+        .borders(Borders::ALL)
434
+        .border_style(Style::default().fg(border_color));
435
+
436
+    let content: Vec<Line> = match step {
437
+        TagStep::EnterName => {
438
+            let mut lines = vec![];
439
+
440
+            // Show existing tags
441
+            if !existing_tags.is_empty() {
442
+                lines.push(Line::from(Span::styled(
443
+                    "  Recent tags:",
444
+                    Style::default().fg(Color::DarkGray),
445
+                )));
446
+                for tag in existing_tags.iter().take(5) {
447
+                    lines.push(Line::from(Span::styled(
448
+                        format!("    {}", tag),
449
+                        Style::default().fg(Color::DarkGray),
450
+                    )));
451
+                }
452
+                lines.push(Line::from(""));
453
+            }
454
+
455
+            // Name input with cursor
456
+            let display_name = if cursor < name.len() {
457
+                format!(
458
+                    "{}█{}",
459
+                    &name[..cursor],
460
+                    &name[cursor..]
461
+                )
462
+            } else {
463
+                format!("{}█", name)
464
+            };
465
+            lines.push(Line::from(vec![
466
+                Span::styled("  Name: ", Style::default().fg(Color::White)),
467
+                Span::styled(display_name, Style::default().fg(Color::Yellow)),
468
+            ]));
469
+
470
+            lines.push(Line::from(""));
471
+            lines.push(Line::from(Span::styled(
472
+                "  Enter:next ESC:cancel",
473
+                Style::default().fg(Color::DarkGray),
474
+            )));
475
+            lines
476
+        }
477
+        TagStep::EnterMessage => {
478
+            let mut lines = vec![];
479
+
480
+            // Show the name
481
+            lines.push(Line::from(vec![
482
+                Span::styled("  Name: ", Style::default().fg(Color::DarkGray)),
483
+                Span::styled(name, Style::default().fg(Color::Yellow)),
484
+            ]));
485
+            lines.push(Line::from(""));
486
+
487
+            // Message input with cursor
488
+            let display_msg = if cursor < message.len() {
489
+                format!(
490
+                    "{}█{}",
491
+                    &message[..cursor],
492
+                    &message[cursor..]
493
+                )
494
+            } else {
495
+                format!("{}█", message)
496
+            };
497
+            lines.push(Line::from(vec![
498
+                Span::styled("  Message: ", Style::default().fg(Color::White)),
499
+                Span::styled(display_msg, Style::default().fg(Color::Cyan)),
500
+            ]));
501
+
502
+            lines.push(Line::from(Span::styled(
503
+                "  (optional - leave empty for lightweight tag)",
504
+                Style::default().fg(Color::DarkGray),
505
+            )));
506
+            lines.push(Line::from(""));
507
+            lines.push(Line::from(Span::styled(
508
+                "  Enter:create ESC:back",
509
+                Style::default().fg(Color::DarkGray),
510
+            )));
511
+            lines
512
+        }
513
+        TagStep::Creating => {
514
+            vec![
515
+                Line::from(""),
516
+                Line::from(Span::styled(
517
+                    format!("  Creating tag '{}'...", name),
518
+                    Style::default().fg(Color::Yellow),
519
+                )),
520
+            ]
521
+        }
522
+        TagStep::AskPush => {
523
+            vec![
524
+                Line::from(""),
525
+                Line::from(Span::styled(
526
+                    format!("  Tag '{}' created!", name),
527
+                    Style::default().fg(Color::Green),
528
+                )),
529
+                Line::from(""),
530
+                Line::from(vec![
531
+                    Span::raw("  Push to origin? "),
532
+                    Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
533
+                    Span::raw("es / "),
534
+                    Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
535
+                    Span::raw("o"),
536
+                ]),
537
+            ]
538
+        }
539
+        TagStep::Pushing => {
540
+            vec![
541
+                Line::from(""),
542
+                Line::from(Span::styled(
543
+                    format!("  Pushing tag '{}'...", name),
544
+                    Style::default().fg(Color::Yellow),
545
+                )),
546
+            ]
547
+        }
548
+        TagStep::Success => {
549
+            vec![
550
+                Line::from(""),
551
+                Line::from(Span::styled(
552
+                    format!("  ✓ Tag '{}' pushed to origin!", name),
553
+                    Style::default().fg(Color::Green),
554
+                )),
555
+            ]
556
+        }
557
+        TagStep::Failed(msg) => {
558
+            vec![
559
+                Line::from(""),
560
+                Line::from(Span::styled(
561
+                    format!("  ✗ {}", msg),
562
+                    Style::default().fg(Color::Red),
563
+                )),
564
+            ]
565
+        }
566
+    };
567
+
568
+    let widget = Paragraph::new(content).block(block);
569
+    frame.render_widget(widget, modal_area);
570
+}
571
+
389572
 /// Draw confirmation modal
390573
 fn draw_confirm_modal(frame: &mut Frame, message: &str) {
391574
     let area = frame.area();