changelog generator & diff tool
stormlightlabs.github.io/git-storm/
changelog
changeset
markdown
golang
git
1package ui
2
3import (
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.
16type 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.
29var validTypes = []string{"added", "changed", "fixed", "removed", "security"}
30
31// editorKeyMap defines keyboard shortcuts for the entry editor.
32type editorKeyMap struct {
33 Next key.Binding
34 Prev key.Binding
35 Confirm key.Binding
36 Quit key.Binding
37 CycleType key.Binding
38}
39
40var 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.
64func 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.
95func (m EntryEditorModel) Init() tea.Cmd {
96 return textinput.Blink
97}
98
99// Update implements tea.Model.
100func (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.
132func (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.
170func (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.
182func (m EntryEditorModel) IsConfirmed() bool {
183 return m.confirmed
184}
185
186// IsCancelled returns true if the user cancelled the edit.
187func (m EntryEditorModel) IsCancelled() bool {
188 return m.cancelled
189}
190
191// nextField moves focus to the next input field.
192func (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.
199func (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.
209func (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}