+5
-5
PROJECT.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}