From 891a57a9bbc09e96f4713ed2299f32e9140376e0 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 4 Nov 2025 06:44:15 -0600 Subject: [PATCH] feat(generate): add interactive commit selector behind -i/interactive flag --- README.md | 10 +- cmd/generate.go | 97 ++++-- internal/ui/commit_selector.go | 440 +++++++++++++++++++++++++ internal/ui/commit_selector_test.go | 477 ++++++++++++++++++++++++++++ 4 files changed, 988 insertions(+), 36 deletions(-) create mode 100644 internal/ui/commit_selector.go create mode 100644 internal/ui/commit_selector_test.go diff --git a/README.md b/README.md index 75c0ff4..d4bcb65 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ │ ├── diff # Minimal line diff for display and review │ ├── changeset # Manage `.changes/*.md` files │ ├── changelog # Build and update `CHANGELOG.md` sections -│ ├── tui # Bubble Tea–based interactive interface +│ ├── ui # Bubble Tea–based interactive interface │ └── style # Centralized Lip Gloss palette and formatting ├── PROJECT.md └── README.md @@ -75,13 +75,7 @@ Merges `.changes/*.md` into the changelog, writes a new section, and optionally 3. Consistent Palette - | Type | Color | - | -------- | --------- | - | Added | `#10b981` | - | Changed | `#0ea5e9` | - | Fixed | `#f43f5e` | - | Removed | `#f59e0b` | - | Security | `#9333ea` | + See package style for the color palette. 4. Commands should chain naturally and script cleanly: diff --git a/cmd/generate.go b/cmd/generate.go index 2f10ccb..83e21d0 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -16,11 +16,13 @@ import ( "fmt" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/go-git/go-git/v6" "github.com/spf13/cobra" "github.com/stormlightlabs/git-storm/internal/changeset" "github.com/stormlightlabs/git-storm/internal/gitlog" "github.com/stormlightlabs/git-storm/internal/style" + "github.com/stormlightlabs/git-storm/internal/ui" ) var ( @@ -52,11 +54,6 @@ interactive review mode.`, from, to = gitlog.ParseRefArgs(args) } - if interactive { - style.Headline("Interactive mode not yet implemented") - fmt.Println("Will generate entries in non-interactive mode...") - } - repo, err := git.PlainOpen(repoPath) if err != nil { return fmt.Errorf("failed to open repository: %w", err) @@ -72,40 +69,84 @@ interactive review mode.`, return nil } - style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to)) - parser := &gitlog.ConventionalParser{} - entries := []changeset.Entry{} - skipped := 0 + var selectedItems []ui.CommitItem - for _, commit := range commits { - subject := commit.Message - body := "" - lines := strings.Split(commit.Message, "\n") - if len(lines) > 0 { - subject = lines[0] - if len(lines) > 1 { - body = strings.Join(lines[1:], "\n") - } - } + if interactive { + model := ui.NewCommitSelectorModel(commits, from, to, parser) + p := tea.NewProgram(model, tea.WithAltScreen()) - meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) + finalModel, err := p.Run() if err != nil { - fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err) - continue + return fmt.Errorf("failed to run interactive selector: %w", err) + } + + selectorModel, ok := finalModel.(ui.CommitSelectorModel) + if !ok { + return fmt.Errorf("unexpected model type") + } + + if selectorModel.IsCancelled() { + style.Headline("Operation cancelled") + return nil + } + + selectedItems = selectorModel.GetSelectedItems() + + if len(selectedItems) == 0 { + style.Headline("No commits selected") + return nil } - category := parser.Categorize(meta) - if category == "" { + style.Headline(fmt.Sprintf("Generating entries for %d selected commits", len(selectedItems))) + } else { + style.Headline(fmt.Sprintf("Found %d commits between %s and %s", len(commits), from, to)) + + for _, commit := range commits { + subject := commit.Message + body := "" + lines := strings.Split(commit.Message, "\n") + if len(lines) > 0 { + subject = lines[0] + if len(lines) > 1 { + body = strings.Join(lines[1:], "\n") + } + } + + meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) + if err != nil { + fmt.Printf("Warning: failed to parse commit %s: %v\n", commit.Hash.String()[:7], err) + continue + } + + category := parser.Categorize(meta) + if category == "" { + continue + } + + selectedItems = append(selectedItems, ui.CommitItem{ + Commit: commit, + Meta: meta, + Category: category, + Selected: true, + }) + } + } + + entries := []changeset.Entry{} + skipped := 0 + + for _, item := range selectedItems { + if item.Category == "" { skipped++ continue } entry := changeset.Entry{ - Type: category, - Scope: meta.Scope, - Summary: meta.Description, - Breaking: meta.Breaking, + Type: item.Category, + Scope: item.Meta.Scope, + Summary: item.Meta.Description, + Breaking: item.Meta.Breaking, } entries = append(entries, entry) diff --git a/internal/ui/commit_selector.go b/internal/ui/commit_selector.go new file mode 100644 index 0000000..893c886 --- /dev/null +++ b/internal/ui/commit_selector.go @@ -0,0 +1,440 @@ +package ui + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/stormlightlabs/git-storm/internal/gitlog" + "github.com/stormlightlabs/git-storm/internal/style" +) + +// CommitItem wraps a commit with its selection state and parsed metadata. +type CommitItem struct { + Commit *object.Commit + Meta gitlog.CommitMeta + Category string + Selected bool +} + +// CommitSelectorModel holds the state for the interactive commit selector TUI. +type CommitSelectorModel struct { + viewport viewport.Model + items []CommitItem + cursor int + ready bool + fromRef string + toRef string + width int + height int + confirmed bool + cancelled bool +} + +// commitSelectorKeyMap defines keyboard shortcuts for the commit selector. +type commitSelectorKeyMap struct { + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Top key.Binding + Bottom key.Binding + Toggle key.Binding + SelectAll key.Binding + DeselectAll key.Binding + Confirm key.Binding + Quit key.Binding +} + +var commitKeys = commitSelectorKeyMap{ + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "u"), + key.WithHelp("pgup/u", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "d"), + key.WithHelp("pgdn/d", "page down"), + ), + Top: key.NewBinding( + key.WithKeys("g", "home"), + key.WithHelp("g/home", "top"), + ), + Bottom: key.NewBinding( + key.WithKeys("G", "end"), + key.WithHelp("G/end", "bottom"), + ), + Toggle: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle"), + ), + SelectAll: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "select all"), + ), + DeselectAll: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "deselect all"), + ), + Confirm: key.NewBinding( + key.WithKeys("enter", "c"), + key.WithHelp("enter/c", "confirm"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +// NewCommitSelectorModel creates a new commit selector model. +func NewCommitSelectorModel(commits []*object.Commit, fromRef, toRef string, parser gitlog.CommitParser) CommitSelectorModel { + items := make([]CommitItem, 0, len(commits)) + + for _, commit := range commits { + subject := commit.Message + body := "" + lines := strings.Split(commit.Message, "\n") + if len(lines) > 0 { + subject = lines[0] + if len(lines) > 1 { + body = strings.Join(lines[1:], "\n") + } + } + + meta, err := parser.Parse(commit.Hash.String(), subject, body, commit.Author.When) + if err != nil { + meta = gitlog.CommitMeta{ + Type: "unknown", + Description: subject, + Body: body, + } + } + + category := parser.Categorize(meta) + + items = append(items, CommitItem{ + Commit: commit, + Meta: meta, + Category: category, + Selected: category != "", + }) + } + + return CommitSelectorModel{ + items: items, + cursor: 0, + fromRef: fromRef, + toRef: toRef, + ready: false, + } +} + +// Init initializes the model (required by Bubble Tea). +func (m CommitSelectorModel) Init() tea.Cmd { + return nil +} + +// Update handles messages and updates the model state. +func (m CommitSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, commitKeys.Quit): + m.cancelled = true + return m, tea.Quit + + case key.Matches(msg, commitKeys.Confirm): + m.confirmed = true + return m, tea.Quit + + case key.Matches(msg, commitKeys.Up): + if m.cursor > 0 { + m.cursor-- + m.ensureVisible() + } + + case key.Matches(msg, commitKeys.Down): + if m.cursor < len(m.items)-1 { + m.cursor++ + m.ensureVisible() + } + + case key.Matches(msg, commitKeys.PageUp): + m.cursor -= m.viewport.Height + if m.cursor < 0 { + m.cursor = 0 + } + m.ensureVisible() + + case key.Matches(msg, commitKeys.PageDown): + m.cursor += m.viewport.Height + if m.cursor >= len(m.items) { + m.cursor = len(m.items) - 1 + } + m.ensureVisible() + + case key.Matches(msg, commitKeys.Top): + m.cursor = 0 + m.ensureVisible() + + case key.Matches(msg, commitKeys.Bottom): + m.cursor = len(m.items) - 1 + m.ensureVisible() + + case key.Matches(msg, commitKeys.Toggle): + if m.cursor >= 0 && m.cursor < len(m.items) { + m.items[m.cursor].Selected = !m.items[m.cursor].Selected + m.updateContent() + } + + case key.Matches(msg, commitKeys.SelectAll): + for i := range m.items { + m.items[i].Selected = true + } + m.updateContent() + + case key.Matches(msg, commitKeys.DeselectAll): + for i := range m.items { + m.items[i].Selected = false + } + m.updateContent() + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height-4) + m.ready = true + m.updateContent() + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - 4 + m.updateContent() + } + } + + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +// View renders the current view of the commit selector. +func (m CommitSelectorModel) View() string { + if !m.ready { + return "\n Initializing..." + } + + header := m.renderCommitHeader() + footer := m.renderCommitFooter() + + return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer) +} + +// GetSelectedCommits returns the list of selected commits. +func (m CommitSelectorModel) GetSelectedCommits() []*object.Commit { + selected := make([]*object.Commit, 0) + for _, item := range m.items { + if item.Selected { + selected = append(selected, item.Commit) + } + } + return selected +} + +// GetSelectedItems returns the list of selected commit items with metadata. +func (m CommitSelectorModel) GetSelectedItems() []CommitItem { + selected := make([]CommitItem, 0) + for _, item := range m.items { + if item.Selected { + selected = append(selected, item) + } + } + return selected +} + +// IsCancelled returns true if the user quit without confirming. +func (m CommitSelectorModel) IsCancelled() bool { + return m.cancelled +} + +// IsConfirmed returns true if the user confirmed their selection. +func (m CommitSelectorModel) IsConfirmed() bool { + return m.confirmed +} + +// ensureVisible scrolls the viewport to keep the cursor visible. +func (m *CommitSelectorModel) ensureVisible() { + lineHeight := 1 + cursorY := m.cursor * lineHeight + + if cursorY < m.viewport.YOffset { + m.viewport.YOffset = cursorY + } else if cursorY >= m.viewport.YOffset+m.viewport.Height { + m.viewport.YOffset = cursorY - m.viewport.Height + 1 + } + + m.updateContent() +} + +// updateContent regenerates the viewport content. +func (m *CommitSelectorModel) updateContent() { + if !m.ready { + return + } + + var content strings.Builder + + for i, item := range m.items { + content.WriteString(m.renderCommitLine(i, item)) + content.WriteString("\n") + } + + m.viewport.SetContent(content.String()) +} + +// renderCommitLine renders a single commit line with selection state. +func (m CommitSelectorModel) renderCommitLine(index int, item CommitItem) string { + checkbox := "[ ]" + if item.Selected { + checkbox = "[✓]" + } + + shortHash := item.Commit.Hash.String()[:7] + subject := item.Meta.Description + if subject == "" { + subject = strings.Split(item.Commit.Message, "\n")[0] + } + + maxSubjectLen := max(m.width-60, 20) + if len(subject) > maxSubjectLen { + subject = subject[:maxSubjectLen-3] + "..." + } + + author := item.Commit.Author.Name + if len(author) > 15 { + author = author[:12] + "..." + } + + timeAgo := fmtTimeAgo(item.Commit.Author.When) + + category := item.Category + if category == "" { + category = "skip" + } + + categoryStyle := getCategoryStyle(category) + lineStyle := lipgloss.NewStyle() + checkboxStyle := lipgloss.NewStyle().Foreground(style.AccentBlue) + hashStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) + + if index == m.cursor { + lineStyle = lineStyle.Background(lipgloss.Color("#1f2428")) + checkboxStyle = checkboxStyle.Bold(true) + } + + line := fmt.Sprintf("%s %s %s %s %s %s", + checkboxStyle.Render(checkbox), + hashStyle.Render(shortHash), + categoryStyle.Render(fmt.Sprintf("%-8s", category)), + subject, + lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Render(author), + lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true).Render(timeAgo), + ) + + return lineStyle.Render(line) +} + +// renderCommitHeader creates the header showing the range. +func (m CommitSelectorModel) renderCommitHeader() string { + headerStyle := lipgloss.NewStyle(). + Foreground(style.AccentBlue). + Bold(true). + Padding(0, 1) + + return headerStyle.Render( + fmt.Sprintf("Select commits to include (%s..%s)", m.fromRef, m.toRef), + ) +} + +// renderCommitFooter creates the footer with help text and selection count. +func (m CommitSelectorModel) renderCommitFooter() string { + footerStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6C7A89")). + Faint(true). + Padding(0, 1) + + selectedCount := 0 + for _, item := range m.items { + if item.Selected { + selectedCount++ + } + } + + helpText := "↑/↓: navigate • space: toggle • a/A: select/deselect all • enter: confirm • q: quit" + selectionInfo := fmt.Sprintf("%d/%d selected", selectedCount, len(m.items)) + + totalWidth := m.width + helpWidth := lipgloss.Width(helpText) + selWidth := lipgloss.Width(selectionInfo) + padding := max(totalWidth-helpWidth-selWidth-2, 0) + + return footerStyle.Render( + helpText + strings.Repeat(" ", padding) + selectionInfo, + ) +} + +// fmtTimeAgo returns a human-readable relative time string. +func fmtTimeAgo(t time.Time) string { + duration := time.Since(t) + + if duration < time.Minute { + return "just now" + } else if duration < time.Hour { + minutes := int(duration.Minutes()) + return fmt.Sprintf("%dm ago", minutes) + } else if duration < 24*time.Hour { + hours := int(duration.Hours()) + return fmt.Sprintf("%dh ago", hours) + } else if duration < 30*24*time.Hour { + days := int(duration.Hours() / 24) + return fmt.Sprintf("%dd ago", days) + } else if duration < 365*24*time.Hour { + months := int(duration.Hours() / 24 / 30) + return fmt.Sprintf("%dmo ago", months) + } else { + years := int(duration.Hours() / 24 / 365) + return fmt.Sprintf("%dy ago", years) + } +} + +func getCategoryStyle(c string) lipgloss.Style { + s := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) + switch c { + case "added": + s = lipgloss.NewStyle().Foreground(style.AddedColor) + case "changed": + s = lipgloss.NewStyle().Foreground(style.ChangedColor) + case "fixed": + s = lipgloss.NewStyle().Foreground(style.AccentBlue) + case "removed": + s = lipgloss.NewStyle().Foreground(style.RemovedColor) + case "security": + s = lipgloss.NewStyle().Foreground(lipgloss.Color("#BF616A")) + } + return s +} diff --git a/internal/ui/commit_selector_test.go b/internal/ui/commit_selector_test.go new file mode 100644 index 0000000..aabed2e --- /dev/null +++ b/internal/ui/commit_selector_test.go @@ -0,0 +1,477 @@ +package ui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/stormlightlabs/git-storm/internal/gitlog" +) + +type mockParser struct{} + +func (p *mockParser) Parse(hash, subject, body string, date time.Time) (gitlog.CommitMeta, error) { + meta := gitlog.CommitMeta{ + Type: "feat", + Scope: "", + Description: subject, + Body: body, + Breaking: false, + Footers: make(map[string]string), + } + return meta, nil +} + +func (p *mockParser) IsValidType(kind gitlog.CommitKind) bool { + return kind != gitlog.CommitTypeUnknown +} + +func (p *mockParser) Categorize(meta gitlog.CommitMeta) string { + switch meta.Type { + case "feat": + return "added" + case "fix": + return "fixed" + default: + return "changed" + } +} + +func createMockCommit(hash, message string, when time.Time) *object.Commit { + return &object.Commit{ + Hash: plumbing.NewHash(hash), + Message: message, + Author: object.Signature{ + Name: "Test Author", + Email: "test@example.com", + When: when, + }, + } +} + +func TestCommitSelectorModel_Init(t *testing.T) { + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", time.Now()), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + cmd := model.Init() + if cmd != nil { + t.Errorf("Init() should return nil, got %v", cmd) + } +} + +func TestCommitSelectorModel_AutoSelect(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "fix: bug fix", now), + } + + parser := &gitlog.ConventionalParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + selectedItems := model.GetSelectedItems() + if len(selectedItems) != 2 { + t.Errorf("Expected 2 auto-selected items, got %d", len(selectedItems)) + } +} + +func TestCommitSelectorModel_GetSelectedCommits(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: feature 1", now), + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: feature 2", now), + createMockCommit("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "feat: feature 3", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + model.items[1].Selected = false + + selected := model.GetSelectedCommits() + if len(selected) != 2 { + t.Errorf("Expected 2 selected commits, got %d", len(selected)) + } + + if selected[0].Hash != commits[0].Hash { + t.Error("First selected commit should match first commit") + } + if selected[1].Hash != commits[2].Hash { + t.Error("Second selected commit should match third commit") + } +} + +func TestCommitSelectorModel_ToggleSelection(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + initialSelected := model.items[0].Selected + + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = updatedModel.(CommitSelectorModel) + + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updatedModel.(CommitSelectorModel) + + if model.items[0].Selected == initialSelected { + t.Error("Selection should have been toggled") + } +} + +func TestCommitSelectorModel_SelectAll(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), + createMockCommit("c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "feat: test 3", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + for i := range model.items { + model.items[i].Selected = false + } + + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = updatedModel.(CommitSelectorModel) + + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}}) + model = updatedModel.(CommitSelectorModel) + + for i, item := range model.items { + if !item.Selected { + t.Errorf("Item %d should be selected", i) + } + } +} + +func TestCommitSelectorModel_DeselectAll(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = updatedModel.(CommitSelectorModel) + + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'A'}}) + model = updatedModel.(CommitSelectorModel) + + for i, item := range model.items { + if item.Selected { + t.Errorf("Item %d should be deselected", i) + } + } +} + +func TestCommitSelectorModel_Navigation(t *testing.T) { + now := time.Now() + commits := make([]*object.Commit, 50) + for i := range commits { + hash := strings.Repeat("a", 40) + commits[i] = createMockCommit(hash, "feat: test", now) + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 20)) + + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyUp}) + tm.Send(tea.KeyMsg{Type: tea.KeyPgDown}) + tm.Send(tea.KeyMsg{Type: tea.KeyPgUp}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) +} + +func TestCommitSelectorModel_TopBottom(t *testing.T) { + now := time.Now() + commits := make([]*object.Commit, 20) + for i := range commits { + hash := strings.Repeat("a", 40) + commits[i] = createMockCommit(hash, "feat: test", now) + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + updatedModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) + model = updatedModel.(CommitSelectorModel) + + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + model = updatedModel.(CommitSelectorModel) + + if model.cursor != len(commits)-1 { + t.Errorf("Cursor should be at bottom (index %d), got %d", len(commits)-1, model.cursor) + } + + updatedModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'g'}}) + model = updatedModel.(CommitSelectorModel) + + if model.cursor != 0 { + t.Errorf("Cursor should be at top (index 0), got %d", model.cursor) + } +} + +func TestCommitSelectorModel_Confirm(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + tm := teatest.NewTestModel(t, model) + + tm.Send(tea.KeyMsg{Type: tea.KeyEnter}) + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) + + finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) + selectorModel, ok := finalModel.(CommitSelectorModel) + if !ok { + t.Fatal("Expected CommitSelectorModel") + } + + if !selectorModel.IsConfirmed() { + t.Error("Model should be confirmed after pressing enter") + } + if selectorModel.IsCancelled() { + t.Error("Model should not be cancelled") + } +} + +func TestCommitSelectorModel_QuitKeys(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), + } + + quitKeys := []struct { + name string + keyType tea.KeyType + runes []rune + }{ + {"q", tea.KeyRunes, []rune{'q'}}, + {"esc", tea.KeyEsc, nil}, + {"ctrl+c", tea.KeyCtrlC, nil}, + } + + for _, tc := range quitKeys { + t.Run(tc.name, func(t *testing.T) { + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + tm := teatest.NewTestModel(t, model) + + var msg tea.Msg + if tc.runes != nil { + msg = tea.KeyMsg{Type: tc.keyType, Runes: tc.runes} + } else { + msg = tea.KeyMsg{Type: tc.keyType} + } + + tm.Send(msg) + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) + + finalModel := tm.FinalModel(t, teatest.WithFinalTimeout(time.Second)) + selectorModel, ok := finalModel.(CommitSelectorModel) + if !ok { + t.Fatal("Expected CommitSelectorModel") + } + + if !selectorModel.IsCancelled() { + t.Error("Model should be cancelled after quit key") + } + if selectorModel.IsConfirmed() { + t.Error("Model should not be confirmed") + } + }) + } +} + +func TestCommitSelectorModel_View(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + updated, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + model = updated.(CommitSelectorModel) + + view := model.View() + + if !strings.Contains(view, "v1.0.0") { + t.Error("View should contain fromRef") + } + if !strings.Contains(view, "HEAD") { + t.Error("View should contain toRef") + } + if !strings.Contains(view, "a1b2c3d") { + t.Error("View should contain commit hash") + } +} + +func TestCommitSelectorModel_RenderHeader(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + model.width = 100 + + header := model.renderCommitHeader() + + if !strings.Contains(header, "v1.0.0") { + t.Error("Header should contain fromRef") + } + if !strings.Contains(header, "HEAD") { + t.Error("Header should contain toRef") + } + if !strings.Contains(header, "Select commits") { + t.Error("Header should contain instruction text") + } +} + +func TestCommitSelectorModel_RenderFooter(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test 1", now), + createMockCommit("b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "feat: test 2", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + model.width = 100 + + footer := model.renderCommitFooter() + + if !strings.Contains(footer, "navigate") { + t.Error("Footer should contain navigation help") + } + if !strings.Contains(footer, "toggle") { + t.Error("Footer should contain toggle help") + } + if !strings.Contains(footer, "confirm") { + t.Error("Footer should contain confirm help") + } + if !strings.Contains(footer, "selected") { + t.Error("Footer should contain selection count") + } +} + +func TestCommitSelectorModel_RenderCommitLine(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: add feature", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + model.width = 100 + + line := model.renderCommitLine(0, model.items[0]) + + if !strings.Contains(line, "[") && !strings.Contains(line, "]") { + t.Error("Line should contain checkbox") + } + if !strings.Contains(line, "a1b2c3d") { + t.Error("Line should contain short commit hash") + } + if !strings.Contains(line, "added") { + t.Error("Line should contain category") + } +} + +func TestFormatTimeAgo(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + time time.Time + expected string + }{ + {"just now", now, "just now"}, + {"minutes ago", now.Add(-5 * time.Minute), "5m ago"}, + {"hours ago", now.Add(-2 * time.Hour), "2h ago"}, + {"days ago", now.Add(-3 * 24 * time.Hour), "3d ago"}, + {"months ago", now.Add(-45 * 24 * time.Hour), "1mo ago"}, + {"years ago", now.Add(-400 * 24 * time.Hour), "1y ago"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := fmtTimeAgo(tc.time) + if result != tc.expected { + t.Errorf("Expected %q, got %q", tc.expected, result) + } + }) + } +} + +func TestCommitSelectorModel_EmptyCommits(t *testing.T) { + commits := []*object.Commit{} + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + if len(model.items) != 0 { + t.Errorf("Expected 0 items, got %d", len(model.items)) + } + + selected := model.GetSelectedCommits() + if len(selected) != 0 { + t.Errorf("Expected 0 selected commits, got %d", len(selected)) + } +} + +func TestCommitSelectorModel_WindowResize(t *testing.T) { + now := time.Now() + commits := []*object.Commit{ + createMockCommit("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "feat: test", now), + } + + parser := &mockParser{} + model := NewCommitSelectorModel(commits, "v1.0.0", "HEAD", parser) + + updated, _ := model.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + model = updated.(CommitSelectorModel) + + if model.width != 80 || model.height != 24 { + t.Errorf("Expected dimensions 80x24, got %dx%d", model.width, model.height) + } + if !model.ready { + t.Error("Model should be ready after window size message") + } + + updated, _ = model.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + model = updated.(CommitSelectorModel) + + if model.width != 120 || model.height != 40 { + t.Errorf("Expected dimensions 120x40, got %dx%d", model.width, model.height) + } +} -- 2.51.2