Approval-based snapshot testing library for Go (mirror)

refactor: cleanup, ux/ui improvements

fix: improve bubble tea TUI visual appearance

- Remove unnecessary status line showing 'Snapshot accepted/rejected/skipped'
This cluttered the UI without providing additional value
- Fix footer styling to properly extend to both screen edges using statusBarStyle
- Restore helpStyle application to footer text for proper color/formatting
- Ensure footer is properly positioned in the layout hierarchy

The TUI now has a cleaner appearance with the status bar properly styled
and the action confirmation removed from the viewport.

feat: auto-close TUI when all snapshots have been reviewed

- TUI now automatically exits after the last snapshot is reviewed (accept/reject/skip)
- Track individual action counts (acceptedAll, rejectedAll, skippedAll) for each snapshot
- Check m.done status after loadCurrentSnapshot() in handlers and exit if review is complete
- Improve exit message to show all non-zero action counts (e.g., '✓ Accepted 3 • ⊘ Rejected 1 • ⊘ Skipped 2')
- This provides a better user experience by not leaving the TUI hanging after the last action

The TUI now properly closes after reviewing each snapshot one-by-one, matching the behavior
of the 'Accept All', 'Reject All', and 'Skip All' commands.

docs: update README with TUI usage documentation and add missing max() helper function

feat: implement semantic colors for TUI footer actions (green/red/yellow) and vertical layout

Improve TUI footer layout: horizontal actions with scroll percentage positioned right

- Changed footer layout from vertical to horizontal action buttons
- Reduced footer height from 4 to 2 lines for better space utilization
- Positioned scroll percentage (%) at the bottom-right corner
- Added spacing between action buttons for better visual clarity
- Minimized whitespace between diff content and action options

