diff --git a/ROADMAP.md b/ROADMAP.md index ba996a2..0a0b692 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,8 +24,8 @@ The foundation CLI structure with core commands. - [x] Auto-detect type from conventional commit format - [x] Optional `--type`, `--scope`, `--summary` override flags - [ ] Optional `--issue` flag (TODO: see issue-linking task below) - - [ ] Implement delete action from review - - [ ] Implement edit action from review + - [x] Implement delete action from review + - [x] Implement edit action from review - [x] `storm check` - Validate that changes include unreleased partials - [x] Detect missing partials for changed code paths - [x] Honor `[nochanges]` and `[skip changelog]` markers in commit messages diff --git a/cmd/unreleased.go b/cmd/unreleased.go index 05d4f33..91cc8d5 100644 --- a/cmd/unreleased.go +++ b/cmd/unreleased.go @@ -182,11 +182,43 @@ unreleased entries before final release.`, editCount := 0 for _, item := range items { - switch item.Action { - case ui.ActionDelete: + if item.Action == ui.ActionDelete { + if err := changeset.Delete(changesDir, item.Entry.Filename); err != nil { + return fmt.Errorf("failed to delete %s: %w", item.Entry.Filename, err) + } deleteCount++ - case ui.ActionEdit: - editCount++ + style.Successf("Deleted: %s", item.Entry.Filename) + } + } + + for _, item := range items { + if item.Action == ui.ActionEdit { + editorModel := ui.NewEntryEditorModel(item.Entry) + p := tea.NewProgram(editorModel, tea.WithAltScreen()) + + finalModel, err := p.Run() + if err != nil { + return fmt.Errorf("failed to run editor TUI: %w", err) + } + + editor, ok := finalModel.(ui.EntryEditorModel) + if !ok { + return fmt.Errorf("unexpected model type") + } + + if editor.IsCancelled() { + style.Warningf("Skipped editing: %s", item.Entry.Filename) + continue + } + + if editor.IsConfirmed() { + editedEntry := editor.GetEditedEntry() + if err := changeset.Update(changesDir, item.Entry.Filename, editedEntry); err != nil { + return fmt.Errorf("failed to update %s: %w", item.Entry.Filename, err) + } + editCount++ + style.Successf("Updated: %s", item.Entry.Filename) + } } } @@ -195,8 +227,7 @@ unreleased entries before final release.`, return nil } - style.Headlinef("Review completed: %d to delete, %d to edit", deleteCount, editCount) - style.Println("Note: Delete and edit actions are not yet implemented") + style.Headlinef("Review completed: %d deleted, %d edited", deleteCount, editCount) return nil }, } diff --git a/cmd/unreleased_test.go b/cmd/unreleased_test.go new file mode 100644 index 0000000..f187118 --- /dev/null +++ b/cmd/unreleased_test.go @@ -0,0 +1,192 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stormlightlabs/git-storm/internal/changeset" + "github.com/stormlightlabs/git-storm/internal/testutils" +) + +func TestUnreleasedReviewWorkflow_Delete(t *testing.T) { + tmpDir := t.TempDir() + changesDir := filepath.Join(tmpDir, ".changes") + + entry1 := changeset.Entry{ + Type: "added", + Scope: "test", + Summary: "Entry to keep", + } + entry2 := changeset.Entry{ + Type: "fixed", + Scope: "test", + Summary: "Entry to delete", + } + + filePath1, err := changeset.Write(changesDir, entry1) + if err != nil { + t.Fatalf("Failed to create entry1: %v", err) + } + filePath2, err := changeset.Write(changesDir, entry2) + if err != nil { + t.Fatalf("Failed to create entry2: %v", err) + } + + filename2 := filepath.Base(filePath2) + + err = changeset.Delete(changesDir, filename2) + if err != nil { + t.Fatalf("Delete action failed: %v", err) + } + + if _, err := os.Stat(filePath1); os.IsNotExist(err) { + t.Error("Entry1 should still exist") + } + + if _, err := os.Stat(filePath2); !os.IsNotExist(err) { + t.Error("Entry2 should have been deleted") + } + + entries, err := changeset.List(changesDir) + if err != nil { + t.Fatalf("Failed to list entries: %v", err) + } + + testutils.Expect.Equal(t, len(entries), 1, "Should have 1 entry remaining") + testutils.Expect.Equal(t, entries[0].Entry.Summary, "Entry to keep") +} + +func TestUnreleasedReviewWorkflow_Edit(t *testing.T) { + tmpDir := t.TempDir() + changesDir := filepath.Join(tmpDir, ".changes") + + originalEntry := changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Original summary", + Breaking: false, + CommitHash: "abc123", + } + + filePath, err := changeset.Write(changesDir, originalEntry) + if err != nil { + t.Fatalf("Failed to create entry: %v", err) + } + + filename := filepath.Base(filePath) + + editedEntry := changeset.Entry{ + Type: "changed", + Scope: "api", + Summary: "Updated summary", + Breaking: true, + CommitHash: "abc123", + } + + err = changeset.Update(changesDir, filename, editedEntry) + if err != nil { + t.Fatalf("Update action failed: %v", err) + } + + entries, err := changeset.List(changesDir) + if err != nil { + t.Fatalf("Failed to list entries: %v", err) + } + + testutils.Expect.Equal(t, len(entries), 1, "Should still have 1 entry") + testutils.Expect.Equal(t, entries[0].Entry.Type, "changed", "Type should be updated") + testutils.Expect.Equal(t, entries[0].Entry.Scope, "api", "Scope should be updated") + testutils.Expect.Equal(t, entries[0].Entry.Summary, "Updated summary", "Summary should be updated") + testutils.Expect.Equal(t, entries[0].Entry.Breaking, true, "Breaking should be updated") + testutils.Expect.Equal(t, entries[0].Entry.CommitHash, "abc123", "CommitHash should be preserved") +} + +func TestUnreleasedReviewWorkflow_DeleteAndEdit(t *testing.T) { + tmpDir := t.TempDir() + changesDir := filepath.Join(tmpDir, ".changes") + + entry1 := changeset.Entry{ + Type: "added", + Summary: "Entry to delete", + } + entry2 := changeset.Entry{ + Type: "fixed", + Summary: "Entry to edit", + } + entry3 := changeset.Entry{ + Type: "changed", + Summary: "Entry to keep", + } + + filePath1, err := changeset.Write(changesDir, entry1) + if err != nil { + t.Fatalf("Failed to create entry1: %v", err) + } + filePath2, err := changeset.Write(changesDir, entry2) + if err != nil { + t.Fatalf("Failed to create entry2: %v", err) + } + _, err = changeset.Write(changesDir, entry3) + if err != nil { + t.Fatalf("Failed to create entry3: %v", err) + } + + filename1 := filepath.Base(filePath1) + filename2 := filepath.Base(filePath2) + + err = changeset.Delete(changesDir, filename1) + if err != nil { + t.Fatalf("Delete action failed: %v", err) + } + + editedEntry := changeset.Entry{ + Type: "security", + Scope: "auth", + Summary: "Updated security fix", + } + err = changeset.Update(changesDir, filename2, editedEntry) + if err != nil { + t.Fatalf("Update action failed: %v", err) + } + + entries, err := changeset.List(changesDir) + if err != nil { + t.Fatalf("Failed to list entries: %v", err) + } + + testutils.Expect.Equal(t, len(entries), 2, "Should have 2 entries remaining") + + var found bool + for _, e := range entries { + if e.Entry.Type == "security" { + testutils.Expect.Equal(t, e.Entry.Scope, "auth") + testutils.Expect.Equal(t, e.Entry.Summary, "Updated security fix") + found = true + } + } + + if !found { + t.Error("Edited entry not found in results") + } + + if _, err := os.Stat(filePath1); !os.IsNotExist(err) { + t.Error("Deleted entry should not exist") + } +} + +func TestUnreleasedReviewWorkflow_EmptyChanges(t *testing.T) { + tmpDir := t.TempDir() + changesDir := filepath.Join(tmpDir, ".changes") + + if err := os.MkdirAll(changesDir, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + entries, err := changeset.List(changesDir) + if err != nil { + t.Fatalf("List should not error on empty directory: %v", err) + } + + testutils.Expect.Equal(t, len(entries), 0, "Should have no entries") +} diff --git a/go.mod b/go.mod index 7bb00d4..7f7d428 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,8 @@ require ( require github.com/goccy/go-yaml v1.18.0 +require github.com/atotto/clipboard v0.1.4 // indirect + require ( github.com/clipperhouse/displaywidth v0.4.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect diff --git a/go.sum b/go.sum index d108136..5397f19 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= diff --git a/internal/changeset/changeset.go b/internal/changeset/changeset.go index c133232..d9caa1a 100644 --- a/internal/changeset/changeset.go +++ b/internal/changeset/changeset.go @@ -402,3 +402,38 @@ func UpdateMetadata(dir string, diffHash string, newCommitHash string) error { } return nil } + +// Delete removes a changelog entry file from the .changes/ directory. +func Delete(dir, filename string) error { + filePath := filepath.Join(dir, filename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return fmt.Errorf("file %s does not exist", filename) + } + + if err := os.Remove(filePath); err != nil { + return fmt.Errorf("failed to delete file %s: %w", filename, err) + } + return nil +} + +// Update modifies an existing changelog entry file with new values. +func Update(dir, filename string, entry Entry) error { + filePath := filepath.Join(dir, filename) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return fmt.Errorf("file %s does not exist", filename) + } + + yamlBytes, err := yaml.Marshal(entry) + if err != nil { + return fmt.Errorf("failed to marshal entry to YAML: %w", err) + } + + content := fmt.Sprintf("---\n%s---\n", string(yamlBytes)) + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("failed to update file %s: %w", filename, err) + } + + return nil +} diff --git a/internal/changeset/changeset_test.go b/internal/changeset/changeset_test.go index 7989cfc..1c660e1 100644 --- a/internal/changeset/changeset_test.go +++ b/internal/changeset/changeset_test.go @@ -14,7 +14,6 @@ import ( func TestWrite(t *testing.T) { tmpDir := t.TempDir() - tests := []struct { name string entry Entry @@ -616,3 +615,160 @@ func TestWritePartial_DuplicateFilename(t *testing.T) { t.Errorf("Expected 'already exists' error, got: %v", err) } } + +func TestDelete(t *testing.T) { + tmpDir := t.TempDir() + + entry := Entry{ + Type: "added", + Scope: "test", + Summary: "Test deletion", + } + + filePath, err := Write(tmpDir, entry) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + filename := filepath.Base(filePath) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Fatalf("File should exist before deletion: %s", filePath) + } + + err = Delete(tmpDir, filename) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Errorf("File should not exist after deletion: %s", filePath) + } +} + +func TestDelete_NonExistentFile(t *testing.T) { + tmpDir := t.TempDir() + + err := Delete(tmpDir, "nonexistent.md") + if err == nil { + t.Error("Expected error when deleting non-existent file, got nil") + } + + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("Expected 'does not exist' error, got: %v", err) + } +} + +func TestUpdate(t *testing.T) { + tmpDir := t.TempDir() + + originalEntry := Entry{ + Type: "added", + Scope: "cli", + Summary: "Original summary", + } + + filePath, err := Write(tmpDir, originalEntry) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + filename := filepath.Base(filePath) + + updatedEntry := Entry{ + Type: "changed", + Scope: "api", + Summary: "Updated summary", + } + + err = Update(tmpDir, filename, updatedEntry) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + + parts := strings.SplitN(string(content), "---\n", 3) + if len(parts) < 3 { + t.Fatal("Invalid YAML frontmatter format") + } + + var parsed Entry + if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + testutils.Expect.Equal(t, parsed.Type, updatedEntry.Type, "Type should be updated") + testutils.Expect.Equal(t, parsed.Scope, updatedEntry.Scope, "Scope should be updated") + testutils.Expect.Equal(t, parsed.Summary, updatedEntry.Summary, "Summary should be updated") +} + +func TestUpdate_NonExistentFile(t *testing.T) { + tmpDir := t.TempDir() + + entry := Entry{ + Type: "added", + Summary: "Test", + } + + err := Update(tmpDir, "nonexistent.md", entry) + if err == nil { + t.Error("Expected error when updating non-existent file, got nil") + } + + if !strings.Contains(err.Error(), "does not exist") { + t.Errorf("Expected 'does not exist' error, got: %v", err) + } +} + +func TestUpdate_PreserveMetadata(t *testing.T) { + tmpDir := t.TempDir() + + originalEntry := Entry{ + Type: "added", + Scope: "cli", + Summary: "Original", + Breaking: false, + CommitHash: "abc123", + DiffHash: "def456", + } + + filePath, err := Write(tmpDir, originalEntry) + if err != nil { + t.Fatalf("Write() error = %v", err) + } + + filename := filepath.Base(filePath) + + updatedEntry := Entry{ + Type: "changed", + Scope: "api", + Summary: "Updated", + Breaking: true, + CommitHash: "abc123", + DiffHash: "def456", + } + + err = Update(tmpDir, filename, updatedEntry) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read updated file: %v", err) + } + + parts := strings.SplitN(string(content), "---\n", 3) + var parsed Entry + if err := yaml.Unmarshal([]byte(parts[1]), &parsed); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + testutils.Expect.Equal(t, parsed.CommitHash, updatedEntry.CommitHash, "CommitHash should be preserved") + testutils.Expect.Equal(t, parsed.DiffHash, updatedEntry.DiffHash, "DiffHash should be preserved") + testutils.Expect.Equal(t, parsed.Breaking, updatedEntry.Breaking, "Breaking should be updated") +} diff --git a/internal/docs/README.md b/internal/docs/README.md index 0884cb4..6d79e46 100644 --- a/internal/docs/README.md +++ b/internal/docs/README.md @@ -1,7 +1,7 @@ --- title: Testing Workflow -updated: 2025-01-08 -version: 1 +updated: 2025-11-08 +version: 2 --- "Ride the lightning." @@ -256,14 +256,77 @@ storm unreleased review - Launches TUI with list of entries - Shows entry details on selection - Keyboard navigation works (j/k or arrows) -- Can mark for delete/edit (not yet implemented) -- Exit with q or ESC +- Can mark entries with actions: + - Press `x` to mark for deletion + - Press `e` to mark for editing + - Press `space` to keep (undo marks) +- Action indicators shown: [✓] keep, [✗] delete, [✎] edit +- Footer shows action counts +- Exit with q or ESC to cancel, Enter to confirm + +#### Deleting entries + +```bash +storm unreleased review +# Press 'x' on unwanted entries, then Enter to confirm +``` + +**Expected:** + +- Entries marked with [✗] are deleted from `.changes/` +- Shows "Deleted: ``" for each removed entry +- Final count: "Review completed: N deleted, M edited" +- Files are permanently removed + +#### Editing entries + +```bash +storm unreleased review +# Press 'e' on an entry, then Enter to confirm +``` + +**Expected:** + +- Launches inline editor TUI for each marked entry +- Editor shows: + - Type (cycle with Ctrl+T through: added, changed, fixed, removed, security) + - Scope (text input field) + - Summary (text input field) + - Breaking change status +- Navigate fields with Tab/Shift+Tab +- Save with Enter or Ctrl+S +- Cancel with Esc (skips editing that entry) +- Shows "Updated: ``" for saved changes +- CommitHash and DiffHash preserved + +#### Review workflow + +```bash +# Full workflow: mark multiple actions +storm unreleased review +# 1. Navigate with j/k +# 2. Mark first entry with 'x' (delete) +# 3. Mark second entry with 'e' (edit) +# 4. Mark third entry with 'x' (delete) +# 5. Press Enter to confirm +``` + +**Expected:** + +- All delete actions processed first +- Then edit TUI launched for each edit action +- Can cancel individual edits with Esc +- Final summary shows both delete and edit counts +- If no actions marked, shows "No changes requested" **Edge Cases:** - Empty changes directory (should show message, not crash) - Corrupted entry file (should handle gracefully) - Non-TTY environment (should detect and warn) +- Cancel review (Esc/q) - no changes applied +- Delete file that no longer exists (should error gracefully) +- Edit with empty fields (fields preserve original if empty) ### CI Validation (`check`) diff --git a/internal/docs/e2e/README.md b/internal/docs/e2e/README.md index b384232..97a79c2 100644 --- a/internal/docs/e2e/README.md +++ b/internal/docs/e2e/README.md @@ -1,7 +1,7 @@ --- title: Integration Testing Scenarios updated: 2025-11-08 -version: 1 +version: 2 --- ## Feature Branch @@ -32,8 +32,12 @@ storm unreleased list # 1. Generate from last release storm generate --since v1.0.0 -# 2. Review what was generated +# 2. Review and clean up entries storm unreleased review +# Navigate with j/k +# Press 'x' to mark duplicates/mistakes for deletion +# Press 'e' to fix typos or categorization +# Press Enter to apply changes # 3. Add manual entry for non-code change storm unreleased add --type changed --summary "Updated documentation" @@ -51,6 +55,33 @@ cat CHANGELOG.md # Expected: Clean CHANGELOG, annotated tag, empty .changes/ ``` +## Entry Cleanup Workflow + +```bash +# 1. Create some test entries with issues +storm unreleased add --type added --summary "Test entry 1" +storm unreleased add --type fixed --summary "Wrong category entry" +storm unreleased add --type added --summary "Duplicate test entry" +storm unreleased add --type added --summary "Duplicate test entry" + +# 2. Review and fix +storm unreleased review +# - Mark duplicate for deletion with 'x' +# - Mark wrong category entry for edit with 'e' +# - Press Enter to confirm + +# 3. In editor TUI for marked entry: +# - Press Ctrl+T to cycle type from 'fixed' to 'changed' +# - Tab to scope field, enter "docs" +# - Tab to summary field, update text +# - Press Enter to save + +# 4. Verify changes +storm unreleased list + +# Expected: Only 2 entries remain, edited entry has correct type and scope +``` + ## CI Pipeline Validation ```bash diff --git a/internal/style/style.go b/internal/style/style.go index 5275273..2b80938 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -58,6 +58,18 @@ func Addedf(format string, args ...any) { fmt.Println(v) } +func Successf(format string, args ...any) { + s := fmt.Sprintf(format, args...) + v := StyleAdded.Render(s) + fmt.Println(v) +} + +func Warningf(format string, args ...any) { + s := fmt.Sprintf(format, args...) + v := StyleSecurity.Render(s) + fmt.Println(v) +} + func Newline() { fmt.Println() } func Fixed(s string) { diff --git a/internal/ui/entry_editor.go b/internal/ui/entry_editor.go new file mode 100644 index 0000000..feba891 --- /dev/null +++ b/internal/ui/entry_editor.go @@ -0,0 +1,213 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/stormlightlabs/git-storm/internal/changeset" + "github.com/stormlightlabs/git-storm/internal/style" +) + +// EntryEditorModel holds the state for the inline entry editor TUI. +type EntryEditorModel struct { + entry changeset.Entry + filename string + inputs []textinput.Model + focusIdx int + typeIdx int // index in validTypes array + confirmed bool + cancelled bool + width int + height int +} + +// validTypes defines the allowed changelog entry types. +var validTypes = []string{"added", "changed", "fixed", "removed", "security"} + +// editorKeyMap defines keyboard shortcuts for the entry editor. +type editorKeyMap struct { + Next key.Binding + Prev key.Binding + Confirm key.Binding + Quit key.Binding + CycleType key.Binding +} + +var editorKeys = editorKeyMap{ + Next: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next field"), + ), + Prev: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev field"), + ), + Confirm: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "save"), + ), + Quit: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + CycleType: key.NewBinding( + key.WithKeys("ctrl+t"), + key.WithHelp("ctrl+t", "cycle type"), + ), +} + +// NewEntryEditorModel creates a new editor initialized with the given entry. +func NewEntryEditorModel(entry changeset.EntryWithFile) EntryEditorModel { + m := EntryEditorModel{ + entry: entry.Entry, + filename: entry.Filename, + inputs: make([]textinput.Model, 2), + } + + for i, t := range validTypes { + if t == entry.Entry.Type { + m.typeIdx = i + break + } + } + + m.inputs[0] = textinput.New() + m.inputs[0].Placeholder = "optional scope (e.g., cli, api)" + m.inputs[0].SetValue(entry.Entry.Scope) + m.inputs[0].CharLimit = 50 + m.inputs[0].Width = 50 + + m.inputs[1] = textinput.New() + m.inputs[1].Placeholder = "brief description of the change" + m.inputs[1].SetValue(entry.Entry.Summary) + m.inputs[1].CharLimit = 200 + m.inputs[1].Width = 80 + + m.inputs[0].Focus() + return m +} + +// Init implements tea.Model. +func (m EntryEditorModel) Init() tea.Cmd { + return textinput.Blink +} + +// Update implements tea.Model. +func (m EntryEditorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, editorKeys.Quit): + m.cancelled = true + return m, tea.Quit + case key.Matches(msg, editorKeys.Confirm): + m.confirmed = true + return m, tea.Quit + case key.Matches(msg, editorKeys.CycleType): + m.typeIdx = (m.typeIdx + 1) % len(validTypes) + return m, nil + case key.Matches(msg, editorKeys.Next): + m.nextField() + return m, nil + case key.Matches(msg, editorKeys.Prev): + m.prevField() + return m, nil + case msg.String() == "enter": + m.confirmed = true + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + } + cmd := m.updateInputs(msg) + return m, cmd +} + +// View implements tea.Model. +func (m EntryEditorModel) View() string { + if m.width == 0 { + return "Loading..." + } + + var b strings.Builder + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(style.AccentBlue). + Render(fmt.Sprintf("Editing: %s", m.filename)) + b.WriteString(title) + b.WriteString("\n\n") + + typeLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Type:") + typeValue := getCategoryStyle(validTypes[m.typeIdx]).Render(validTypes[m.typeIdx]) + b.WriteString(fmt.Sprintf("%s %s (ctrl+t to cycle)\n", typeLabel, typeValue)) + + scopeLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Scope:") + b.WriteString(fmt.Sprintf("\n%s\n%s\n", scopeLabel, m.inputs[0].View())) + + summaryLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Summary:") + b.WriteString(fmt.Sprintf("\n%s\n%s\n", summaryLabel, m.inputs[1].View())) + + breakingLabel := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render("Breaking:") + breakingValue := "no" + if m.entry.Breaking { + breakingValue = style.StyleRemoved.Render("yes") + } + b.WriteString(fmt.Sprintf("\n%s %s\n", breakingLabel, breakingValue)) + + b.WriteString("\n") + helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) + b.WriteString(helpStyle.Render("tab: next • shift+tab: prev • ctrl+t: cycle type • enter/ctrl+s: save • esc: cancel")) + return b.String() +} + +// GetEditedEntry returns the entry with updated values. +func (m EntryEditorModel) GetEditedEntry() changeset.Entry { + return changeset.Entry{ + Type: validTypes[m.typeIdx], + Scope: strings.TrimSpace(m.inputs[0].Value()), + Summary: strings.TrimSpace(m.inputs[1].Value()), + Breaking: m.entry.Breaking, + CommitHash: m.entry.CommitHash, + DiffHash: m.entry.DiffHash, + } +} + +// IsConfirmed returns true if the user confirmed the edit. +func (m EntryEditorModel) IsConfirmed() bool { + return m.confirmed +} + +// IsCancelled returns true if the user cancelled the edit. +func (m EntryEditorModel) IsCancelled() bool { + return m.cancelled +} + +// nextField moves focus to the next input field. +func (m *EntryEditorModel) nextField() { + m.inputs[m.focusIdx].Blur() + m.focusIdx = (m.focusIdx + 1) % len(m.inputs) + m.inputs[m.focusIdx].Focus() +} + +// prevField moves focus to the previous input field. +func (m *EntryEditorModel) prevField() { + m.inputs[m.focusIdx].Blur() + m.focusIdx-- + if m.focusIdx < 0 { + m.focusIdx = len(m.inputs) - 1 + } + m.inputs[m.focusIdx].Focus() +} + +// updateInputs handles updates for text input fields. +func (m *EntryEditorModel) updateInputs(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg) + return cmd +} diff --git a/internal/ui/entry_editor_test.go b/internal/ui/entry_editor_test.go new file mode 100644 index 0000000..3da39ad --- /dev/null +++ b/internal/ui/entry_editor_test.go @@ -0,0 +1,312 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stormlightlabs/git-storm/internal/changeset" +) + +func TestEntryEditorModel_Init(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + cmd := model.Init() + if cmd == nil { + t.Error("Init() should return textinput.Blink command") + } +} + +func TestEntryEditorModel_DefaultState(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + if model.confirmed { + t.Error("Model should not be confirmed initially") + } + + if model.cancelled { + t.Error("Model should not be cancelled initially") + } + + if model.focusIdx != 0 { + t.Errorf("Focus should be on first input, got %d", model.focusIdx) + } + + if model.typeIdx != 0 { + t.Errorf("Type index should be 0 for 'added', got %d", model.typeIdx) + } +} + +func TestEntryEditorModel_TypeCycling(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + initialType := model.typeIdx + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyCtrlT}) + model = updated.(EntryEditorModel) + + if model.typeIdx == initialType { + t.Error("Type should have cycled to next value") + } + + expectedNext := (initialType + 1) % len(validTypes) + if model.typeIdx != expectedNext { + t.Errorf("Type index should be %d, got %d", expectedNext, model.typeIdx) + } +} + +func TestEntryEditorModel_Confirm(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(EntryEditorModel) + + if !model.confirmed { + t.Error("Model should be confirmed after pressing Enter") + } + + if cmd == nil { + t.Error("Confirm should return tea.Quit command") + } +} + +func TestEntryEditorModel_ConfirmWithCtrlS(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + model = updated.(EntryEditorModel) + + if !model.confirmed { + t.Error("Model should be confirmed after pressing Ctrl+S") + } + + if cmd == nil { + t.Error("Confirm should return tea.Quit command") + } +} + +func TestEntryEditorModel_Cancel(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + updated, cmd := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = updated.(EntryEditorModel) + + if !model.cancelled { + t.Error("Model should be cancelled after pressing Esc") + } + + if cmd == nil { + t.Error("Cancel should return tea.Quit command") + } +} + +func TestEntryEditorModel_FieldNavigation(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + if model.focusIdx != 0 { + t.Fatalf("Initial focus should be on field 0, got %d", model.focusIdx) + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyTab}) + model = updated.(EntryEditorModel) + + if model.focusIdx != 1 { + t.Errorf("Focus should move to field 1 after Tab, got %d", model.focusIdx) + } + + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + model = updated.(EntryEditorModel) + + if model.focusIdx != 0 { + t.Errorf("Focus should move back to field 0 after Shift+Tab, got %d", model.focusIdx) + } +} + +func TestEntryEditorModel_GetEditedEntry(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: "added", + Scope: "cli", + Summary: "Test entry", + Breaking: false, + CommitHash: "abc123", + DiffHash: "def456", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + model.typeIdx = 1 + + editedEntry := model.GetEditedEntry() + + if editedEntry.Type != validTypes[1] { + t.Errorf("Expected type %s, got %s", validTypes[1], editedEntry.Type) + } + + if editedEntry.CommitHash != entry.Entry.CommitHash { + t.Error("CommitHash should be preserved") + } + + if editedEntry.DiffHash != entry.Entry.DiffHash { + t.Error("DiffHash should be preserved") + } +} + +func TestEntryEditorModel_IsConfirmed(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{Type: "added", Summary: "Test"}, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + if model.IsConfirmed() { + t.Error("Model should not be confirmed initially") + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(EntryEditorModel) + + if !model.IsConfirmed() { + t.Error("Model should be confirmed after Enter key") + } +} + +func TestEntryEditorModel_IsCancelled(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{Type: "added", Summary: "Test"}, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + if model.IsCancelled() { + t.Error("Model should not be cancelled initially") + } + + updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyEsc}) + model = updated.(EntryEditorModel) + + if !model.IsCancelled() { + t.Error("Model should be cancelled after Esc key") + } +} + +func TestEntryEditorModel_WindowSize(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{Type: "added", Summary: "Test"}, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + if model.width != 0 || model.height != 0 { + t.Error("Initial window size should be 0") + } + + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = updated.(EntryEditorModel) + + if model.width != 100 { + t.Errorf("Width should be 100, got %d", model.width) + } + + if model.height != 30 { + t.Errorf("Height should be 30, got %d", model.height) + } +} + +func TestEntryEditorModel_TypeIndexForDifferentTypes(t *testing.T) { + tests := []struct { + entryType string + expectedIndex int + }{ + {"added", 0}, + {"changed", 1}, + {"fixed", 2}, + {"removed", 3}, + {"security", 4}, + } + + for _, tt := range tests { + t.Run(tt.entryType, func(t *testing.T) { + entry := changeset.EntryWithFile{ + Entry: changeset.Entry{ + Type: tt.entryType, + Summary: "Test entry", + }, + Filename: "test.md", + } + + model := NewEntryEditorModel(entry) + + if model.typeIdx != tt.expectedIndex { + t.Errorf("Type index for %s should be %d, got %d", tt.entryType, tt.expectedIndex, model.typeIdx) + } + }) + } +}