changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(unreleased): implement delete and edit actions in review workflow with TUI support

+1064 -15
+2 -2
ROADMAP.md
··· 24 24 - [x] Auto-detect type from conventional commit format 25 25 - [x] Optional `--type`, `--scope`, `--summary` override flags 26 26 - [ ] Optional `--issue` flag (TODO: see issue-linking task below) 27 - - [ ] Implement delete action from review 28 - - [ ] Implement edit action from review 27 + - [x] Implement delete action from review 28 + - [x] Implement edit action from review 29 29 - [x] `storm check` - Validate that changes include unreleased partials 30 30 - [x] Detect missing partials for changed code paths 31 31 - [x] Honor `[nochanges]` and `[skip changelog]` markers in commit messages
+37 -6
cmd/unreleased.go
··· 182 182 editCount := 0 183 183 184 184 for _, item := range items { 185 - switch item.Action { 186 - case ui.ActionDelete: 185 + if item.Action == ui.ActionDelete { 186 + if err := changeset.Delete(changesDir, item.Entry.Filename); err != nil { 187 + return fmt.Errorf("failed to delete %s: %w", item.Entry.Filename, err) 188 + } 187 189 deleteCount++ 188 - case ui.ActionEdit: 189 - editCount++ 190 + style.Successf("Deleted: %s", item.Entry.Filename) 191 + } 192 + } 193 + 194 + for _, item := range items { 195 + if item.Action == ui.ActionEdit { 196 + editorModel := ui.NewEntryEditorModel(item.Entry) 197 + p := tea.NewProgram(editorModel, tea.WithAltScreen()) 198 + 199 + finalModel, err := p.Run() 200 + if err != nil { 201 + return fmt.Errorf("failed to run editor TUI: %w", err) 202 + } 203 + 204 + editor, ok := finalModel.(ui.EntryEditorModel) 205 + if !ok { 206 + return fmt.Errorf("unexpected model type") 207 + } 208 + 209 + if editor.IsCancelled() { 210 + style.Warningf("Skipped editing: %s", item.Entry.Filename) 211 + continue 212 + } 213 + 214 + if editor.IsConfirmed() { 215 + editedEntry := editor.GetEditedEntry() 216 + if err := changeset.Update(changesDir, item.Entry.Filename, editedEntry); err != nil { 217 + return fmt.Errorf("failed to update %s: %w", item.Entry.Filename, err) 218 + } 219 + editCount++ 220 + style.Successf("Updated: %s", item.Entry.Filename) 221 + } 190 222 } 191 223 } 192 224 ··· 195 227 return nil 196 228 } 197 229 198 - style.Headlinef("Review completed: %d to delete, %d to edit", deleteCount, editCount) 199 - style.Println("Note: Delete and edit actions are not yet implemented") 230 + style.Headlinef("Review completed: %d deleted, %d edited", deleteCount, editCount) 200 231 return nil 201 232 }, 202 233 }
+192
cmd/unreleased_test.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/stormlightlabs/git-storm/internal/changeset" 9 + "github.com/stormlightlabs/git-storm/internal/testutils" 10 + ) 11 + 12 + func TestUnreleasedReviewWorkflow_Delete(t *testing.T) { 13 + tmpDir := t.TempDir() 14 + changesDir := filepath.Join(tmpDir, ".changes") 15 + 16 + entry1 := changeset.Entry{ 17 + Type: "added", 18 + Scope: "test", 19 + Summary: "Entry to keep", 20 + } 21 + entry2 := changeset.Entry{ 22 + Type: "fixed", 23 + Scope: "test", 24 + Summary: "Entry to delete", 25 + } 26 + 27 + filePath1, err := changeset.Write(changesDir, entry1) 28 + if err != nil { 29 + t.Fatalf("Failed to create entry1: %v", err) 30 + } 31 + filePath2, err := changeset.Write(changesDir, entry2) 32 + if err != nil { 33 + t.Fatalf("Failed to create entry2: %v", err) 34 + } 35 + 36 + filename2 := filepath.Base(filePath2) 37 + 38 + err = changeset.Delete(changesDir, filename2) 39 + if err != nil { 40 + t.Fatalf("Delete action failed: %v", err) 41 + } 42 + 43 + if _, err := os.Stat(filePath1); os.IsNotExist(err) { 44 + t.Error("Entry1 should still exist") 45 + } 46 + 47 + if _, err := os.Stat(filePath2); !os.IsNotExist(err) { 48 + t.Error("Entry2 should have been deleted") 49 + } 50 + 51 + entries, err := changeset.List(changesDir) 52 + if err != nil { 53 + t.Fatalf("Failed to list entries: %v", err) 54 + } 55 + 56 + testutils.Expect.Equal(t, len(entries), 1, "Should have 1 entry remaining") 57 + testutils.Expect.Equal(t, entries[0].Entry.Summary, "Entry to keep") 58 + } 59 + 60 + func TestUnreleasedReviewWorkflow_Edit(t *testing.T) { 61 + tmpDir := t.TempDir() 62 + changesDir := filepath.Join(tmpDir, ".changes") 63 + 64 + originalEntry := changeset.Entry{ 65 + Type: "added", 66 + Scope: "cli", 67 + Summary: "Original summary", 68 + Breaking: false, 69 + CommitHash: "abc123", 70 + } 71 + 72 + filePath, err := changeset.Write(changesDir, originalEntry) 73 + if err != nil { 74 + t.Fatalf("Failed to create entry: %v", err) 75 + } 76 + 77 + filename := filepath.Base(filePath) 78 + 79 + editedEntry := changeset.Entry{ 80 + Type: "changed", 81 + Scope: "api", 82 + Summary: "Updated summary", 83 + Breaking: true, 84 + CommitHash: "abc123", 85 + } 86 + 87 + err = changeset.Update(changesDir, filename, editedEntry) 88 + if err != nil { 89 + t.Fatalf("Update action failed: %v", err) 90 + } 91 + 92 + entries, err := changeset.List(changesDir) 93 + if err != nil { 94 + t.Fatalf("Failed to list entries: %v", err) 95 + } 96 + 97 + testutils.Expect.Equal(t, len(entries), 1, "Should still have 1 entry") 98 + testutils.Expect.Equal(t, entries[0].Entry.Type, "changed", "Type should be updated") 99 + testutils.Expect.Equal(t, entries[0].Entry.Scope, "api", "Scope should be updated") 100 + testutils.Expect.Equal(t, entries[0].Entry.Summary, "Updated summary", "Summary should be updated") 101 + testutils.Expect.Equal(t, entries[0].Entry.Breaking, true, "Breaking should be updated") 102 + testutils.Expect.Equal(t, entries[0].Entry.CommitHash, "abc123", "CommitHash should be preserved") 103 + } 104 + 105 + func TestUnreleasedReviewWorkflow_DeleteAndEdit(t *testing.T) { 106 + tmpDir := t.TempDir() 107 + changesDir := filepath.Join(tmpDir, ".changes") 108 + 109 + entry1 := changeset.Entry{ 110 + Type: "added", 111 + Summary: "Entry to delete", 112 + } 113 + entry2 := changeset.Entry{ 114 + Type: "fixed", 115 + Summary: "Entry to edit", 116 + } 117 + entry3 := changeset.Entry{ 118 + Type: "changed", 119 + Summary: "Entry to keep", 120 + } 121 + 122 + filePath1, err := changeset.Write(changesDir, entry1) 123 + if err != nil { 124 + t.Fatalf("Failed to create entry1: %v", err) 125 + } 126 + filePath2, err := changeset.Write(changesDir, entry2) 127 + if err != nil { 128 + t.Fatalf("Failed to create entry2: %v", err) 129 + } 130 + _, err = changeset.Write(changesDir, entry3) 131 + if err != nil { 132 + t.Fatalf("Failed to create entry3: %v", err) 133 + } 134 + 135 + filename1 := filepath.Base(filePath1) 136 + filename2 := filepath.Base(filePath2) 137 + 138 + err = changeset.Delete(changesDir, filename1) 139 + if err != nil { 140 + t.Fatalf("Delete action failed: %v", err) 141 + } 142 + 143 + editedEntry := changeset.Entry{ 144 + Type: "security", 145 + Scope: "auth", 146 + Summary: "Updated security fix", 147 + } 148 + err = changeset.Update(changesDir, filename2, editedEntry) 149 + if err != nil { 150 + t.Fatalf("Update action failed: %v", err) 151 + } 152 + 153 + entries, err := changeset.List(changesDir) 154 + if err != nil { 155 + t.Fatalf("Failed to list entries: %v", err) 156 + } 157 + 158 + testutils.Expect.Equal(t, len(entries), 2, "Should have 2 entries remaining") 159 + 160 + var found bool 161 + for _, e := range entries { 162 + if e.Entry.Type == "security" { 163 + testutils.Expect.Equal(t, e.Entry.Scope, "auth") 164 + testutils.Expect.Equal(t, e.Entry.Summary, "Updated security fix") 165 + found = true 166 + } 167 + } 168 + 169 + if !found { 170 + t.Error("Edited entry not found in results") 171 + } 172 + 173 + if _, err := os.Stat(filePath1); !os.IsNotExist(err) { 174 + t.Error("Deleted entry should not exist") 175 + } 176 + } 177 + 178 + func TestUnreleasedReviewWorkflow_EmptyChanges(t *testing.T) { 179 + tmpDir := t.TempDir() 180 + changesDir := filepath.Join(tmpDir, ".changes") 181 + 182 + if err := os.MkdirAll(changesDir, 0755); err != nil { 183 + t.Fatalf("Failed to create directory: %v", err) 184 + } 185 + 186 + entries, err := changeset.List(changesDir) 187 + if err != nil { 188 + t.Fatalf("List should not error on empty directory: %v", err) 189 + } 190 + 191 + testutils.Expect.Equal(t, len(entries), 0, "Should have no entries") 192 + }
+2
go.mod
··· 13 13 14 14 require github.com/goccy/go-yaml v1.18.0 15 15 16 + require github.com/atotto/clipboard v0.1.4 // indirect 17 + 16 18 require ( 17 19 github.com/clipperhouse/displaywidth v0.4.1 // indirect 18 20 github.com/clipperhouse/stringish v0.1.1 // indirect
+2
go.sum
··· 6 6 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 7 7 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 8 8 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 9 + github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 10 + github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 11 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 12 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 13 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+35
internal/changeset/changeset.go
··· 402 402 } 403 403 return nil 404 404 } 405 + 406 + // Delete removes a changelog entry file from the .changes/ directory. 407 + func Delete(dir, filename string) error { 408 + filePath := filepath.Join(dir, filename) 409 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 410 + return fmt.Errorf("file %s does not exist", filename) 411 + } 412 + 413 + if err := os.Remove(filePath); err != nil { 414 + return fmt.Errorf("failed to delete file %s: %w", filename, err) 415 + } 416 + return nil 417 + } 418 + 419 + // Update modifies an existing changelog entry file with new values. 420 + func Update(dir, filename string, entry Entry) error { 421 + filePath := filepath.Join(dir, filename) 422 + 423 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 424 + return fmt.Errorf("file %s does not exist", filename) 425 + } 426 + 427 + yamlBytes, err := yaml.Marshal(entry) 428 + if err != nil { 429 + return fmt.Errorf("failed to marshal entry to YAML: %w", err) 430 + } 431 + 432 + content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) 433 + 434 + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 435 + return fmt.Errorf("failed to update file %s: %w", filename, err) 436 + } 437 + 438 + return nil 439 + }
+157 -1
internal/changeset/changeset_test.go
··· 14 14 15 15 func TestWrite(t *testing.T) { 16 16 tmpDir := t.TempDir() 17 - 18 17 tests := []struct { 19 18 name string 20 19 entry Entry ··· 616 615 t.Errorf("Expected 'already exists' error, got: %v", err) 617 616 } 618 617 } 618 + 619 + func TestDelete(t *testing.T) { 620 + tmpDir := t.TempDir() 621 + 622 + entry := Entry{ 623 + Type: "added", 624 + Scope: "test", 625 + Summary: "Test deletion", 626 + } 627 + 628 + filePath, err := Write(tmpDir, entry) 629 + if err != nil { 630 + t.Fatalf("Write() error = %v", err) 631 + } 632 + 633 + filename := filepath.Base(filePath) 634 + 635 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 636 + t.Fatalf("File should exist before deletion: %s", filePath) 637 + } 638 + 639 + err = Delete(tmpDir, filename) 640 + if err != nil { 641 + t.Fatalf("Delete() error = %v", err) 642 + } 643 + 644 + if _, err := os.Stat(filePath); !os.IsNotExist(err) { 645 + t.Errorf("File should not exist after deletion: %s", filePath) 646 + } 647 + } 648 + 649 + func TestDelete_NonExistentFile(t *testing.T) { 650 + tmpDir := t.TempDir() 651 + 652 + err := Delete(tmpDir, "nonexistent.md") 653 + if err == nil { 654 + t.Error("Expected error when deleting non-existent file, got nil") 655 + } 656 + 657 + if !strings.Contains(err.Error(), "does not exist") { 658 + t.Errorf("Expected 'does not exist' error, got: %v", err) 659 + } 660 + } 661 + 662 + func TestUpdate(t *testing.T) { 663 + tmpDir := t.TempDir() 664 + 665 + originalEntry := Entry{ 666 + Type: "added", 667 + Scope: "cli", 668 + Summary: "Original summary", 669 + } 670 + 671 + filePath, err := Write(tmpDir, originalEntry) 672 + if err != nil { 673 + t.Fatalf("Write() error = %v", err) 674 + } 675 + 676 + filename := filepath.Base(filePath) 677 + 678 + updatedEntry := Entry{ 679 + Type: "changed", 680 + Scope: "api", 681 + Summary: "Updated summary", 682 + } 683 + 684 + err = Update(tmpDir, filename, updatedEntry) 685 + if err != nil { 686 + t.Fatalf("Update() error = %v", err) 687 + } 688 + 689 + content, err := os.ReadFile(filePath) 690 + if err != nil { 691 + t.Fatalf("Failed to read updated file: %v", err) 692 + } 693 + 694 + parts := strings.SplitN(string(content), "---\n", 3) 695 + if len(parts) < 3 { 696 + t.Fatal("Invalid YAML frontmatter format") 697 + } 698 + 699 + var parsed Entry 700 + if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil { 701 + t.Fatalf("Failed to parse YAML: %v", err) 702 + } 703 + 704 + testutils.Expect.Equal(t, parsed.Type, updatedEntry.Type, "Type should be updated") 705 + testutils.Expect.Equal(t, parsed.Scope, updatedEntry.Scope, "Scope should be updated") 706 + testutils.Expect.Equal(t, parsed.Summary, updatedEntry.Summary, "Summary should be updated") 707 + } 708 + 709 + func TestUpdate_NonExistentFile(t *testing.T) { 710 + tmpDir := t.TempDir() 711 + 712 + entry := Entry{ 713 + Type: "added", 714 + Summary: "Test", 715 + } 716 + 717 + err := Update(tmpDir, "nonexistent.md", entry) 718 + if err == nil { 719 + t.Error("Expected error when updating non-existent file, got nil") 720 + } 721 + 722 + if !strings.Contains(err.Error(), "does not exist") { 723 + t.Errorf("Expected 'does not exist' error, got: %v", err) 724 + } 725 + } 726 + 727 + func TestUpdate_PreserveMetadata(t *testing.T) { 728 + tmpDir := t.TempDir() 729 + 730 + originalEntry := Entry{ 731 + Type: "added", 732 + Scope: "cli", 733 + Summary: "Original", 734 + Breaking: false, 735 + CommitHash: "abc123", 736 + DiffHash: "def456", 737 + } 738 + 739 + filePath, err := Write(tmpDir, originalEntry) 740 + if err != nil { 741 + t.Fatalf("Write() error = %v", err) 742 + } 743 + 744 + filename := filepath.Base(filePath) 745 + 746 + updatedEntry := Entry{ 747 + Type: "changed", 748 + Scope: "api", 749 + Summary: "Updated", 750 + Breaking: true, 751 + CommitHash: "abc123", 752 + DiffHash: "def456", 753 + } 754 + 755 + err = Update(tmpDir, filename, updatedEntry) 756 + if err != nil { 757 + t.Fatalf("Update() error = %v", err) 758 + } 759 + 760 + content, err := os.ReadFile(filePath) 761 + if err != nil { 762 + t.Fatalf("Failed to read updated file: %v", err) 763 + } 764 + 765 + parts := strings.SplitN(string(content), "---\n", 3) 766 + var parsed Entry 767 + if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil { 768 + t.Fatalf("Failed to parse YAML: %v", err) 769 + } 770 + 771 + testutils.Expect.Equal(t, parsed.CommitHash, updatedEntry.CommitHash, "CommitHash should be preserved") 772 + testutils.Expect.Equal(t, parsed.DiffHash, updatedEntry.DiffHash, "DiffHash should be preserved") 773 + testutils.Expect.Equal(t, parsed.Breaking, updatedEntry.Breaking, "Breaking should be updated") 774 + }
+67 -4
internal/docs/README.md
··· 1 1 --- 2 2 title: Testing Workflow 3 - updated: 2025-01-08 4 - version: 1 3 + updated: 2025-11-08 4 + version: 2 5 5 --- 6 6 7 7 "Ride the lightning." ··· 256 256 - Launches TUI with list of entries 257 257 - Shows entry details on selection 258 258 - Keyboard navigation works (j/k or arrows) 259 - - Can mark for delete/edit (not yet implemented) 260 - - Exit with q or ESC 259 + - Can mark entries with actions: 260 + - Press `x` to mark for deletion 261 + - Press `e` to mark for editing 262 + - Press `space` to keep (undo marks) 263 + - Action indicators shown: [✓] keep, [✗] delete, [✎] edit 264 + - Footer shows action counts 265 + - Exit with q or ESC to cancel, Enter to confirm 266 + 267 + #### Deleting entries 268 + 269 + ```bash 270 + storm unreleased review 271 + # Press 'x' on unwanted entries, then Enter to confirm 272 + ``` 273 + 274 + **Expected:** 275 + 276 + - Entries marked with [✗] are deleted from `.changes/` 277 + - Shows "Deleted: `<filename>`" for each removed entry 278 + - Final count: "Review completed: N deleted, M edited" 279 + - Files are permanently removed 280 + 281 + #### Editing entries 282 + 283 + ```bash 284 + storm unreleased review 285 + # Press 'e' on an entry, then Enter to confirm 286 + ``` 287 + 288 + **Expected:** 289 + 290 + - Launches inline editor TUI for each marked entry 291 + - Editor shows: 292 + - Type (cycle with Ctrl+T through: added, changed, fixed, removed, security) 293 + - Scope (text input field) 294 + - Summary (text input field) 295 + - Breaking change status 296 + - Navigate fields with Tab/Shift+Tab 297 + - Save with Enter or Ctrl+S 298 + - Cancel with Esc (skips editing that entry) 299 + - Shows "Updated: `<filename>`" for saved changes 300 + - CommitHash and DiffHash preserved 301 + 302 + #### Review workflow 303 + 304 + ```bash 305 + # Full workflow: mark multiple actions 306 + storm unreleased review 307 + # 1. Navigate with j/k 308 + # 2. Mark first entry with 'x' (delete) 309 + # 3. Mark second entry with 'e' (edit) 310 + # 4. Mark third entry with 'x' (delete) 311 + # 5. Press Enter to confirm 312 + ``` 313 + 314 + **Expected:** 315 + 316 + - All delete actions processed first 317 + - Then edit TUI launched for each edit action 318 + - Can cancel individual edits with Esc 319 + - Final summary shows both delete and edit counts 320 + - If no actions marked, shows "No changes requested" 261 321 262 322 **Edge Cases:** 263 323 264 324 - Empty changes directory (should show message, not crash) 265 325 - Corrupted entry file (should handle gracefully) 266 326 - Non-TTY environment (should detect and warn) 327 + - Cancel review (Esc/q) - no changes applied 328 + - Delete file that no longer exists (should error gracefully) 329 + - Edit with empty fields (fields preserve original if empty) 267 330 268 331 ### CI Validation (`check`) 269 332
+33 -2
internal/docs/e2e/README.md
··· 1 1 --- 2 2 title: Integration Testing Scenarios 3 3 updated: 2025-11-08 4 - version: 1 4 + version: 2 5 5 --- 6 6 7 7 ## Feature Branch ··· 32 32 # 1. Generate from last release 33 33 storm generate --since v1.0.0 34 34 35 - # 2. Review what was generated 35 + # 2. Review and clean up entries 36 36 storm unreleased review 37 + # Navigate with j/k 38 + # Press 'x' to mark duplicates/mistakes for deletion 39 + # Press 'e' to fix typos or categorization 40 + # Press Enter to apply changes 37 41 38 42 # 3. Add manual entry for non-code change 39 43 storm unreleased add --type changed --summary "Updated documentation" ··· 49 53 cat CHANGELOG.md 50 54 51 55 # Expected: Clean CHANGELOG, annotated tag, empty .changes/ 56 + ``` 57 + 58 + ## Entry Cleanup Workflow 59 + 60 + ```bash 61 + # 1. Create some test entries with issues 62 + storm unreleased add --type added --summary "Test entry 1" 63 + storm unreleased add --type fixed --summary "Wrong category entry" 64 + storm unreleased add --type added --summary "Duplicate test entry" 65 + storm unreleased add --type added --summary "Duplicate test entry" 66 + 67 + # 2. Review and fix 68 + storm unreleased review 69 + # - Mark duplicate for deletion with 'x' 70 + # - Mark wrong category entry for edit with 'e' 71 + # - Press Enter to confirm 72 + 73 + # 3. In editor TUI for marked entry: 74 + # - Press Ctrl+T to cycle type from 'fixed' to 'changed' 75 + # - Tab to scope field, enter "docs" 76 + # - Tab to summary field, update text 77 + # - Press Enter to save 78 + 79 + # 4. Verify changes 80 + storm unreleased list 81 + 82 + # Expected: Only 2 entries remain, edited entry has correct type and scope 52 83 ``` 53 84 54 85 ## CI Pipeline Validation
+12
internal/style/style.go
··· 58 58 fmt.Println(v) 59 59 } 60 60 61 + func Successf(format string, args ...any) { 62 + s := fmt.Sprintf(format, args...) 63 + v := StyleAdded.Render(s) 64 + fmt.Println(v) 65 + } 66 + 67 + func Warningf(format string, args ...any) { 68 + s := fmt.Sprintf(format, args...) 69 + v := StyleSecurity.Render(s) 70 + fmt.Println(v) 71 + } 72 + 61 73 func Newline() { fmt.Println() } 62 74 63 75 func Fixed(s string) {
+213
internal/ui/entry_editor.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/bubbles/key" 8 + "github.com/charmbracelet/bubbles/textinput" 9 + tea "github.com/charmbracelet/bubbletea" 10 + "github.com/charmbracelet/lipgloss" 11 + "github.com/stormlightlabs/git-storm/internal/changeset" 12 + "github.com/stormlightlabs/git-storm/internal/style" 13 + ) 14 + 15 + // EntryEditorModel holds the state for the inline entry editor TUI. 16 + type EntryEditorModel struct { 17 + entry changeset.Entry 18 + filename string 19 + inputs []textinput.Model 20 + focusIdx int 21 + typeIdx int // index in validTypes array 22 + confirmed bool 23 + cancelled bool 24 + width int 25 + height int 26 + } 27 + 28 + // validTypes defines the allowed changelog entry types. 29 + var validTypes = []string{"added", "changed", "fixed", "removed", "security"} 30 + 31 + // editorKeyMap defines keyboard shortcuts for the entry editor. 32 + type editorKeyMap struct { 33 + Next key.Binding 34 + Prev key.Binding 35 + Confirm key.Binding 36 + Quit key.Binding 37 + CycleType key.Binding 38 + } 39 + 40 + var editorKeys = editorKeyMap{ 41 + Next: key.NewBinding( 42 + key.WithKeys("tab"), 43 + key.WithHelp("tab", "next field"), 44 + ), 45 + Prev: key.NewBinding( 46 + key.WithKeys("shift+tab"), 47 + key.WithHelp("shift+tab", "prev field"), 48 + ), 49 + Confirm: key.NewBinding( 50 + key.WithKeys("ctrl+s"), 51 + key.WithHelp("ctrl+s", "save"), 52 + ), 53 + Quit: key.NewBinding( 54 + key.WithKeys("esc"), 55 + key.WithHelp("esc", "cancel"), 56 + ), 57 + CycleType: key.NewBinding( 58 + key.WithKeys("ctrl+t"), 59 + key.WithHelp("ctrl+t", "cycle type"), 60 + ), 61 + } 62 + 63 + // NewEntryEditorModel creates a new editor initialized with the given entry. 64 + func NewEntryEditorModel(entry changeset.EntryWithFile) EntryEditorModel { 65 + m := EntryEditorModel{ 66 + entry: entry.Entry, 67 + filename: entry.Filename, 68 + inputs: make([]textinput.Model, 2), 69 + } 70 + 71 + for i, t := range validTypes { 72 + if t == entry.Entry.Type { 73 + m.typeIdx = i 74 + break 75 + } 76 + } 77 + 78 + m.inputs[0] = textinput.New() 79 + m.inputs[0].Placeholder = "optional scope (e.g., cli, api)" 80 + m.inputs[0].SetValue(entry.Entry.Scope) 81 + m.inputs[0].CharLimit = 50 82 + m.inputs[0].Width = 50 83 + 84 + m.inputs[1] = textinput.New() 85 + m.inputs[1].Placeholder = "brief description of the change" 86 + m.inputs[1].SetValue(entry.Entry.Summary) 87 + m.inputs[1].CharLimit = 200 88 + m.inputs[1].Width = 80 89 + 90 + m.inputs[0].Focus() 91 + return m 92 + } 93 + 94 + // Init implements tea.Model. 95 + func (m EntryEditorModel) Init() tea.Cmd { 96 + return textinput.Blink 97 + } 98 + 99 + // Update implements tea.Model. 100 + func (m EntryEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 101 + switch msg := msg.(type) { 102 + case tea.KeyMsg: 103 + switch { 104 + case key.Matches(msg, editorKeys.Quit): 105 + m.cancelled = true 106 + return m, tea.Quit 107 + case key.Matches(msg, editorKeys.Confirm): 108 + m.confirmed = true 109 + return m, tea.Quit 110 + case key.Matches(msg, editorKeys.CycleType): 111 + m.typeIdx = (m.typeIdx + 1) % len(validTypes) 112 + return m, nil 113 + case key.Matches(msg, editorKeys.Next): 114 + m.nextField() 115 + return m, nil 116 + case key.Matches(msg, editorKeys.Prev): 117 + m.prevField() 118 + return m, nil 119 + case msg.String() == "enter": 120 + m.confirmed = true 121 + return m, tea.Quit 122 + } 123 + case tea.WindowSizeMsg: 124 + m.width = msg.Width 125 + m.height = msg.Height 126 + } 127 + cmd := m.updateInputs(msg) 128 + return m, cmd 129 + } 130 + 131 + // View implements tea.Model. 132 + func (m EntryEditorModel) View() string { 133 + if m.width == 0 { 134 + return "Loading..." 135 + } 136 + 137 + var b strings.Builder 138 + 139 + title := lipgloss.NewStyle(). 140 + Bold(true). 141 + Foreground(style.AccentBlue). 142 + Render(fmt.Sprintf("Editing: %s", m.filename)) 143 + b.WriteString(title) 144 + b.WriteString("\n\n") 145 + 146 + typeLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Type:") 147 + typeValue := getCategoryStyle(validTypes[m.typeIdx]).Render(validTypes[m.typeIdx]) 148 + b.WriteString(fmt.Sprintf("%s %s (ctrl+t to cycle)\n", typeLabel, typeValue)) 149 + 150 + scopeLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Scope:") 151 + b.WriteString(fmt.Sprintf("\n%s\n%s\n", scopeLabel, m.inputs[0].View())) 152 + 153 + summaryLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Summary:") 154 + b.WriteString(fmt.Sprintf("\n%s\n%s\n", summaryLabel, m.inputs[1].View())) 155 + 156 + breakingLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Breaking:") 157 + breakingValue := "no" 158 + if m.entry.Breaking { 159 + breakingValue = style.StyleRemoved.Render("yes") 160 + } 161 + b.WriteString(fmt.Sprintf("\n%s %s\n", breakingLabel, breakingValue)) 162 + 163 + b.WriteString("\n") 164 + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 165 + b.WriteString(helpStyle.Render("tab: next • shift+tab: prev • ctrl+t: cycle type • enter/ctrl+s: save • esc: cancel")) 166 + return b.String() 167 + } 168 + 169 + // GetEditedEntry returns the entry with updated values. 170 + func (m EntryEditorModel) GetEditedEntry() changeset.Entry { 171 + return changeset.Entry{ 172 + Type: validTypes[m.typeIdx], 173 + Scope: strings.TrimSpace(m.inputs[0].Value()), 174 + Summary: strings.TrimSpace(m.inputs[1].Value()), 175 + Breaking: m.entry.Breaking, 176 + CommitHash: m.entry.CommitHash, 177 + DiffHash: m.entry.DiffHash, 178 + } 179 + } 180 + 181 + // IsConfirmed returns true if the user confirmed the edit. 182 + func (m EntryEditorModel) IsConfirmed() bool { 183 + return m.confirmed 184 + } 185 + 186 + // IsCancelled returns true if the user cancelled the edit. 187 + func (m EntryEditorModel) IsCancelled() bool { 188 + return m.cancelled 189 + } 190 + 191 + // nextField moves focus to the next input field. 192 + func (m *EntryEditorModel) nextField() { 193 + m.inputs[m.focusIdx].Blur() 194 + m.focusIdx = (m.focusIdx + 1) % len(m.inputs) 195 + m.inputs[m.focusIdx].Focus() 196 + } 197 + 198 + // prevField moves focus to the previous input field. 199 + func (m *EntryEditorModel) prevField() { 200 + m.inputs[m.focusIdx].Blur() 201 + m.focusIdx-- 202 + if m.focusIdx < 0 { 203 + m.focusIdx = len(m.inputs) - 1 204 + } 205 + m.inputs[m.focusIdx].Focus() 206 + } 207 + 208 + // updateInputs handles updates for text input fields. 209 + func (m *EntryEditorModel) updateInputs(msg tea.Msg) tea.Cmd { 210 + var cmd tea.Cmd 211 + m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg) 212 + return cmd 213 + }
+312
internal/ui/entry_editor_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "testing" 5 + 6 + tea "github.com/charmbracelet/bubbletea" 7 + "github.com/stormlightlabs/git-storm/internal/changeset" 8 + ) 9 + 10 + func TestEntryEditorModel_Init(t *testing.T) { 11 + entry := changeset.EntryWithFile{ 12 + Entry: changeset.Entry{ 13 + Type: "added", 14 + Scope: "cli", 15 + Summary: "Test entry", 16 + }, 17 + Filename: "test.md", 18 + } 19 + 20 + model := NewEntryEditorModel(entry) 21 + 22 + cmd := model.Init() 23 + if cmd == nil { 24 + t.Error("Init() should return textinput.Blink command") 25 + } 26 + } 27 + 28 + func TestEntryEditorModel_DefaultState(t *testing.T) { 29 + entry := changeset.EntryWithFile{ 30 + Entry: changeset.Entry{ 31 + Type: "added", 32 + Scope: "cli", 33 + Summary: "Test entry", 34 + }, 35 + Filename: "test.md", 36 + } 37 + 38 + model := NewEntryEditorModel(entry) 39 + 40 + if model.confirmed { 41 + t.Error("Model should not be confirmed initially") 42 + } 43 + 44 + if model.cancelled { 45 + t.Error("Model should not be cancelled initially") 46 + } 47 + 48 + if model.focusIdx != 0 { 49 + t.Errorf("Focus should be on first input, got %d", model.focusIdx) 50 + } 51 + 52 + if model.typeIdx != 0 { 53 + t.Errorf("Type index should be 0 for 'added', got %d", model.typeIdx) 54 + } 55 + } 56 + 57 + func TestEntryEditorModel_TypeCycling(t *testing.T) { 58 + entry := changeset.EntryWithFile{ 59 + Entry: changeset.Entry{ 60 + Type: "added", 61 + Scope: "cli", 62 + Summary: "Test entry", 63 + }, 64 + Filename: "test.md", 65 + } 66 + 67 + model := NewEntryEditorModel(entry) 68 + 69 + initialType := model.typeIdx 70 + 71 + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) 72 + model = updated.(EntryEditorModel) 73 + 74 + if model.typeIdx == initialType { 75 + t.Error("Type should have cycled to next value") 76 + } 77 + 78 + expectedNext := (initialType + 1) % len(validTypes) 79 + if model.typeIdx != expectedNext { 80 + t.Errorf("Type index should be %d, got %d", expectedNext, model.typeIdx) 81 + } 82 + } 83 + 84 + func TestEntryEditorModel_Confirm(t *testing.T) { 85 + entry := changeset.EntryWithFile{ 86 + Entry: changeset.Entry{ 87 + Type: "added", 88 + Scope: "cli", 89 + Summary: "Test entry", 90 + }, 91 + Filename: "test.md", 92 + } 93 + 94 + model := NewEntryEditorModel(entry) 95 + 96 + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) 97 + model = updated.(EntryEditorModel) 98 + 99 + if !model.confirmed { 100 + t.Error("Model should be confirmed after pressing Enter") 101 + } 102 + 103 + if cmd == nil { 104 + t.Error("Confirm should return tea.Quit command") 105 + } 106 + } 107 + 108 + func TestEntryEditorModel_ConfirmWithCtrlS(t *testing.T) { 109 + entry := changeset.EntryWithFile{ 110 + Entry: changeset.Entry{ 111 + Type: "added", 112 + Scope: "cli", 113 + Summary: "Test entry", 114 + }, 115 + Filename: "test.md", 116 + } 117 + 118 + model := NewEntryEditorModel(entry) 119 + 120 + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) 121 + model = updated.(EntryEditorModel) 122 + 123 + if !model.confirmed { 124 + t.Error("Model should be confirmed after pressing Ctrl+S") 125 + } 126 + 127 + if cmd == nil { 128 + t.Error("Confirm should return tea.Quit command") 129 + } 130 + } 131 + 132 + func TestEntryEditorModel_Cancel(t *testing.T) { 133 + entry := changeset.EntryWithFile{ 134 + Entry: changeset.Entry{ 135 + Type: "added", 136 + Scope: "cli", 137 + Summary: "Test entry", 138 + }, 139 + Filename: "test.md", 140 + } 141 + 142 + model := NewEntryEditorModel(entry) 143 + 144 + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) 145 + model = updated.(EntryEditorModel) 146 + 147 + if !model.cancelled { 148 + t.Error("Model should be cancelled after pressing Esc") 149 + } 150 + 151 + if cmd == nil { 152 + t.Error("Cancel should return tea.Quit command") 153 + } 154 + } 155 + 156 + func TestEntryEditorModel_FieldNavigation(t *testing.T) { 157 + entry := changeset.EntryWithFile{ 158 + Entry: changeset.Entry{ 159 + Type: "added", 160 + Scope: "cli", 161 + Summary: "Test entry", 162 + }, 163 + Filename: "test.md", 164 + } 165 + 166 + model := NewEntryEditorModel(entry) 167 + 168 + if model.focusIdx != 0 { 169 + t.Fatalf("Initial focus should be on field 0, got %d", model.focusIdx) 170 + } 171 + 172 + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab}) 173 + model = updated.(EntryEditorModel) 174 + 175 + if model.focusIdx != 1 { 176 + t.Errorf("Focus should move to field 1 after Tab, got %d", model.focusIdx) 177 + } 178 + 179 + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) 180 + model = updated.(EntryEditorModel) 181 + 182 + if model.focusIdx != 0 { 183 + t.Errorf("Focus should move back to field 0 after Shift+Tab, got %d", model.focusIdx) 184 + } 185 + } 186 + 187 + func TestEntryEditorModel_GetEditedEntry(t *testing.T) { 188 + entry := changeset.EntryWithFile{ 189 + Entry: changeset.Entry{ 190 + Type: "added", 191 + Scope: "cli", 192 + Summary: "Test entry", 193 + Breaking: false, 194 + CommitHash: "abc123", 195 + DiffHash: "def456", 196 + }, 197 + Filename: "test.md", 198 + } 199 + 200 + model := NewEntryEditorModel(entry) 201 + 202 + model.typeIdx = 1 203 + 204 + editedEntry := model.GetEditedEntry() 205 + 206 + if editedEntry.Type != validTypes[1] { 207 + t.Errorf("Expected type %s, got %s", validTypes[1], editedEntry.Type) 208 + } 209 + 210 + if editedEntry.CommitHash != entry.Entry.CommitHash { 211 + t.Error("CommitHash should be preserved") 212 + } 213 + 214 + if editedEntry.DiffHash != entry.Entry.DiffHash { 215 + t.Error("DiffHash should be preserved") 216 + } 217 + } 218 + 219 + func TestEntryEditorModel_IsConfirmed(t *testing.T) { 220 + entry := changeset.EntryWithFile{ 221 + Entry: changeset.Entry{Type: "added", Summary: "Test"}, 222 + Filename: "test.md", 223 + } 224 + 225 + model := NewEntryEditorModel(entry) 226 + 227 + if model.IsConfirmed() { 228 + t.Error("Model should not be confirmed initially") 229 + } 230 + 231 + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) 232 + model = updated.(EntryEditorModel) 233 + 234 + if !model.IsConfirmed() { 235 + t.Error("Model should be confirmed after Enter key") 236 + } 237 + } 238 + 239 + func TestEntryEditorModel_IsCancelled(t *testing.T) { 240 + entry := changeset.EntryWithFile{ 241 + Entry: changeset.Entry{Type: "added", Summary: "Test"}, 242 + Filename: "test.md", 243 + } 244 + 245 + model := NewEntryEditorModel(entry) 246 + 247 + if model.IsCancelled() { 248 + t.Error("Model should not be cancelled initially") 249 + } 250 + 251 + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) 252 + model = updated.(EntryEditorModel) 253 + 254 + if !model.IsCancelled() { 255 + t.Error("Model should be cancelled after Esc key") 256 + } 257 + } 258 + 259 + func TestEntryEditorModel_WindowSize(t *testing.T) { 260 + entry := changeset.EntryWithFile{ 261 + Entry: changeset.Entry{Type: "added", Summary: "Test"}, 262 + Filename: "test.md", 263 + } 264 + 265 + model := NewEntryEditorModel(entry) 266 + 267 + if model.width != 0 || model.height != 0 { 268 + t.Error("Initial window size should be 0") 269 + } 270 + 271 + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 272 + model = updated.(EntryEditorModel) 273 + 274 + if model.width != 100 { 275 + t.Errorf("Width should be 100, got %d", model.width) 276 + } 277 + 278 + if model.height != 30 { 279 + t.Errorf("Height should be 30, got %d", model.height) 280 + } 281 + } 282 + 283 + func TestEntryEditorModel_TypeIndexForDifferentTypes(t *testing.T) { 284 + tests := []struct { 285 + entryType string 286 + expectedIndex int 287 + }{ 288 + {"added", 0}, 289 + {"changed", 1}, 290 + {"fixed", 2}, 291 + {"removed", 3}, 292 + {"security", 4}, 293 + } 294 + 295 + for _, tt := range tests { 296 + t.Run(tt.entryType, func(t *testing.T) { 297 + entry := changeset.EntryWithFile{ 298 + Entry: changeset.Entry{ 299 + Type: tt.entryType, 300 + Summary: "Test entry", 301 + }, 302 + Filename: "test.md", 303 + } 304 + 305 + model := NewEntryEditorModel(entry) 306 + 307 + if model.typeIdx != tt.expectedIndex { 308 + t.Errorf("Type index for %s should be %d, got %d", tt.entryType, tt.expectedIndex, model.typeIdx) 309 + } 310 + }) 311 + } 312 + }