+164 -59
-5
.gitignore
··· 9 9 go.work.sum 10 10 .env 11 11 cmd/tui/freeze-tui 12 - cmd/tui/tui 13 - cmd/freeze/freeze 14 - freeze-tui 15 - tui 16 - freeze
+25 -3
README.md
··· 4 4 5 5 ![New snapshot screen](./assets/screenshot-new.png "New snapshot view") 6 6 7 - ![Snapshot review CLI](./assets/screenshots-diff-cli "Snapshot diff view (CLI)") 7 + ![Snapshot review CLI](./assets/screenshots-diff-cli.png "Snapshot diff view (CLI)") 8 8 9 9 ## Installation 10 10 ··· 34 34 35 35 <!-- TODO: add example of `freeze.Review()` in go code --> 36 36 37 - Freeze also includes (in a separate Go module) with a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). (The TUI is shipped in a separate module to make the added dependencies optional) 37 + Freeze also includes (in a separate Go module) a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). (The TUI is shipped in a separate module to make the added dependencies optional) 38 + 39 + ### TUI Usage 38 40 39 41 ```sh 40 - # TODO: tui usage 42 + go run github.com/ptdewey/freeze/cmd/tui review 43 + ``` 44 + 45 + #### Interactive Controls 46 + 47 + - `a` - Accept current snapshot 48 + - `r` - Reject current snapshot 49 + - `s` - Skip current snapshot 50 + - `A` - Accept all remaining snapshots 51 + - `R` - Reject all remaining snapshots 52 + - `S` - Skip all remaining snapshots 53 + - `q` - Quit 54 + 55 + #### Alternative Commands 56 + 57 + ```sh 58 + # Accept all new snapshots without review 59 + go run github.com/ptdewey/freeze/cmd/tui accept-all 60 + 61 + # Reject all new snapshots without review 62 + go run github.com/ptdewey/freeze/cmd/tui reject-all 41 63 ``` 42 64 43 65 ## Disclaimer
+1
__snapshots__/test_accept.snap
··· 3 3 test_name: TestAccept 4 4 file_path: 5 5 func_name: 6 + version: 6 7 --- 7 8 new content to accept
+2 -1
__snapshots__/test_map.snap
··· 1 1 --- 2 2 title: Map Test 3 3 test_name: TestMap 4 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: 5 5 func_name: 6 + version: 0.1.0 6 7 --- 7 8 map[string]interface{}{ 8 9 "foo": "bar",
+2 -1
__snapshots__/test_snap_custom_type.snap
··· 1 1 --- 2 2 title: Custom Type Test 3 3 test_name: TestSnapCustomType 4 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: 5 5 func_name: 6 + version: 0.1.0 6 7 --- 7 8 freeze_test.CustomStruct{ 8 9 Name: "Alice",
+2 -1
__snapshots__/test_snap_func.snap
··· 1 1 --- 2 2 title: TestSnapFunc 3 3 test_name: TestSnapFunc 4 - file_path: /home/patrick/projects/freeze/freeze_test.go 4 + file_path: 5 5 func_name: 6 + version: 0.1.0 6 7 --- 7 8 "helper result"
+2 -1
__snapshots__/test_snap_func_another_helper.snap
··· 1 1 --- 2 2 title: TestSnapFuncAnotherHelper 3 3 test_name: TestSnapFuncAnotherHelper 4 - file_path: /home/patrick/projects/freeze/freeze_test.go 4 + file_path: 5 5 func_name: 6 + version: 0.1.0 6 7 --- 7 8 10
+2 -1
__snapshots__/test_snap_multiple.snap
··· 1 1 --- 2 2 title: Multiple Values Test 3 3 test_name: TestSnapMultiple 4 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: 5 5 func_name: 6 + version: 0.1.0 6 7 --- 7 8 "value1" 8 9 "value2"
+2 -1
__snapshots__/test_snap_string.snap
··· 1 1 --- 2 2 title: Simple String Test 3 3 test_name: TestSnapString 4 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: 5 5 func_name: 6 + version: 0.1.0 6 7 --- 7 8 hello world
+92 -27
cmd/tui/main.go
··· 35 35 36 36 contentStyle = lipgloss.NewStyle(). 37 37 Padding(1, 2) 38 + 39 + // Action styles with semantic colors 40 + acceptStyle = lipgloss.NewStyle(). 41 + Foreground(lipgloss.Color("10")). // Green 42 + Bold(true) 43 + 44 + rejectStyle = lipgloss.NewStyle(). 45 + Foreground(lipgloss.Color("9")). // Red 46 + Bold(true) 47 + 48 + skipStyle = lipgloss.NewStyle(). 49 + Foreground(lipgloss.Color("11")). // Yellow 50 + Bold(true) 51 + 52 + keyStyle = lipgloss.NewStyle(). 53 + Foreground(lipgloss.Color("241")) 54 + 55 + helpTextStyle = lipgloss.NewStyle(). 56 + Foreground(lipgloss.Color("240")) 38 57 ) 39 58 40 59 type model struct { ··· 135 154 m.height = msg.Height 136 155 137 156 headerHeight := 3 138 - footerHeight := 2 157 + footerHeight := 1 139 158 verticalMarginHeight := headerHeight + footerHeight 140 159 141 160 if !m.ready { ··· 161 180 if err := files.AcceptSnapshot(testName); err != nil { 162 181 m.err = err 163 182 } else { 164 - m.actionResult = "Snapshot accepted" 183 + m.acceptedAll++ 165 184 m.current++ 166 185 if err := m.loadCurrentSnapshot(); err != nil { 167 186 m.err = err 187 + } 188 + if m.done { 189 + return m, tea.Quit 168 190 } 169 191 m.updateViewportContent() 170 192 } ··· 175 197 if err := files.RejectSnapshot(testName); err != nil { 176 198 m.err = err 177 199 } else { 178 - m.actionResult = "Snapshot rejected" 200 + m.rejectedAll++ 179 201 m.current++ 180 202 if err := m.loadCurrentSnapshot(); err != nil { 181 203 m.err = err 182 204 } 205 + if m.done { 206 + return m, tea.Quit 207 + } 183 208 m.updateViewportContent() 184 209 } 185 210 186 211 case "s": 187 212 // Skip current snapshot 188 - m.actionResult = "Snapshot skipped" 213 + m.skippedAll++ 189 214 m.current++ 190 215 if err := m.loadCurrentSnapshot(); err != nil { 191 216 m.err = err 217 + } 218 + if m.done { 219 + return m, tea.Quit 192 220 } 193 221 m.updateViewportContent() 194 222 ··· 251 279 } 252 280 } 253 281 254 - if m.actionResult != "" { 255 - b.WriteString("\n\n") 256 - b.WriteString(pretty.Success("✓ " + m.actionResult)) 257 - } 282 + // Add action options below the snapshot/diff box 283 + b.WriteString("\n") 284 + acceptLine := lipgloss.JoinHorizontal(lipgloss.Left, 285 + keyStyle.Render("[a]"), 286 + helpTextStyle.Render(" "), 287 + acceptStyle.Render("accept"), 288 + ) 289 + b.WriteString(acceptLine) 290 + b.WriteString("\n") 291 + 292 + rejectLine := lipgloss.JoinHorizontal(lipgloss.Left, 293 + keyStyle.Render("[r]"), 294 + helpTextStyle.Render(" "), 295 + rejectStyle.Render("reject"), 296 + ) 297 + b.WriteString(rejectLine) 298 + b.WriteString("\n") 299 + 300 + skipLine := lipgloss.JoinHorizontal(lipgloss.Left, 301 + keyStyle.Render("[s]"), 302 + helpTextStyle.Render(" "), 303 + skipStyle.Render("skip"), 304 + ) 305 + b.WriteString(skipLine) 258 306 259 307 m.viewport.SetContent(contentStyle.Render(b.String())) 260 308 m.viewport.GotoTop() ··· 266 314 return pretty.Success("✓ No new snapshots to review\n") 267 315 } 268 316 317 + // Build summary from counts 318 + var summary []string 269 319 if m.acceptedAll > 0 { 270 - return pretty.Success(fmt.Sprintf("✓ Accepted %d snapshot(s)\n", m.acceptedAll)) 320 + summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll)) 271 321 } 272 322 if m.rejectedAll > 0 { 273 - return pretty.Warning(fmt.Sprintf("⊘ Rejected %d snapshot(s)\n", m.rejectedAll)) 323 + summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll)) 274 324 } 275 325 if m.skippedAll > 0 { 276 - return pretty.Warning(fmt.Sprintf("⊘ Skipped %d snapshot(s)\n", m.skippedAll)) 326 + summary = append(summary, fmt.Sprintf("⊘ Skipped %d", m.skippedAll)) 327 + } 328 + 329 + if len(summary) > 0 { 330 + return pretty.Success(strings.Join(summary, " • ") + "\n") 277 331 } 278 332 return pretty.Success("\n✓ Review complete\n") 279 333 } ··· 289 343 // Header 290 344 header := lipgloss.JoinHorizontal( 291 345 lipgloss.Left, 292 - titleStyle.Render("Review Snapshots"), 346 + titleStyle.Render("Review Snapshots "), 293 347 counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), m.snapshots[m.current])), 294 348 ) 349 + headerStyled := statusBarStyle.Width(m.width).Render(header) 295 350 296 - // Footer with help 351 + // Footer with scroll info only 297 352 scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) 298 - helpText := "↑/↓/scroll: navigate • [a]ccept • [r]eject • [s]kip • [A]ll Accept • [R]ll Reject • [q]uit" 353 + scrollStyled := helpStyle.Render(scrollInfo) 299 354 300 - footerLeft := helpStyle.Render(helpText) 301 - footerRight := helpStyle.Render(scrollInfo) 355 + // Create footer with just scroll info on the right 356 + footer := lipgloss.JoinHorizontal(lipgloss.Left, 357 + strings.Repeat(" ", max(m.width-lipgloss.Width(scrollStyled)-1, 0)), 358 + scrollStyled, 359 + ) 360 + footerStyled := statusBarStyle.Width(m.width).Render(footer) 302 361 303 - gap := max(m.width-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight), 0) 362 + // Viewport content 363 + viewportContent := m.viewport.View() 304 364 305 - footer := lipgloss.JoinHorizontal( 306 - lipgloss.Left, 307 - footerLeft, 308 - strings.Repeat(" ", gap), 309 - footerRight, 310 - ) 365 + // Calculate how much vertical space we have 366 + // Total height = terminal height 367 + // Used by header = ~1 line (the rendered header) 368 + // Used by footer = ~1 line 369 + // Middle = viewport (takes remaining space) 311 370 312 - // Main content with viewport 313 371 return lipgloss.JoinVertical( 314 372 lipgloss.Left, 315 - statusBarStyle.Width(m.width).Render(header), 316 - m.viewport.View(), 317 - statusBarStyle.Width(m.width).Render(footer), 373 + headerStyled, 374 + viewportContent, 375 + footerStyled, 318 376 ) 377 + } 378 + 379 + func max(a, b int) int { 380 + if a > b { 381 + return a 382 + } 383 + return b 319 384 } 320 385 321 386 func main() {
+6 -6
freeze.go
··· 11 11 "github.com/ptdewey/freeze/internal/review" 12 12 ) 13 13 14 + const version = "0.1.0" 15 + 14 16 // TODO: probably make this (and other things) configurable 15 17 func init() { 16 18 utter.Config.ElideType = true ··· 57 59 func snapWithTitle(t testingT, title string, testName string, content string) { 58 60 t.Helper() 59 61 60 - _, filePath, _, _ := runtime.Caller(2) 61 - 62 62 snapshot := &files.Snapshot{ 63 - Title: title, 64 - Name: testName, 65 - FilePath: filePath, 66 - Content: content, 63 + Title: title, 64 + Name: testName, 65 + Content: content, 66 + Version: version, 67 67 } 68 68 69 69 accepted, err := files.ReadAccepted(testName)
+1 -1
freeze_test.go
··· 66 66 } 67 67 68 68 serialized := snap.Serialize() 69 - expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_path: \nfunc_name: \n---\ntest content\nmultiline" 69 + expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_path: \nfunc_name: \nversion: \n---\ntest content\nmultiline" 70 70 if serialized != expected { 71 71 t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) 72 72 }
+4 -1
internal/files/files.go
··· 13 13 Name string 14 14 FilePath string 15 15 FuncName string 16 + Version string 16 17 Content string 17 18 } 18 19 19 20 func (s *Snapshot) Serialize() string { 20 - header := fmt.Sprintf("---\ntitle: %s\ntest_name: %s\nfile_path: %s\nfunc_name: %s\n---\n", s.Title, s.Name, s.FilePath, s.FuncName) 21 + header := fmt.Sprintf("---\ntitle: %s\ntest_name: %s\nfile_path: %s\nfunc_name: %s\nversion: %s\n---\n", s.Title, s.Name, s.FilePath, s.FuncName, s.Version) 21 22 return header + s.Content 22 23 } 23 24 ··· 55 56 snap.FilePath = value 56 57 case "func_name": 57 58 snap.FuncName = value 59 + case "version": 60 + snap.Version = value 58 61 } 59 62 } 60 63
+21 -1
internal/files/files_test.go
··· 37 37 Title: "Example Title", 38 38 Name: "TestExample", 39 39 FilePath: "/path/to/test.go", 40 + Version: "1.0.0", 40 41 Content: "test content\nmultiline", 41 42 } 42 43 43 44 serialized := snap.Serialize() 44 - expected := "---\ntitle: Example Title\ntest_name: TestExample\nfile_path: /path/to/test.go\nfunc_name: \n---\ntest content\nmultiline" 45 + expected := "---\ntitle: Example Title\ntest_name: TestExample\nfile_path: /path/to/test.go\nfunc_name: \nversion: 1.0.0\n---\ntest content\nmultiline" 45 46 if serialized != expected { 46 47 t.Errorf("Serialize():\nexpected:\n%s\n\ngot:\n%s", expected, serialized) 47 48 } ··· 59 60 } 60 61 if deserialized.FilePath != snap.FilePath { 61 62 t.Errorf("FilePath mismatch: %s != %s", deserialized.FilePath, snap.FilePath) 63 + } 64 + if deserialized.Version != snap.Version { 65 + t.Errorf("Version mismatch: %s != %s", deserialized.Version, snap.Version) 62 66 } 63 67 if deserialized.Content != snap.Content { 64 68 t.Errorf("Content mismatch: %s != %s", deserialized.Content, snap.Content) ··· 91 95 input string 92 96 wantTitle string 93 97 wantTest string 98 + wantVersion string 94 99 wantContent string 95 100 }{ 96 101 { ··· 98 103 "---\ntitle: Simple Title\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", 99 104 "Simple Title", 100 105 "Test", 106 + "", 107 + "content", 108 + }, 109 + { 110 + "with version", 111 + "---\ntitle: With Version\ntest_name: Test\nfile_path: /path\nfunc_name: \nversion: 1.0.0\n---\ncontent", 112 + "With Version", 113 + "Test", 114 + "1.0.0", 101 115 "content", 102 116 }, 103 117 { ··· 105 119 "---\ntitle: Multi Title\ntest_name: MyTest\nfile_path: /path\nfunc_name: \n---\nline1\nline2\nline3", 106 120 "Multi Title", 107 121 "MyTest", 122 + "", 108 123 "line1\nline2\nline3", 109 124 }, 110 125 { ··· 112 127 "---\ntitle: Extra Title\ntest_name: Test\nfile_path: /path\nfunc_name: \nextra: ignored\n---\ncontent", 113 128 "Extra Title", 114 129 "Test", 130 + "", 115 131 "content", 116 132 }, 117 133 { ··· 119 135 "---\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", 120 136 "", 121 137 "Test", 138 + "", 122 139 "content", 123 140 }, 124 141 } ··· 134 151 } 135 152 if snap.Name != tt.wantTest { 136 153 t.Errorf("Name = %s, want %s", snap.Name, tt.wantTest) 154 + } 155 + if snap.Version != tt.wantVersion { 156 + t.Errorf("Version = %s, want %s", snap.Version, tt.wantVersion) 137 157 } 138 158 if snap.Content != tt.wantContent { 139 159 t.Errorf("Content = %s, want %s", snap.Content, tt.wantContent)
+1 -4
internal/pretty/boxes.go
··· 34 34 if snap.Title != "" { 35 35 sb.WriteString(fmt.Sprintf(" title: %s\n", Blue("\""+snap.Title+"\""))) 36 36 } 37 - if isFuncSnapshot && snap.FuncName != "" { 38 - sb.WriteString(fmt.Sprintf(" func: %s\n", Blue("\""+snap.FuncName+"\""))) 39 - sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) 40 - } else { 37 + if snap.Name != "" { 41 38 sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) 42 39 } 43 40 sb.WriteString("\n")
+1 -5
internal/review/review.go
··· 76 76 diffLines := computeDiffLines(accepted, newSnap) 77 77 fmt.Println(pretty.DiffSnapshotBox(accepted, newSnap, diffLines)) 78 78 } else { 79 - if newSnap.FuncName != "" { 80 - fmt.Println(pretty.NewSnapshotBoxFunc(newSnap)) 81 - } else { 82 - fmt.Println(pretty.NewSnapshotBox(newSnap)) 83 - } 79 + fmt.Println(pretty.NewSnapshotBox(newSnap)) 84 80 } 85 81 86 82 for {