changelog generator & diff tool stormlightlabs.github.io/git-storm/
changelog changeset markdown golang git
at main 5.8 kB view raw
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}