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

chore: rename project

* basic side by side diffing

+2
.gitignore
··· 31 31 # .idea/ 32 32 # .vscode/ 33 33 .gocache/ 34 + .task/ 35 + tmp/
+5 -5
PROJECT.md
··· 125 125 ```ruby 126 126 class Gostorm < Formula 127 127 desc "Git-aware changelog manager with TUI review" 128 - homepage "https://github.com/stormlightlabs/storm" 128 + homepage "https://github.com/stormlightlabs/git-storm" 129 129 version "1.3.0" 130 - url "https://github.com/stormlightlabs/storm/archive/refs/tags/v1.3.0.tar.gz" 130 + url "https://github.com/stormlightlabs/git-storm/archive/refs/tags/v1.3.0.tar.gz" 131 131 sha256 "<insert_sha256_here>" 132 132 license "MIT" 133 133 ··· 173 173 ```powershell 174 174 $ErrorActionPreference = 'Stop' 175 175 $toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)" 176 - $url = 'https://github.com/stormlightlabs/storm/releases/download/v1.3.0/storm_1.3.0_windows_amd64.zip' 176 + $url = 'https://github.com/stormlightlabs/git-storm/releases/download/v1.3.0/storm_1.3.0_windows_amd64.zip' 177 177 178 178 Install-ChocolateyZipPackage 'storm' $url $toolsDir 179 179 ``` ··· 188 188 <authors>Owais Jamil</authors> 189 189 <description>Git-aware changelog manager with TUI review</description> 190 190 <licenseUrl>https://opensource.org/licenses/MIT</licenseUrl> 191 - <projectUrl>https://github.com/stormlightlabs/storm</projectUrl> 191 + <projectUrl>https://github.com/stormlightlabs/git-storm</projectUrl> 192 192 </metadata> 193 193 </package> 194 194 ``` ··· 210 210 pkgrel=1 211 211 pkgdesc="Git-aware changelog manager with TUI review" 212 212 arch=('x86_64') 213 - url="https://github.com/stormlightlabs/storm" 213 + url="https://github.com/stormlightlabs/git-storm" 214 214 license=('MIT') 215 215 depends=('git' 'go') 216 216 source=("$url/archive/refs/tags/v${pkgver}.tar.gz")
+95
Taskfile.yml
··· 1 + version: "3" 2 + 3 + vars: 4 + BINARY_NAME: storm 5 + BUILD_DIR: ./tmp 6 + MAIN_PATH: ./main.go 7 + VERSION: 8 + sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" 9 + 10 + tasks: 11 + default: 12 + desc: Show available tasks 13 + cmds: 14 + - task --list 15 + 16 + build: 17 + desc: Build the storm binary to ./tmp 18 + cmds: 19 + - mkdir -p {{.BUILD_DIR}} 20 + - go build -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PATH}} 21 + sources: 22 + - "**/*.go" 23 + - go.mod 24 + - go.sum 25 + generates: 26 + - "{{.BUILD_DIR}}/{{.BINARY_NAME}}" 27 + 28 + build:release: 29 + desc: Build optimized release binary 30 + cmds: 31 + - mkdir -p {{.BUILD_DIR}} 32 + - go build -ldflags="-s -w -X main.versionString={{.VERSION}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.MAIN_PATH}} 33 + 34 + install: 35 + desc: Install storm to $GOPATH/bin 36 + cmds: 37 + - go install {{.MAIN_PATH}} 38 + 39 + clean: 40 + desc: Remove build artifacts 41 + cmds: 42 + - rm -rf {{.BUILD_DIR}} 43 + - go clean 44 + 45 + test: 46 + desc: Run all tests with verbose output 47 + cmds: 48 + - go test -v ./... 49 + 50 + test:quiet: 51 + desc: Run all tests 52 + cmds: 53 + - go test ./... 54 + 55 + test:cover: 56 + desc: Run tests with coverage report 57 + cmds: 58 + - go test -coverprofile={{.BUILD_DIR}}/coverage.out ./... 59 + - go tool cover -html={{.BUILD_DIR}}/coverage.out -o {{.BUILD_DIR}}/coverage.html 60 + - 'echo "Coverage report: {{.BUILD_DIR}}/coverage.html"' 61 + 62 + fmt: 63 + desc: Format Go code 64 + cmds: 65 + - go fmt ./... 66 + 67 + tidy: 68 + desc: Tidy and verify dependencies 69 + cmds: 70 + - go mod tidy 71 + - go mod verify 72 + 73 + deps: 74 + desc: Download dependencies 75 + cmds: 76 + - go mod download 77 + 78 + check: 79 + desc: Run all checks (fmt, test, lint) 80 + cmds: 81 + - task: fmt 82 + - task: test 83 + 84 + vet: 85 + desc: Run go vet 86 + cmds: 87 + - go vet ./... 88 + 89 + setup: 90 + desc: Initial setup - install dependencies and build 91 + cmds: 92 + - task: deps 93 + - task: tidy 94 + - task: build 95 + - echo "Setup complete."
+2 -1
go.mod
··· 1 - module github.com/stormlightlabs/storm 1 + module github.com/stormlightlabs/git-storm 2 2 3 3 go 1.24.5 4 4 5 5 require ( 6 + github.com/charmbracelet/bubbles v0.21.0 6 7 github.com/charmbracelet/fang v0.4.3 7 8 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 8 9 github.com/charmbracelet/log v0.4.2
+2
go.sum
··· 10 10 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 11 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= 12 12 github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= 13 + github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 14 + github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 13 15 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 14 16 github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 15 17 github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
+156
internal/diff/format.go
··· 1 + package diff 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/lipgloss" 8 + "github.com/stormlightlabs/git-storm/internal/style" 9 + ) 10 + 11 + const ( 12 + // Layout constants for side-by-side view 13 + lineNumWidth = 4 14 + gutterWidth = 3 15 + minPaneWidth = 40 16 + ) 17 + 18 + // SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting. 19 + type SideBySideFormatter struct { 20 + // TerminalWidth is the total available width for rendering 21 + TerminalWidth int 22 + // ShowLineNumbers controls whether line numbers are displayed 23 + ShowLineNumbers bool 24 + } 25 + 26 + // Format renders the edits as a styled side-by-side diff string. 27 + // 28 + // The left pane shows the old content (deletions and unchanged lines). 29 + // The right pane shows the new content (insertions and unchanged lines). 30 + // Line numbers and color coding help visualize the changes. 31 + func (f *SideBySideFormatter) Format(edits []Edit) string { 32 + if len(edits) == 0 { 33 + return style.StyleText.Render("No changes") 34 + } 35 + 36 + paneWidth := f.calculatePaneWidth() 37 + 38 + var sb strings.Builder 39 + lineNumStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")).Faint(true) 40 + 41 + for _, edit := range edits { 42 + left, right := f.renderEdit(edit, paneWidth) 43 + 44 + if f.ShowLineNumbers { 45 + leftNum := f.formatLineNum(edit.AIndex, lineNumStyle) 46 + rightNum := f.formatLineNum(edit.BIndex, lineNumStyle) 47 + 48 + sb.WriteString(leftNum) 49 + sb.WriteString(left) 50 + sb.WriteString(f.renderGutter(edit.Kind)) 51 + sb.WriteString(rightNum) 52 + sb.WriteString(right) 53 + } else { 54 + sb.WriteString(left) 55 + sb.WriteString(f.renderGutter(edit.Kind)) 56 + sb.WriteString(right) 57 + } 58 + sb.WriteString("\n") 59 + } 60 + 61 + return sb.String() 62 + } 63 + 64 + // calculatePaneWidth determines the width available for each content pane. 65 + func (f *SideBySideFormatter) calculatePaneWidth() int { 66 + usedWidth := gutterWidth 67 + if f.ShowLineNumbers { 68 + usedWidth += 2 * lineNumWidth 69 + } 70 + 71 + availableWidth := f.TerminalWidth - usedWidth 72 + paneWidth := availableWidth / 2 73 + 74 + if paneWidth < minPaneWidth { 75 + paneWidth = minPaneWidth 76 + } 77 + 78 + return paneWidth 79 + } 80 + 81 + // renderEdit formats a single edit operation for both left and right panes. 82 + func (f *SideBySideFormatter) renderEdit(edit Edit, paneWidth int) (left, right string) { 83 + content := f.truncateContent(edit.Content, paneWidth) 84 + 85 + switch edit.Kind { 86 + case Equal: 87 + // Show on both sides with neutral styling 88 + leftStyled := style.StyleText.Width(paneWidth).Render(content) 89 + rightStyled := style.StyleText.Width(paneWidth).Render(content) 90 + return leftStyled, rightStyled 91 + 92 + case Delete: 93 + // Show on left in red, empty right 94 + leftStyled := style.StyleRemoved.Width(paneWidth).Render(content) 95 + rightStyled := lipgloss.NewStyle().Width(paneWidth).Render("") 96 + return leftStyled, rightStyled 97 + 98 + case Insert: 99 + // Empty left, show on right in green 100 + leftStyled := lipgloss.NewStyle().Width(paneWidth).Render("") 101 + rightStyled := style.StyleAdded.Width(paneWidth).Render(content) 102 + return leftStyled, rightStyled 103 + 104 + default: 105 + // Fallback for unknown edit kinds 106 + return lipgloss.NewStyle().Width(paneWidth).Render(content), 107 + lipgloss.NewStyle().Width(paneWidth).Render(content) 108 + } 109 + } 110 + 111 + // renderGutter creates the visual separator between left and right panes. 112 + func (f *SideBySideFormatter) renderGutter(kind EditKind) string { 113 + var symbol string 114 + var st lipgloss.Style 115 + 116 + switch kind { 117 + case Equal: 118 + symbol = " │ " 119 + st = lipgloss.NewStyle().Foreground(lipgloss.Color("#6C7A89")) 120 + case Delete: 121 + symbol = " < " 122 + st = style.StyleRemoved 123 + case Insert: 124 + symbol = " > " 125 + st = style.StyleAdded 126 + default: 127 + symbol = " │ " 128 + st = lipgloss.NewStyle() 129 + } 130 + 131 + return st.Render(symbol) 132 + } 133 + 134 + // formatLineNum renders a line number with styling. 135 + func (f *SideBySideFormatter) formatLineNum(index int, st lipgloss.Style) string { 136 + if index < 0 { 137 + return st.Width(lineNumWidth).Render("") 138 + } 139 + return st.Width(lineNumWidth).Render(fmt.Sprintf("%4d", index+1)) 140 + } 141 + 142 + // truncateContent ensures content fits within the pane width. 143 + func (f *SideBySideFormatter) truncateContent(content string, maxWidth int) string { 144 + // Remove trailing whitespace but preserve leading indentation 145 + content = strings.TrimRight(content, " \t\r\n") 146 + 147 + if len(content) <= maxWidth { 148 + return content 149 + } 150 + 151 + if maxWidth <= 3 { 152 + return content[:maxWidth] 153 + } 154 + 155 + return content[:maxWidth-3] + "..." 156 + }
+224
internal/diff/format_test.go
··· 1 + package diff 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestSideBySideFormatter_Format(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + edits []Edit 12 + width int 13 + expect func(string) bool 14 + }{ 15 + { 16 + name: "empty edits", 17 + edits: []Edit{}, 18 + width: 80, 19 + expect: func(output string) bool { 20 + return strings.Contains(output, "No changes") 21 + }, 22 + }, 23 + { 24 + name: "equal lines", 25 + edits: []Edit{ 26 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "hello world"}, 27 + }, 28 + width: 80, 29 + expect: func(output string) bool { 30 + return strings.Contains(output, "hello world") 31 + }, 32 + }, 33 + { 34 + name: "insert operation", 35 + edits: []Edit{ 36 + {Kind: Insert, AIndex: -1, BIndex: 0, Content: "new line"}, 37 + }, 38 + width: 80, 39 + expect: func(output string) bool { 40 + return strings.Contains(output, "new line") && strings.Contains(output, ">") 41 + }, 42 + }, 43 + { 44 + name: "delete operation", 45 + edits: []Edit{ 46 + {Kind: Delete, AIndex: 0, BIndex: -1, Content: "old line"}, 47 + }, 48 + width: 80, 49 + expect: func(output string) bool { 50 + return strings.Contains(output, "old line") && strings.Contains(output, "<") 51 + }, 52 + }, 53 + { 54 + name: "mixed operations", 55 + edits: []Edit{ 56 + {Kind: Equal, AIndex: 0, BIndex: 0, Content: "unchanged"}, 57 + {Kind: Delete, AIndex: 1, BIndex: -1, Content: "removed"}, 58 + {Kind: Insert, AIndex: -1, BIndex: 1, Content: "added"}, 59 + {Kind: Equal, AIndex: 2, BIndex: 2, Content: "also unchanged"}, 60 + }, 61 + width: 100, 62 + expect: func(output string) bool { 63 + return strings.Contains(output, "unchanged") && 64 + strings.Contains(output, "removed") && 65 + strings.Contains(output, "added") 66 + }, 67 + }, 68 + } 69 + 70 + for _, tt := range tests { 71 + t.Run(tt.name, func(t *testing.T) { 72 + formatter := &SideBySideFormatter{ 73 + TerminalWidth: tt.width, 74 + ShowLineNumbers: true, 75 + } 76 + 77 + output := formatter.Format(tt.edits) 78 + 79 + if !tt.expect(output) { 80 + t.Errorf("Format() output did not meet expectations.\nGot:\n%s", output) 81 + } 82 + }) 83 + } 84 + } 85 + 86 + func TestSideBySideFormatter_CalculatePaneWidth(t *testing.T) { 87 + tests := []struct { 88 + name string 89 + terminalWidth int 90 + showLineNumbers bool 91 + minExpected int 92 + }{ 93 + { 94 + name: "standard width with line numbers", 95 + terminalWidth: 120, 96 + showLineNumbers: true, 97 + minExpected: 40, 98 + }, 99 + { 100 + name: "narrow terminal", 101 + terminalWidth: 60, 102 + showLineNumbers: true, 103 + minExpected: minPaneWidth, 104 + }, 105 + { 106 + name: "without line numbers", 107 + terminalWidth: 100, 108 + showLineNumbers: false, 109 + minExpected: 40, 110 + }, 111 + } 112 + 113 + for _, tt := range tests { 114 + t.Run(tt.name, func(t *testing.T) { 115 + formatter := &SideBySideFormatter{ 116 + TerminalWidth: tt.terminalWidth, 117 + ShowLineNumbers: tt.showLineNumbers, 118 + } 119 + 120 + paneWidth := formatter.calculatePaneWidth() 121 + 122 + if paneWidth < tt.minExpected { 123 + t.Errorf("calculatePaneWidth() = %d, expected at least %d", paneWidth, tt.minExpected) 124 + } 125 + }) 126 + } 127 + } 128 + 129 + func TestSideBySideFormatter_TruncateContent(t *testing.T) { 130 + formatter := &SideBySideFormatter{} 131 + 132 + tests := []struct { 133 + name string 134 + content string 135 + maxWidth int 136 + expected string 137 + }{ 138 + { 139 + name: "short content", 140 + content: "hello", 141 + maxWidth: 10, 142 + expected: "hello", 143 + }, 144 + { 145 + name: "exact fit", 146 + content: "hello world", 147 + maxWidth: 11, 148 + expected: "hello world", 149 + }, 150 + { 151 + name: "needs truncation", 152 + content: "hello world this is a long line", 153 + maxWidth: 10, 154 + expected: "hello w...", 155 + }, 156 + { 157 + name: "very small width", 158 + content: "hello", 159 + maxWidth: 3, 160 + expected: "hel", 161 + }, 162 + { 163 + name: "trailing whitespace removed", 164 + content: "hello ", 165 + maxWidth: 10, 166 + expected: "hello", 167 + }, 168 + } 169 + 170 + for _, tt := range tests { 171 + t.Run(tt.name, func(t *testing.T) { 172 + result := formatter.truncateContent(tt.content, tt.maxWidth) 173 + if result != tt.expected { 174 + t.Errorf("truncateContent() = %q, expected %q", result, tt.expected) 175 + } 176 + }) 177 + } 178 + } 179 + 180 + func TestSideBySideFormatter_RenderEdit(t *testing.T) { 181 + formatter := &SideBySideFormatter{ 182 + TerminalWidth: 100, 183 + ShowLineNumbers: true, 184 + } 185 + paneWidth := 40 186 + 187 + tests := []struct { 188 + name string 189 + edit Edit 190 + expect func(left, right string) bool 191 + }{ 192 + { 193 + name: "equal edit shows on both sides", 194 + edit: Edit{Kind: Equal, AIndex: 0, BIndex: 0, Content: "same"}, 195 + expect: func(left, right string) bool { 196 + return strings.Contains(left, "same") && strings.Contains(right, "same") 197 + }, 198 + }, 199 + { 200 + name: "delete shows only on left", 201 + edit: Edit{Kind: Delete, AIndex: 0, BIndex: -1, Content: "removed"}, 202 + expect: func(left, right string) bool { 203 + return strings.Contains(left, "removed") && !strings.Contains(right, "removed") 204 + }, 205 + }, 206 + { 207 + name: "insert shows only on right", 208 + edit: Edit{Kind: Insert, AIndex: -1, BIndex: 0, Content: "added"}, 209 + expect: func(left, right string) bool { 210 + return !strings.Contains(left, "added") && strings.Contains(right, "added") 211 + }, 212 + }, 213 + } 214 + 215 + for _, tt := range tests { 216 + t.Run(tt.name, func(t *testing.T) { 217 + left, right := formatter.renderEdit(tt.edit, paneWidth) 218 + 219 + if !tt.expect(left, right) { 220 + t.Errorf("renderEdit() failed expectations.\nLeft: %q\nRight: %q", left, right) 221 + } 222 + }) 223 + } 224 + }
+202
internal/ui/ui.go
··· 1 1 package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/bubbles/key" 8 + "github.com/charmbracelet/bubbles/viewport" 9 + tea "github.com/charmbracelet/bubbletea" 10 + "github.com/charmbracelet/lipgloss" 11 + "github.com/stormlightlabs/git-storm/internal/diff" 12 + "github.com/stormlightlabs/git-storm/internal/style" 13 + ) 14 + 15 + // DiffModel holds the state for the side-by-side diff viewer. 16 + type DiffModel struct { 17 + viewport viewport.Model 18 + content string 19 + ready bool 20 + oldPath string 21 + newPath string 22 + } 23 + 24 + // keyMap defines keyboard shortcuts for the diff viewer. 25 + type keyMap struct { 26 + Up key.Binding 27 + Down key.Binding 28 + PageUp key.Binding 29 + PageDown key.Binding 30 + HalfUp key.Binding 31 + HalfDown key.Binding 32 + Top key.Binding 33 + Bottom key.Binding 34 + Quit key.Binding 35 + } 36 + 37 + var keys = keyMap{ 38 + Up: key.NewBinding( 39 + key.WithKeys("up", "k"), 40 + key.WithHelp("↑/k", "up"), 41 + ), 42 + Down: key.NewBinding( 43 + key.WithKeys("down", "j"), 44 + key.WithHelp("↓/j", "down"), 45 + ), 46 + PageUp: key.NewBinding( 47 + key.WithKeys("pgup", "b"), 48 + key.WithHelp("pgup/b", "page up"), 49 + ), 50 + PageDown: key.NewBinding( 51 + key.WithKeys("pgdown", "f", " "), 52 + key.WithHelp("pgdn/f/space", "page down"), 53 + ), 54 + HalfUp: key.NewBinding( 55 + key.WithKeys("u", "ctrl+u"), 56 + key.WithHelp("u", "half page up"), 57 + ), 58 + HalfDown: key.NewBinding( 59 + key.WithKeys("d", "ctrl+d"), 60 + key.WithHelp("d", "half page down"), 61 + ), 62 + Top: key.NewBinding( 63 + key.WithKeys("home", "g"), 64 + key.WithHelp("g/home", "top"), 65 + ), 66 + Bottom: key.NewBinding( 67 + key.WithKeys("end", "G"), 68 + key.WithHelp("G/end", "bottom"), 69 + ), 70 + Quit: key.NewBinding( 71 + key.WithKeys("q", "esc", "ctrl+c"), 72 + key.WithHelp("q", "quit"), 73 + ), 74 + } 75 + 76 + // NewDiffModel creates a new diff viewer model with the given edits. 77 + func NewDiffModel(edits []diff.Edit, oldPath, newPath string, terminalWidth, terminalHeight int) DiffModel { 78 + formatter := &diff.SideBySideFormatter{ 79 + TerminalWidth: terminalWidth, 80 + ShowLineNumbers: true, 81 + } 82 + 83 + content := formatter.Format(edits) 84 + 85 + vp := viewport.New(terminalWidth, terminalHeight-2) // Reserve space for header 86 + vp.SetContent(content) 87 + 88 + return DiffModel{ 89 + viewport: vp, 90 + content: content, 91 + ready: true, 92 + oldPath: oldPath, 93 + newPath: newPath, 94 + } 95 + } 96 + 97 + // Init initializes the model (required by Bubble Tea). 98 + func (m DiffModel) Init() tea.Cmd { 99 + return nil 100 + } 101 + 102 + // Update handles messages and updates the model state. 103 + func (m DiffModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 104 + var cmd tea.Cmd 105 + 106 + switch msg := msg.(type) { 107 + case tea.KeyMsg: 108 + switch { 109 + case key.Matches(msg, keys.Quit): 110 + return m, tea.Quit 111 + 112 + case key.Matches(msg, keys.Up): 113 + m.viewport.LineUp(1) 114 + 115 + case key.Matches(msg, keys.Down): 116 + m.viewport.LineDown(1) 117 + 118 + case key.Matches(msg, keys.PageUp): 119 + m.viewport.ViewUp() 120 + 121 + case key.Matches(msg, keys.PageDown): 122 + m.viewport.ViewDown() 123 + 124 + case key.Matches(msg, keys.HalfUp): 125 + m.viewport.HalfViewUp() 126 + 127 + case key.Matches(msg, keys.HalfDown): 128 + m.viewport.HalfViewDown() 129 + 130 + case key.Matches(msg, keys.Top): 131 + m.viewport.GotoTop() 132 + 133 + case key.Matches(msg, keys.Bottom): 134 + m.viewport.GotoBottom() 135 + } 136 + 137 + case tea.WindowSizeMsg: 138 + if !m.ready { 139 + m.viewport = viewport.New(msg.Width, msg.Height-2) 140 + m.viewport.SetContent(m.content) 141 + m.ready = true 142 + } else { 143 + m.viewport.Width = msg.Width 144 + m.viewport.Height = msg.Height - 2 145 + } 146 + } 147 + 148 + m.viewport, cmd = m.viewport.Update(msg) 149 + return m, cmd 150 + } 151 + 152 + // View renders the current view of the diff viewer. 153 + func (m DiffModel) View() string { 154 + if !m.ready { 155 + return "\n Initializing..." 156 + } 157 + 158 + header := m.renderHeader() 159 + footer := m.renderFooter() 160 + 161 + return fmt.Sprintf("%s\n%s\n%s", header, m.viewport.View(), footer) 162 + } 163 + 164 + // renderHeader creates the header bar showing file paths. 165 + func (m DiffModel) renderHeader() string { 166 + headerStyle := lipgloss.NewStyle(). 167 + Foreground(style.AccentBlue). 168 + Bold(true). 169 + Padding(0, 1) 170 + 171 + oldLabel := lipgloss.NewStyle().Foreground(style.RemovedColor).Render("−") 172 + newLabel := lipgloss.NewStyle().Foreground(style.AddedColor).Render("+") 173 + 174 + return headerStyle.Render( 175 + fmt.Sprintf("%s %s %s %s", oldLabel, m.oldPath, newLabel, m.newPath), 176 + ) 177 + } 178 + 179 + // renderFooter creates the footer bar with help text and scroll position. 180 + func (m DiffModel) renderFooter() string { 181 + footerStyle := lipgloss.NewStyle(). 182 + Foreground(lipgloss.Color("#6C7A89")). 183 + Faint(true). 184 + Padding(0, 1) 185 + 186 + helpText := "↑/↓: scroll • space/b: page • g/G: top/bottom • q: quit" 187 + 188 + scrollPercent := m.viewport.ScrollPercent() 189 + scrollInfo := fmt.Sprintf("%.0f%%", scrollPercent*100) 190 + 191 + totalWidth := m.viewport.Width 192 + helpWidth := lipgloss.Width(helpText) 193 + scrollWidth := lipgloss.Width(scrollInfo) 194 + padding := totalWidth - helpWidth - scrollWidth - 2 195 + 196 + if padding < 0 { 197 + padding = 0 198 + } 199 + 200 + return footerStyle.Render( 201 + helpText + strings.Repeat(" ", padding) + scrollInfo, 202 + ) 203 + }
+137
internal/ui/ui_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + "time" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/x/exp/teatest" 10 + "github.com/stormlightlabs/git-storm/internal/diff" 11 + ) 12 + 13 + func TestDiffModel_Init(t *testing.T) { 14 + edits := []diff.Edit{ 15 + {Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "line 1"}, 16 + } 17 + 18 + model := NewDiffModel(edits, "old.txt", "new.txt", 80, 24) 19 + 20 + cmd := model.Init() 21 + if cmd != nil { 22 + t.Errorf("Init() should return nil, got %v", cmd) 23 + } 24 + } 25 + 26 + func TestDiffModel_View(t *testing.T) { 27 + edits := []diff.Edit{ 28 + {Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "unchanged line"}, 29 + {Kind: diff.Delete, AIndex: 1, BIndex: -1, Content: "removed line"}, 30 + {Kind: diff.Insert, AIndex: -1, BIndex: 1, Content: "added line"}, 31 + } 32 + 33 + model := NewDiffModel(edits, "old.txt", "new.txt", 100, 30) 34 + 35 + view := model.View() 36 + 37 + if !strings.Contains(view, "old.txt") { 38 + t.Error("View should contain old file path") 39 + } 40 + if !strings.Contains(view, "new.txt") { 41 + t.Error("View should contain new file path") 42 + } 43 + if !strings.Contains(view, "unchanged line") { 44 + t.Error("View should contain unchanged line") 45 + } 46 + } 47 + 48 + func TestDiffModel_KeyboardNavigation(t *testing.T) { 49 + edits := make([]diff.Edit, 100) 50 + for i := range edits { 51 + edits[i] = diff.Edit{ 52 + Kind: diff.Equal, 53 + AIndex: i, 54 + BIndex: i, 55 + Content: "line content", 56 + } 57 + } 58 + 59 + model := NewDiffModel(edits, "old.txt", "new.txt", 80, 20) 60 + 61 + tm := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(80, 20)) 62 + 63 + // Test down movement 64 + tm.Send(tea.KeyMsg{Type: tea.KeyDown}) 65 + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 66 + return len(bts) > 0 67 + }) 68 + 69 + // Test up movement 70 + tm.Send(tea.KeyMsg{Type: tea.KeyUp}) 71 + teatest.WaitFor(t, tm.Output(), func(bts []byte) bool { 72 + return len(bts) > 0 73 + }) 74 + 75 + // Test quit 76 + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) 77 + tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second)) 78 + } 79 + 80 + func TestDiffModel_QuitKeys(t *testing.T) { 81 + edits := []diff.Edit{ 82 + {Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}, 83 + } 84 + 85 + quitKeys := []tea.KeyType{ 86 + tea.KeyRunes, // 'q' 87 + tea.KeyEsc, 88 + tea.KeyCtrlC, 89 + } 90 + 91 + for _, keyType := range quitKeys { 92 + t.Run(keyType.String(), func(t *testing.T) { 93 + model := NewDiffModel(edits, "old.txt", "new.txt", 80, 20) 94 + tm := teatest.NewTestModel(t, model) 95 + 96 + var msg tea.Msg 97 + if keyType == tea.KeyRunes { 98 + msg = tea.KeyMsg{Type: keyType, Runes: []rune{'q'}} 99 + } else { 100 + msg = tea.KeyMsg{Type: keyType} 101 + } 102 + 103 + tm.Send(msg) 104 + tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) 105 + }) 106 + } 107 + } 108 + 109 + func TestDiffModel_RenderHeader(t *testing.T) { 110 + edits := []diff.Edit{} 111 + model := NewDiffModel(edits, "src/old.go", "src/new.go", 80, 20) 112 + 113 + header := model.renderHeader() 114 + 115 + if !strings.Contains(header, "old.go") { 116 + t.Error("Header should contain old file path") 117 + } 118 + if !strings.Contains(header, "new.go") { 119 + t.Error("Header should contain new file path") 120 + } 121 + } 122 + 123 + func TestDiffModel_RenderFooter(t *testing.T) { 124 + edits := []diff.Edit{ 125 + {Kind: diff.Equal, AIndex: 0, BIndex: 0, Content: "test"}, 126 + } 127 + model := NewDiffModel(edits, "old.txt", "new.txt", 80, 20) 128 + 129 + footer := model.renderFooter() 130 + 131 + if !strings.Contains(footer, "scroll") { 132 + t.Error("Footer should contain help text about scrolling") 133 + } 134 + if !strings.Contains(footer, "quit") { 135 + t.Error("Footer should contain help text about quitting") 136 + } 137 + }
+253
main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + tea "github.com/charmbracelet/bubbletea" 9 + "github.com/charmbracelet/fang" 10 + "github.com/charmbracelet/log" 11 + "github.com/go-git/go-git/v6" 12 + "github.com/go-git/go-git/v6/plumbing" 13 + "github.com/spf13/cobra" 14 + "github.com/stormlightlabs/git-storm/internal/diff" 15 + "github.com/stormlightlabs/git-storm/internal/ui" 16 + ) 17 + 18 + var ( 19 + repoPath string 20 + output string 21 + ) 22 + 23 + var ( 24 + fromRef string 25 + toRef string 26 + interactive bool 27 + sinceTag string 28 + ) 29 + 30 + var ( 31 + changeType string 32 + scope string 33 + summary string 34 + outputJSON bool 35 + ) 36 + 37 + var ( 38 + releaseVersion string 39 + tagRelease bool 40 + dryRun bool 41 + ) 42 + 43 + const versionString string = "0.1.0-dev" 44 + 45 + // runDiff executes the diff command by reading file contents from two git refs and launching the TUI. 46 + func runDiff(fromRef, toRef, filePath string) error { 47 + repo, err := git.PlainOpen(repoPath) 48 + if err != nil { 49 + return fmt.Errorf("failed to open repository: %w", err) 50 + } 51 + 52 + oldContent, err := getFileContent(repo, fromRef, filePath) 53 + if err != nil { 54 + return fmt.Errorf("failed to read %s from %s: %w", filePath, fromRef, err) 55 + } 56 + 57 + newContent, err := getFileContent(repo, toRef, filePath) 58 + if err != nil { 59 + return fmt.Errorf("failed to read %s from %s: %w", filePath, toRef, err) 60 + } 61 + 62 + oldLines := strings.Split(oldContent, "\n") 63 + newLines := strings.Split(newContent, "\n") 64 + 65 + myers := &diff.Myers{} 66 + edits, err := myers.Compute(oldLines, newLines) 67 + if err != nil { 68 + return fmt.Errorf("diff computation failed: %w", err) 69 + } 70 + 71 + model := ui.NewDiffModel(edits, fromRef+":"+filePath, toRef+":"+filePath, 120, 30) 72 + 73 + p := tea.NewProgram(model, tea.WithAltScreen()) 74 + if _, err := p.Run(); err != nil { 75 + return fmt.Errorf("TUI failed: %w", err) 76 + } 77 + 78 + return nil 79 + } 80 + 81 + // getFileContent reads the content of a file at a specific ref (commit, tag, or branch). 82 + func getFileContent(repo *git.Repository, ref, filePath string) (string, error) { 83 + hash, err := repo.ResolveRevision(plumbing.Revision(ref)) 84 + if err != nil { 85 + return "", fmt.Errorf("failed to resolve %s: %w", ref, err) 86 + } 87 + 88 + commit, err := repo.CommitObject(*hash) 89 + if err != nil { 90 + return "", fmt.Errorf("failed to get commit: %w", err) 91 + } 92 + 93 + tree, err := commit.Tree() 94 + if err != nil { 95 + return "", fmt.Errorf("failed to get tree: %w", err) 96 + } 97 + 98 + file, err := tree.File(filePath) 99 + if err != nil { 100 + return "", fmt.Errorf("file not found: %w", err) 101 + } 102 + 103 + content, err := file.Contents() 104 + if err != nil { 105 + return "", fmt.Errorf("failed to read file content: %w", err) 106 + } 107 + 108 + return content, nil 109 + } 110 + 111 + func versionCmd() *cobra.Command { 112 + return &cobra.Command{ 113 + Use: "version", 114 + Short: "Print the current storm version", 115 + RunE: func(cmd *cobra.Command, args []string) error { 116 + fmt.Println(versionString) 117 + return nil 118 + }, 119 + } 120 + } 121 + 122 + func diffCmd() *cobra.Command { 123 + var filePath string 124 + 125 + c := &cobra.Command{ 126 + Use: "diff <from> <to>", 127 + Short: "Show a line-based diff between two commits or tags", 128 + Long: `Displays an inline diff (added/removed/unchanged lines) between two refs 129 + using the built-in diff engine.`, 130 + Args: cobra.ExactArgs(2), 131 + RunE: func(cmd *cobra.Command, args []string) error { 132 + return runDiff(args[0], args[1], filePath) 133 + }, 134 + } 135 + 136 + c.Flags().StringVarP(&filePath, "file", "f", "", "File path to diff (required)") 137 + c.MarkFlagRequired("file") 138 + 139 + return c 140 + } 141 + 142 + func releaseCmd() *cobra.Command { 143 + c := &cobra.Command{ 144 + Use: "release", 145 + Short: "Promote unreleased changes into a new changelog version", 146 + Long: `Merges all .changes entries into CHANGELOG.md under a new version header. 147 + Optionally creates a Git tag and clears the .changes directory.`, 148 + RunE: func(cmd *cobra.Command, args []string) error { 149 + fmt.Println("release command not implemented") 150 + fmt.Printf("version=%v tag=%v dry-run=%v\n", releaseVersion, tagRelease, dryRun) 151 + return nil 152 + }, 153 + } 154 + 155 + c.Flags().StringVar(&releaseVersion, "version", "", "Semantic version for the new release (e.g., 1.3.0)") 156 + c.Flags().BoolVar(&tagRelease, "tag", false, "Create a Git tag after release") 157 + c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") 158 + c.MarkFlagRequired("version") 159 + 160 + return c 161 + } 162 + 163 + func unreleasedCmd() *cobra.Command { 164 + add := &cobra.Command{ 165 + Use: "add", 166 + Short: "Add a new unreleased change entry", 167 + Long: `Creates a new .changes/<date>-<summary>.md file with the specified type, 168 + scope, and summary.`, 169 + RunE: func(cmd *cobra.Command, args []string) error { 170 + fmt.Println("unreleased add not implemented") 171 + fmt.Printf("type=%q scope=%q summary=%q\n", changeType, scope, summary) 172 + return nil 173 + }, 174 + } 175 + add.Flags().StringVar(&changeType, "type", "", "Type of change (added, changed, fixed, removed, security)") 176 + add.Flags().StringVar(&scope, "scope", "", "Optional scope or subsystem name") 177 + add.Flags().StringVar(&summary, "summary", "", "Short summary of the change") 178 + add.MarkFlagRequired("type") 179 + add.MarkFlagRequired("summary") 180 + 181 + list := &cobra.Command{ 182 + Use: "list", 183 + Short: "List all unreleased changes", 184 + Long: "Prints all pending .changes entries to stdout. Supports JSON output.", 185 + RunE: func(cmd *cobra.Command, args []string) error { 186 + fmt.Println("unreleased list not implemented") 187 + fmt.Printf("outputJSON=%v\n", outputJSON) 188 + return nil 189 + }, 190 + } 191 + list.Flags().BoolVar(&outputJSON, "json", false, "Output results as JSON") 192 + 193 + review := &cobra.Command{ 194 + Use: "review", 195 + Short: "Review unreleased changes interactively", 196 + Long: `Launches an interactive Bubble Tea TUI to review, edit, or categorize 197 + unreleased entries before final release.`, 198 + RunE: func(cmd *cobra.Command, args []string) error { 199 + fmt.Println("unreleased review not implemented (TUI)") 200 + return nil 201 + }, 202 + } 203 + 204 + root := &cobra.Command{ 205 + Use: "unreleased", 206 + Short: "Manage unreleased changes (.changes directory)", 207 + Long: `Work with unreleased change notes. Supports adding, listing, 208 + and reviewing pending entries before release.`, 209 + } 210 + root.AddCommand(add, list, review) 211 + 212 + return root 213 + } 214 + 215 + func generateCmd() *cobra.Command { 216 + c := &cobra.Command{ 217 + Use: "generate [from] [to]", 218 + Short: "Generate changelog entries from Git commits", 219 + Long: `Scans commits between two Git refs (tags or hashes) and outputs draft 220 + entries in .changes/. Supports conventional commit parsing and 221 + interactive review mode.`, 222 + Args: cobra.MaximumNArgs(2), 223 + RunE: func(cmd *cobra.Command, args []string) error { 224 + fmt.Println("generate command not implemented") 225 + fmt.Printf("from=%v to=%v interactive=%v sinceTag=%v\n", fromRef, toRef, interactive, sinceTag) 226 + return nil 227 + }, 228 + } 229 + 230 + c.Flags().BoolVarP(&interactive, "interactive", "i", false, "Review changes interactively in a TUI") 231 + c.Flags().StringVar(&sinceTag, "since", "", "Generate changes since the given tag") 232 + 233 + return c 234 + } 235 + 236 + func main() { 237 + ctx := context.Background() 238 + root := &cobra.Command{ 239 + Use: "storm", 240 + Short: "A Git-aware changelog manager for Go projects", 241 + Long: `storm is a modern changelog generator inspired by Towncrier. 242 + It manages .changes/ entries, generates Keep a Changelog sections, 243 + and can review commits interactively through a TUI.`, 244 + } 245 + 246 + root.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the Git repository") 247 + root.PersistentFlags().StringVarP(&output, "output", "o", "CHANGELOG.md", "Output changelog file path") 248 + root.AddCommand(generateCmd(), unreleasedCmd(), releaseCmd(), diffCmd(), versionCmd()) 249 + 250 + if err := fang.Execute(ctx, root); err != nil { 251 + log.Fatalf("Execution failed: %v", err) 252 + } 253 + }