Approval-based snapshot testing library for Go (mirror)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: bubble tea tui (separate module), improved diff ui

ptdewey a7268364 7cfbebf4

+647 -99
+1
__snapshots__/test_accept.snap
··· 1 1 --- 2 + title: Accept Title 2 3 test_name: TestAccept 3 4 file_path: 4 5 func_name:
+4 -1
__snapshots__/test_map.snap
··· 1 1 --- 2 + title: Map Test 2 3 test_name: TestMap 3 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 5 func_name: 5 6 --- 6 7 map[string]interface{}{ 8 + "wibble": "wobble", 7 9 "foo": "bar", 10 + "wibbling": "wobble", 8 11 }
+2 -1
__snapshots__/test_snap_custom_type.snap
··· 1 1 --- 2 + title: Custom Type Test 2 3 test_name: TestSnapCustomType 3 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 5 func_name: 5 6 --- 6 7 freeze_test.CustomStruct{
+2 -1
__snapshots__/test_snap_func.snap
··· 1 1 --- 2 + title: TestSnapFunc 2 3 test_name: TestSnapFunc 3 - file_path: /home/patrick/projects/freeze/freeze_test.go 4 + file_path: /home/patrick/projects/scratchpad/freeze/freeze_test.go 4 5 func_name: 5 6 --- 6 7 "helper result"
+2 -1
__snapshots__/test_snap_func_another_helper.snap
··· 1 1 --- 2 + title: TestSnapFuncAnotherHelper 2 3 test_name: TestSnapFuncAnotherHelper 3 - file_path: /home/patrick/projects/freeze/freeze_test.go 4 + file_path: /home/patrick/projects/scratchpad/freeze/freeze_test.go 4 5 func_name: 5 6 --- 6 7 10
+2 -1
__snapshots__/test_snap_multiple.snap
··· 1 1 --- 2 + title: Multiple Values Test 2 3 test_name: TestSnapMultiple 3 - file_path: /home/patrick/projects/freeze/freeze.go 4 + file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 4 5 func_name: 5 6 --- 6 7 "value1"
+7
__snapshots__/test_snap_string.snap
··· 1 + --- 2 + title: Simple String Test 3 + test_name: TestSnapString 4 + file_path: /home/patrick/projects/scratchpad/freeze/freeze.go 5 + func_name: 6 + --- 7 + hello world
-6
__snapshots__/test_snap_string.snap.new
··· 1 - --- 2 - test_name: TestSnapString 3 - file_path: /home/patrick/projects/freeze/freeze.go 4 - func_name: 5 - --- 6 - hello world
+32
cmd/tui/go.mod
··· 1 + module github.com/ptdewey/freeze/cmd/tui 2 + 3 + go 1.25.2 4 + 5 + require ( 6 + github.com/charmbracelet/bubbletea v1.3.10 7 + github.com/charmbracelet/lipgloss v1.1.0 8 + github.com/charmbracelet/bubbles v0.21.0 9 + github.com/ptdewey/freeze v0.0.0 10 + ) 11 + 12 + require ( 13 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 15 + github.com/charmbracelet/x/ansi v0.10.1 // indirect 16 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 17 + github.com/charmbracelet/x/term v0.2.1 // indirect 18 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 19 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 + github.com/mattn/go-isatty v0.0.20 // indirect 21 + github.com/mattn/go-localereader v0.0.1 // indirect 22 + github.com/mattn/go-runewidth v0.0.16 // indirect 23 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 24 + github.com/muesli/cancelreader v0.2.2 // indirect 25 + github.com/muesli/termenv v0.16.0 // indirect 26 + github.com/rivo/uniseg v0.4.7 // indirect 27 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 28 + golang.org/x/sys v0.36.0 // indirect 29 + golang.org/x/text v0.3.8 // indirect 30 + ) 31 + 32 + replace github.com/ptdewey/freeze => ../..
+45
cmd/tui/go.sum
··· 1 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 + github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 4 + github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 5 + github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= 6 + github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= 7 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 8 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 9 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 10 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 11 + github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 12 + github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 13 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 14 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 15 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 16 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 17 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 18 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 19 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 22 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 23 + github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 24 + github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 25 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 26 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 27 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 28 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 29 + github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 30 + github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 31 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 32 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 33 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 35 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 36 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 37 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 38 + golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 39 + golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 40 + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 + golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 43 + golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 44 + golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 45 + golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+409
cmd/tui/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "strings" 7 + 8 + "github.com/charmbracelet/bubbles/viewport" 9 + tea "github.com/charmbracelet/bubbletea" 10 + "github.com/charmbracelet/lipgloss" 11 + "github.com/ptdewey/freeze/internal/diff" 12 + "github.com/ptdewey/freeze/internal/files" 13 + "github.com/ptdewey/freeze/internal/pretty" 14 + ) 15 + 16 + // Styles 17 + var ( 18 + titleStyle = lipgloss.NewStyle(). 19 + Bold(true). 20 + Foreground(lipgloss.Color("205")). 21 + Padding(0, 1) 22 + 23 + counterStyle = lipgloss.NewStyle(). 24 + Foreground(lipgloss.Color("240")). 25 + Padding(0, 1) 26 + 27 + helpStyle = lipgloss.NewStyle(). 28 + Foreground(lipgloss.Color("241")). 29 + Background(lipgloss.Color("236")). 30 + Padding(0, 1) 31 + 32 + statusBarStyle = lipgloss.NewStyle(). 33 + Background(lipgloss.Color("236")). 34 + Foreground(lipgloss.Color("230")) 35 + 36 + contentStyle = lipgloss.NewStyle(). 37 + Padding(1, 2) 38 + ) 39 + 40 + type model struct { 41 + snapshots []string 42 + current int 43 + newSnap *files.Snapshot 44 + accepted *files.Snapshot 45 + diffLines []pretty.DiffLine 46 + choice string 47 + done bool 48 + err error 49 + acceptedAll int 50 + rejectedAll int 51 + skippedAll int 52 + actionResult string 53 + viewport viewport.Model 54 + ready bool 55 + width int 56 + height int 57 + } 58 + 59 + func initialModel() (model, error) { 60 + snapshots, err := files.ListNewSnapshots() 61 + if err != nil { 62 + return model{}, err 63 + } 64 + 65 + if len(snapshots) == 0 { 66 + return model{done: true}, nil 67 + } 68 + 69 + m := model{ 70 + snapshots: snapshots, 71 + current: 0, 72 + } 73 + 74 + if err := m.loadCurrentSnapshot(); err != nil { 75 + return model{}, err 76 + } 77 + 78 + return m, nil 79 + } 80 + 81 + func (m *model) loadCurrentSnapshot() error { 82 + if m.current >= len(m.snapshots) { 83 + m.done = true 84 + return nil 85 + } 86 + 87 + testName := m.snapshots[m.current] 88 + 89 + newSnap, err := files.ReadSnapshot(testName, "new") 90 + if err != nil { 91 + return err 92 + } 93 + m.newSnap = newSnap 94 + 95 + accepted, err := files.ReadSnapshot(testName, "accepted") 96 + if err == nil { 97 + m.accepted = accepted 98 + diffLines := computeDiffLines(accepted, newSnap) 99 + m.diffLines = diffLines 100 + } else { 101 + m.accepted = nil 102 + m.diffLines = nil 103 + } 104 + 105 + return nil 106 + } 107 + 108 + func computeDiffLines(old, new *files.Snapshot) []pretty.DiffLine { 109 + diffLines := diff.Histogram(old.Content, new.Content) 110 + result := make([]pretty.DiffLine, len(diffLines)) 111 + for i, dl := range diffLines { 112 + result[i] = pretty.DiffLine{ 113 + OldNumber: dl.OldNumber, 114 + NewNumber: dl.NewNumber, 115 + Line: dl.Line, 116 + Kind: pretty.DiffKind(dl.Kind), 117 + } 118 + } 119 + return result 120 + } 121 + 122 + func (m model) Init() tea.Cmd { 123 + return tea.EnterAltScreen 124 + } 125 + 126 + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 127 + var ( 128 + cmd tea.Cmd 129 + cmds []tea.Cmd 130 + ) 131 + 132 + switch msg := msg.(type) { 133 + case tea.WindowSizeMsg: 134 + m.width = msg.Width 135 + m.height = msg.Height 136 + 137 + headerHeight := 3 138 + footerHeight := 2 139 + verticalMarginHeight := headerHeight + footerHeight 140 + 141 + if !m.ready { 142 + m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 143 + m.viewport.YPosition = headerHeight 144 + m.ready = true 145 + m.updateViewportContent() 146 + } else { 147 + m.viewport.Width = msg.Width 148 + m.viewport.Height = msg.Height - verticalMarginHeight 149 + m.updateViewportContent() 150 + } 151 + 152 + case tea.KeyMsg: 153 + switch msg.String() { 154 + case "q", "ctrl+c", "esc": 155 + m.done = true 156 + return m, tea.Quit 157 + 158 + case "a": 159 + // Accept current snapshot 160 + testName := m.snapshots[m.current] 161 + if err := files.AcceptSnapshot(testName); err != nil { 162 + m.err = err 163 + } else { 164 + m.actionResult = "Snapshot accepted" 165 + m.current++ 166 + if err := m.loadCurrentSnapshot(); err != nil { 167 + m.err = err 168 + } 169 + m.updateViewportContent() 170 + } 171 + 172 + case "r": 173 + // Reject current snapshot 174 + testName := m.snapshots[m.current] 175 + if err := files.RejectSnapshot(testName); err != nil { 176 + m.err = err 177 + } else { 178 + m.actionResult = "Snapshot rejected" 179 + m.current++ 180 + if err := m.loadCurrentSnapshot(); err != nil { 181 + m.err = err 182 + } 183 + m.updateViewportContent() 184 + } 185 + 186 + case "s": 187 + // Skip current snapshot 188 + m.actionResult = "Snapshot skipped" 189 + m.current++ 190 + if err := m.loadCurrentSnapshot(); err != nil { 191 + m.err = err 192 + } 193 + m.updateViewportContent() 194 + 195 + case "A": 196 + // Accept all remaining 197 + for i := m.current; i < len(m.snapshots); i++ { 198 + if err := files.AcceptSnapshot(m.snapshots[i]); err != nil { 199 + m.err = err 200 + break 201 + } 202 + m.acceptedAll++ 203 + } 204 + m.done = true 205 + return m, tea.Quit 206 + 207 + case "R": 208 + // Reject all remaining 209 + for i := m.current; i < len(m.snapshots); i++ { 210 + if err := files.RejectSnapshot(m.snapshots[i]); err != nil { 211 + m.err = err 212 + break 213 + } 214 + m.rejectedAll++ 215 + } 216 + m.done = true 217 + return m, tea.Quit 218 + 219 + case "S": 220 + // Skip all remaining 221 + m.skippedAll = len(m.snapshots) - m.current 222 + m.done = true 223 + return m, tea.Quit 224 + } 225 + } 226 + 227 + // Handle viewport scrolling 228 + m.viewport, cmd = m.viewport.Update(msg) 229 + cmds = append(cmds, cmd) 230 + 231 + return m, tea.Batch(cmds...) 232 + } 233 + 234 + func (m *model) updateViewportContent() { 235 + if !m.ready { 236 + return 237 + } 238 + 239 + var b strings.Builder 240 + 241 + // Show diff or new snapshot 242 + if m.accepted != nil && m.diffLines != nil { 243 + b.WriteString(pretty.DiffSnapshotBox(m.accepted, m.newSnap, m.diffLines)) 244 + } else { 245 + if m.newSnap != nil { 246 + if m.newSnap.FuncName != "" { 247 + b.WriteString(pretty.NewSnapshotBoxFunc(m.newSnap)) 248 + } else { 249 + b.WriteString(pretty.NewSnapshotBox(m.newSnap)) 250 + } 251 + } 252 + } 253 + 254 + if m.actionResult != "" { 255 + b.WriteString("\n\n") 256 + b.WriteString(pretty.Success("✓ " + m.actionResult)) 257 + } 258 + 259 + m.viewport.SetContent(contentStyle.Render(b.String())) 260 + m.viewport.GotoTop() 261 + } 262 + 263 + func (m model) View() string { 264 + if m.done { 265 + if len(m.snapshots) == 0 { 266 + return pretty.Success("✓ No new snapshots to review\n") 267 + } 268 + 269 + if m.acceptedAll > 0 { 270 + return pretty.Success(fmt.Sprintf("✓ Accepted %d snapshot(s)\n", m.acceptedAll)) 271 + } 272 + if m.rejectedAll > 0 { 273 + return pretty.Warning(fmt.Sprintf("⊘ Rejected %d snapshot(s)\n", m.rejectedAll)) 274 + } 275 + if m.skippedAll > 0 { 276 + return pretty.Warning(fmt.Sprintf("⊘ Skipped %d snapshot(s)\n", m.skippedAll)) 277 + } 278 + return pretty.Success("\n✓ Review complete\n") 279 + } 280 + 281 + if m.err != nil { 282 + return pretty.Error("Error: " + m.err.Error() + "\n") 283 + } 284 + 285 + if !m.ready { 286 + return "\n Initializing..." 287 + } 288 + 289 + // Header 290 + header := lipgloss.JoinHorizontal( 291 + lipgloss.Left, 292 + titleStyle.Render("Review Snapshots"), 293 + counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), m.snapshots[m.current])), 294 + ) 295 + 296 + // Footer with help 297 + 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" 299 + 300 + footerLeft := helpStyle.Render(helpText) 301 + footerRight := helpStyle.Render(scrollInfo) 302 + 303 + gap := max(m.width-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight), 0) 304 + 305 + footer := lipgloss.JoinHorizontal( 306 + lipgloss.Left, 307 + footerLeft, 308 + strings.Repeat(" ", gap), 309 + footerRight, 310 + ) 311 + 312 + // Main content with viewport 313 + return lipgloss.JoinVertical( 314 + lipgloss.Left, 315 + statusBarStyle.Width(m.width).Render(header), 316 + m.viewport.View(), 317 + statusBarStyle.Width(m.width).Render(footer), 318 + ) 319 + } 320 + 321 + func main() { 322 + if len(os.Args) > 1 { 323 + switch os.Args[1] { 324 + case "accept-all": 325 + if err := acceptAll(); err != nil { 326 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 327 + os.Exit(1) 328 + } 329 + return 330 + case "reject-all": 331 + if err := rejectAll(); err != nil { 332 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 333 + os.Exit(1) 334 + } 335 + return 336 + case "help", "-h", "--help": 337 + fmt.Println(`Usage: freeze-tui [COMMAND] 338 + 339 + Commands: 340 + review Review and accept/reject new snapshots (default) 341 + accept-all Accept all new snapshots 342 + reject-all Reject all new snapshots 343 + help Show this help message 344 + 345 + Interactive Controls: 346 + a Accept current snapshot 347 + r Reject current snapshot 348 + s Skip current snapshot 349 + A Accept all remaining snapshots 350 + R Reject all remaining snapshots 351 + S Skip all remaining snapshots 352 + q Quit`) 353 + return 354 + } 355 + } 356 + 357 + m, err := initialModel() 358 + if err != nil { 359 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 360 + os.Exit(1) 361 + } 362 + 363 + if m.done && len(m.snapshots) == 0 { 364 + fmt.Println(m.View()) 365 + return 366 + } 367 + 368 + p := tea.NewProgram( 369 + m, 370 + tea.WithAltScreen(), 371 + tea.WithMouseCellMotion(), 372 + ) 373 + if _, err := p.Run(); err != nil { 374 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 375 + os.Exit(1) 376 + } 377 + } 378 + 379 + func acceptAll() error { 380 + snapshots, err := files.ListNewSnapshots() 381 + if err != nil { 382 + return err 383 + } 384 + 385 + for _, testName := range snapshots { 386 + if err := files.AcceptSnapshot(testName); err != nil { 387 + return err 388 + } 389 + } 390 + 391 + fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)) 392 + return nil 393 + } 394 + 395 + func rejectAll() error { 396 + snapshots, err := files.ListNewSnapshots() 397 + if err != nil { 398 + return err 399 + } 400 + 401 + for _, testName := range snapshots { 402 + if err := files.RejectSnapshot(testName); err != nil { 403 + return err 404 + } 405 + } 406 + 407 + fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) 408 + return nil 409 + }
+16 -38
freeze.go
··· 16 16 utter.Config.ElideType = true 17 17 } 18 18 19 - func SnapString(t testingT, content string) { 20 - t.Helper() 21 - snap(t, content) 22 - } 23 - 24 - func Snap(t testingT, values ...any) { 19 + func SnapString(t testingT, title string, content string) { 25 20 t.Helper() 26 - content := formatValues(values...) 27 - snap(t, content) 21 + snap(t, title, content) 28 22 } 29 23 30 - func SnapWithTitle(t testingT, title string, values ...any) { 24 + func Snap(t testingT, title string, values ...any) { 31 25 t.Helper() 32 26 content := formatValues(values...) 33 - snapWithTitle(t, title, content) 27 + snap(t, title, content) 34 28 } 35 29 36 30 func SnapFunc(t testingT, values ...any) { ··· 49 43 } 50 44 funcName = fullName[parts:] 51 45 } 46 + testName := t.Name() 52 47 content := formatValues(values...) 53 - snapWithTitle(t, funcName, content) 54 - } 55 - 56 - func getFunctionName() string { 57 - pc, _, _, _ := runtime.Caller(2) 58 - fn := runtime.FuncForPC(pc) 59 - if fn == nil { 60 - return "unknown" 61 - } 62 - 63 - fullName := fn.Name() 64 - parts := len(fullName) - 1 65 - for i := len(fullName) - 1; i >= 0; i-- { 66 - if fullName[i] == '.' { 67 - parts = i + 1 68 - break 69 - } 70 - } 71 - 72 - return fullName[parts:] 48 + snapWithTitle(t, funcName, testName, content) 73 49 } 74 50 75 - func snap(t testingT, content string) { 51 + func snap(t testingT, title string, content string) { 76 52 t.Helper() 77 53 testName := t.Name() 78 - snapWithTitle(t, testName, content) 54 + snapWithTitle(t, title, testName, content) 79 55 } 80 56 81 - func snapWithTitle(t testingT, title string, content string) { 57 + func snapWithTitle(t testingT, title string, testName string, content string) { 82 58 t.Helper() 83 59 84 60 _, filePath, _, _ := runtime.Caller(2) 85 61 86 62 snapshot := &files.Snapshot{ 87 - Name: title, 63 + Title: title, 64 + Name: testName, 88 65 FilePath: filePath, 89 66 Content: content, 90 67 } 91 68 92 - accepted, err := files.ReadAccepted(title) 69 + accepted, err := files.ReadAccepted(testName) 93 70 if err == nil { 94 71 if accepted.Content == content { 95 72 return ··· 119 96 result := make([]pretty.DiffLine, len(diffLines)) 120 97 for i, dl := range diffLines { 121 98 result[i] = pretty.DiffLine{ 122 - Number: dl.Number, 123 - Line: dl.Line, 124 - Kind: pretty.DiffKind(dl.Kind), 99 + OldNumber: dl.OldNumber, 100 + NewNumber: dl.NewNumber, 101 + Line: dl.Line, 102 + Kind: pretty.DiffKind(dl.Kind), 125 103 } 126 104 } 127 105 return result
+16 -6
freeze_test.go
··· 11 11 ) 12 12 13 13 func TestSnapString(t *testing.T) { 14 - freeze.SnapString(t, "hello world") 14 + freeze.SnapString(t, "Simple String Test", "hello world") 15 15 } 16 16 17 17 func TestSnapMultiple(t *testing.T) { 18 - freeze.Snap(t, "value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil) 18 + freeze.Snap(t, "Multiple Values Test", "value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil) 19 19 } 20 20 21 21 type CustomStruct struct { ··· 32 32 Name: "Alice", 33 33 Age: 30, 34 34 } 35 - freeze.Snap(t, cs) 35 + freeze.Snap(t, "Custom Type Test", cs) 36 36 } 37 37 38 38 func TestMap(t *testing.T) { 39 - freeze.Snap(t, map[string]any{ 40 - "foo": "bar", 39 + freeze.Snap(t, "Map Test", map[string]any{ 40 + "foo": "bar", 41 + "wibbling": "wobble", 42 + "wibble": "wobble", 41 43 }) 42 44 } 43 45 ··· 59 61 60 62 func TestSerializeDeserialize(t *testing.T) { 61 63 snap := &freeze.Snapshot{ 64 + Title: "My Test Title", 62 65 Name: "TestExample", 63 66 Content: "test content\nmultiline", 64 67 } 65 68 66 69 serialized := snap.Serialize() 67 - expected := "---\ntest_name: TestExample\nfile_path: \nfunc_name: \n---\ntest content\nmultiline" 70 + expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_path: \nfunc_name: \n---\ntest content\nmultiline" 68 71 if serialized != expected { 69 72 t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) 70 73 } ··· 74 77 t.Fatalf("failed to deserialize: %v", err) 75 78 } 76 79 80 + if deserialized.Title != snap.Title { 81 + t.Errorf("title mismatch: %s != %s", deserialized.Title, snap.Title) 82 + } 77 83 if deserialized.Name != snap.Name { 78 84 t.Errorf("test name mismatch: %s != %s", deserialized.Name, snap.Name) 79 85 } ··· 84 90 85 91 func TestFileOperations(t *testing.T) { 86 92 snap := &freeze.Snapshot{ 93 + Title: "File Ops Title", 87 94 Name: "TestFileOps", 88 95 Content: "file test content", 89 96 } ··· 163 170 164 171 func TestDiffSnapshotBox(t *testing.T) { 165 172 old := &freeze.Snapshot{ 173 + Title: "Diff Test Title", 166 174 Name: "TestDiff", 167 175 Content: "old content", 168 176 } 169 177 170 178 new := &freeze.Snapshot{ 179 + Title: "Diff Test Title", 171 180 Name: "TestDiff", 172 181 Content: "new content", 173 182 } ··· 184 193 185 194 func TestNewSnapshotBox(t *testing.T) { 186 195 snap := &freeze.Snapshot{ 196 + Title: "New Test Title", 187 197 Name: "TestNew", 188 198 Content: "test content", 189 199 }
+4 -3
internal/api/api.go
··· 53 53 result := make([]pretty.DiffLine, len(diffLines)) 54 54 for i, dl := range diffLines { 55 55 result[i] = pretty.DiffLine{ 56 - Number: dl.Number, 57 - Line: dl.Line, 58 - Kind: pretty.DiffKind(dl.Kind), 56 + OldNumber: dl.OldNumber, 57 + NewNumber: dl.NewNumber, 58 + Line: dl.Line, 59 + Kind: pretty.DiffKind(dl.Kind), 59 60 } 60 61 } 61 62 return result
+16 -14
internal/diff/diff.go
··· 13 13 ) 14 14 15 15 type DiffLine struct { 16 - Number int 17 - Line string 18 - Kind DiffKind 16 + OldNumber int 17 + NewNumber int 18 + Line string 19 + Kind DiffKind 19 20 } 20 21 21 22 func Histogram(old, new string) []DiffLine { ··· 66 67 67 68 for i > 0 || j > 0 { 68 69 if i > 0 && j > 0 && old[i-1] == new[j-1] { 69 - result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared}}, result...) 70 + // Lines match - shared 71 + result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared, OldNumber: i, NewNumber: j}}, result...) 70 72 i-- 71 73 j-- 72 - } else if j > 0 && (i == 0 || matrix[i][j-1] < matrix[i-1][j]) { 73 - result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) 74 + } else if i > 0 && j > 0 && matrix[i-1][j] == matrix[i][j-1] { 75 + // Equal cost - prefer showing deletions first, then additions 76 + // This groups changes better 77 + result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld, OldNumber: i}}, result...) 78 + i-- 79 + } else if j > 0 && (i == 0 || matrix[i][j-1] <= matrix[i-1][j]) { 80 + // Addition 81 + result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew, NewNumber: j}}, result...) 74 82 j-- 75 83 } else if i > 0 { 76 - result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld}}, result...) 84 + // Deletion 85 + result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld, OldNumber: i}}, result...) 77 86 i-- 78 - } else { 79 - result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) 80 - j-- 81 87 } 82 - } 83 - 84 - for idx := range result { 85 - result[idx].Number = idx + 1 86 88 } 87 89 88 90 return result
+12 -4
internal/diff/diff_test.go
··· 37 37 if dl.Kind != diff.DiffShared { 38 38 t.Errorf("line %d: expected DiffShared, got %v", i, dl.Kind) 39 39 } 40 - if dl.Number != i+1 { 41 - t.Errorf("line %d: expected Number=%d, got %d", i, i+1, dl.Number) 40 + expectedLineNum := i + 1 41 + if dl.OldNumber != expectedLineNum { 42 + t.Errorf("line %d: expected OldNumber=%d, got %d", i, expectedLineNum, dl.OldNumber) 43 + } 44 + if dl.NewNumber != expectedLineNum { 45 + t.Errorf("line %d: expected NewNumber=%d, got %d", i, expectedLineNum, dl.NewNumber) 42 46 } 43 47 } 44 48 } ··· 146 150 result := diff.Histogram(old, new) 147 151 148 152 for i, dl := range result { 149 - if dl.Number != i+1 { 150 - t.Errorf("line %d: expected Number=%d, got %d", i, i+1, dl.Number) 153 + expectedLineNum := i + 1 154 + if dl.OldNumber != expectedLineNum { 155 + t.Errorf("line %d: expected OldNumber=%d, got %d", i, expectedLineNum, dl.OldNumber) 156 + } 157 + if dl.NewNumber != expectedLineNum { 158 + t.Errorf("line %d: expected NewNumber=%d, got %d", i, expectedLineNum, dl.NewNumber) 151 159 } 152 160 } 153 161 }
+4 -1
internal/files/files.go
··· 9 9 ) 10 10 11 11 type Snapshot struct { 12 + Title string 12 13 Name string 13 14 FilePath string 14 15 FuncName string ··· 16 17 } 17 18 18 19 func (s *Snapshot) Serialize() string { 19 - header := fmt.Sprintf("---\ntest_name: %s\nfile_path: %s\nfunc_name: %s\n---\n", s.Name, s.FilePath, s.FuncName) 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) 20 21 return header + s.Content 21 22 } 22 23 ··· 46 47 47 48 key, value := kv[0], kv[1] 48 49 switch key { 50 + case "title": 51 + snap.Title = value 49 52 case "test_name": 50 53 snap.Name = value 51 54 case "file_path":
+25 -4
internal/files/files_test.go
··· 34 34 35 35 func TestSerializeDeserialize(t *testing.T) { 36 36 snap := &files.Snapshot{ 37 + Title: "Example Title", 37 38 Name: "TestExample", 38 39 FilePath: "/path/to/test.go", 39 40 Content: "test content\nmultiline", 40 41 } 41 42 42 43 serialized := snap.Serialize() 43 - expected := "---\ntest_name: TestExample\nfile_path: /path/to/test.go\nfunc_name: \n---\ntest content\nmultiline" 44 + expected := "---\ntitle: Example Title\ntest_name: TestExample\nfile_path: /path/to/test.go\nfunc_name: \n---\ntest content\nmultiline" 44 45 if serialized != expected { 45 46 t.Errorf("Serialize():\nexpected:\n%s\n\ngot:\n%s", expected, serialized) 46 47 } ··· 50 51 t.Fatalf("Deserialize failed: %v", err) 51 52 } 52 53 54 + if deserialized.Title != snap.Title { 55 + t.Errorf("Title mismatch: %s != %s", deserialized.Title, snap.Title) 56 + } 53 57 if deserialized.Name != snap.Name { 54 58 t.Errorf("Name mismatch: %s != %s", deserialized.Name, snap.Name) 55 59 } ··· 85 89 tests := []struct { 86 90 name string 87 91 input string 92 + wantTitle string 88 93 wantTest string 89 94 wantContent string 90 95 }{ 91 96 { 92 97 "simple", 93 - "---\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", 98 + "---\ntitle: Simple Title\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", 99 + "Simple Title", 94 100 "Test", 95 101 "content", 96 102 }, 97 103 { 98 104 "multiline content", 99 - "---\ntest_name: MyTest\nfile_path: /path\nfunc_name: \n---\nline1\nline2\nline3", 105 + "---\ntitle: Multi Title\ntest_name: MyTest\nfile_path: /path\nfunc_name: \n---\nline1\nline2\nline3", 106 + "Multi Title", 100 107 "MyTest", 101 108 "line1\nline2\nline3", 102 109 }, 103 110 { 104 111 "with extra fields", 105 - "---\ntest_name: Test\nfile_path: /path\nfunc_name: \nextra: ignored\n---\ncontent", 112 + "---\ntitle: Extra Title\ntest_name: Test\nfile_path: /path\nfunc_name: \nextra: ignored\n---\ncontent", 113 + "Extra Title", 114 + "Test", 115 + "content", 116 + }, 117 + { 118 + "backward compatibility - no title", 119 + "---\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", 120 + "", 106 121 "Test", 107 122 "content", 108 123 }, ··· 113 128 snap, err := files.Deserialize(tt.input) 114 129 if err != nil { 115 130 t.Fatalf("Deserialize failed: %v", err) 131 + } 132 + if snap.Title != tt.wantTitle { 133 + t.Errorf("Title = %s, want %s", snap.Title, tt.wantTitle) 116 134 } 117 135 if snap.Name != tt.wantTest { 118 136 t.Errorf("Name = %s, want %s", snap.Name, tt.wantTest) ··· 126 144 127 145 func TestSaveAndReadSnapshot(t *testing.T) { 128 146 snap := &files.Snapshot{ 147 + Title: "Save Read Title", 129 148 Name: "TestSaveRead", 130 149 Content: "saved content", 131 150 } ··· 155 174 156 175 func TestAcceptSnapshot(t *testing.T) { 157 176 newSnap := &files.Snapshot{ 177 + Title: "Accept Title", 158 178 Name: "TestAccept", 159 179 Content: "new content to accept", 160 180 } ··· 186 206 187 207 func TestRejectSnapshot(t *testing.T) { 188 208 snap := &files.Snapshot{ 209 + Title: "Reject Title", 189 210 Name: "TestReject", 190 211 Content: "content to reject", 191 212 }
+44 -15
internal/pretty/boxes.go
··· 9 9 ) 10 10 11 11 type DiffLine struct { 12 - Number int 13 - Line string 14 - Kind DiffKind 12 + OldNumber int 13 + NewNumber int 14 + Line string 15 + Kind DiffKind 15 16 } 16 17 17 18 type DiffKind int ··· 24 25 25 26 func newSnapshotBoxInternal(snap *files.Snapshot, isFuncSnapshot bool) string { 26 27 width := TerminalWidth() 28 + snapshotFileName := files.SnapshotFileName(snap.Name) + ".snap.new" 27 29 28 30 var sb strings.Builder 29 - sb.WriteString("─── " + "New Snapshot " + strings.Repeat("─", width-15) + "\n\n") 31 + sb.WriteString("─── " + "New Snapshot " + strings.Repeat("─", width-15) + "\n") 32 + sb.WriteString(fmt.Sprintf(" file: %s\n\n", Gray(snapshotFileName))) 30 33 34 + if snap.Title != "" { 35 + sb.WriteString(fmt.Sprintf(" title: %s\n", Blue("\""+snap.Title+"\""))) 36 + } 31 37 if isFuncSnapshot && snap.FuncName != "" { 32 38 sb.WriteString(fmt.Sprintf(" func: %s\n", Blue("\""+snap.FuncName+"\""))) 33 39 sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) 34 40 } else { 35 41 sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) 36 42 } 37 - 38 - sb.WriteString(fmt.Sprintf(" snapshot: %s\n", Gray(files.SnapshotFileName(snap.Name)+".snap.new"))) 39 - if snap.FilePath != "" { 40 - sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(snap.FilePath))) 41 - } 42 43 sb.WriteString("\n") 43 44 44 45 lines := strings.Split(snap.Content, "\n") ··· 74 75 return newSnapshotBoxInternal(snap, true) 75 76 } 76 77 77 - // TODO: diff should show old and new line numbers 78 78 func DiffSnapshotBox(old, new *files.Snapshot, diffLines []DiffLine) string { 79 79 width := TerminalWidth() 80 + snapshotFileName := files.SnapshotFileName(new.Name) + ".snap" 80 81 81 82 var sb strings.Builder 82 83 sb.WriteString(strings.Repeat("─", width) + "\n") 84 + sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(snapshotFileName))) 83 85 sb.WriteString(fmt.Sprintf(" %s\n", Blue("Snapshot Diff"))) 84 - if new.FilePath != "" { 85 - sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(new.FilePath))) 86 + if new.Title != "" { 87 + sb.WriteString(fmt.Sprintf(" title: %s\n", Blue("\""+new.Title+"\""))) 86 88 } 89 + sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+new.Name+"\""))) 87 90 sb.WriteString(strings.Repeat("─", width) + "\n") 88 91 92 + // Calculate max line numbers for proper spacing 93 + maxOldNum := 0 94 + maxNewNum := 0 89 95 for _, dl := range diffLines { 96 + if dl.OldNumber > maxOldNum { 97 + maxOldNum = dl.OldNumber 98 + } 99 + if dl.NewNumber > maxNewNum { 100 + maxNewNum = dl.NewNumber 101 + } 102 + } 103 + oldWidth := len(fmt.Sprintf("%d", maxOldNum)) 104 + newWidth := len(fmt.Sprintf("%d", maxNewNum)) 105 + 106 + for _, dl := range diffLines { 107 + var oldNumStr, newNumStr string 90 108 var prefix string 91 109 var formatted string 92 110 93 111 switch dl.Kind { 94 112 case DiffOld: 113 + oldNumStr = fmt.Sprintf("%*d", oldWidth, dl.OldNumber) 114 + newNumStr = strings.Repeat(" ", newWidth) 95 115 prefix = Red("−") 96 116 formatted = Red(dl.Line) 97 117 case DiffNew: 118 + oldNumStr = strings.Repeat(" ", oldWidth) 119 + newNumStr = fmt.Sprintf("%*d", newWidth, dl.NewNumber) 98 120 prefix = Green("+") 99 121 formatted = Green(dl.Line) 100 122 case DiffShared: 123 + oldNumStr = fmt.Sprintf("%*d", oldWidth, dl.OldNumber) 124 + newNumStr = fmt.Sprintf("%*d", newWidth, dl.NewNumber) 101 125 prefix = " " 102 126 formatted = dl.Line 103 127 } 104 128 105 - display := fmt.Sprintf("%s %s", prefix, formatted) 106 - if len(display) > width-4 { 107 - display = display[:width-7] + "..." 129 + linePrefix := fmt.Sprintf("%s %s %s", Gray(oldNumStr), Gray(newNumStr), prefix) 130 + display := fmt.Sprintf("%s %s", linePrefix, formatted) 131 + 132 + // Adjust for actual display length considering ANSI codes 133 + if len(dl.Line) > width-oldWidth-newWidth-8 { 134 + formatted = formatted[:width-oldWidth-newWidth-11] + "..." 135 + display = fmt.Sprintf("%s %s", linePrefix, formatted) 108 136 } 137 + 109 138 sb.WriteString(fmt.Sprintf(" %s\n", display)) 110 139 } 111 140
+4 -3
internal/review/review.go
··· 28 28 result := make([]pretty.DiffLine, len(diffLines)) 29 29 for i, dl := range diffLines { 30 30 result[i] = pretty.DiffLine{ 31 - Number: dl.Number, 32 - Line: dl.Line, 33 - Kind: pretty.DiffKind(dl.Kind), 31 + OldNumber: dl.OldNumber, 32 + NewNumber: dl.NewNumber, 33 + Line: dl.Line, 34 + Kind: pretty.DiffKind(dl.Kind), 34 35 } 35 36 } 36 37 return result