Approval-based snapshot testing library for Go (mirror)

docs: documentation improvements

+518 -507
+14 -5
README.md
··· 12 12 go get github.com/ptdewey/shutter 13 13 ``` 14 14 15 + The review TUI is shipped separately to avoid adding unnecessary project dependencies (installation of the TUI is recommended): 16 + 17 + ```sh 18 + # executable is installed as `shutter` 19 + go install github.com/ptdewey/shutter/cmd/shutter@latest 20 + ``` 21 + 15 22 ## Usage 16 23 17 24 ### Basic Usage ··· 176 183 177 184 ### Reviewing Snapshots 178 185 179 - To review a set of snapshots, run: 186 + To review a set of snapshots, run (CLI version -- not recommended): 180 187 181 188 ```sh 182 - go run github.com/ptdewey/shutter/cmd/shutter review 189 + go run github.com/ptdewey/shutter/cmd/cli review 183 190 ``` 184 191 185 192 Shutter can also be used programmatically: ··· 206 213 (The TUI is shipped in a separate module to make the added dependencies optional) 207 214 208 215 ### TUI Usage 216 + 217 + After installing the TUI: 209 218 210 219 ```sh 211 - go run github.com/ptdewey/shutter/cmd/tui review 220 + shutter 212 221 ``` 213 222 214 223 #### Interactive Controls ··· 225 234 226 235 ```sh 227 236 # Accept all new snapshots without review 228 - go run github.com/ptdewey/shutter/cmd/tui accept-all 237 + shutter accept-all 229 238 230 239 # Reject all new snapshots without review 231 - go run github.com/ptdewey/shutter/cmd/tui reject-all 240 + shutter reject-all 232 241 ``` 233 242 234 243 ## Other Libraries
+57
cmd/cli/main.go
··· 1 + package main 2 + 3 + import ( 4 + "flag" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/ptdewey/shutter" 9 + ) 10 + 11 + func main() { 12 + flag.Usage = func() { 13 + fmt.Fprintf(os.Stderr, `Usage: shutter-cli [COMMAND] 14 + 15 + Commands: 16 + review Review and accept/reject new snapshots (default) 17 + accept-all Accept all new snapshots 18 + reject-all Reject all new snapshots 19 + help Show this help message 20 + 21 + Examples: 22 + shutter # Start interactive review 23 + shutter review # Same as above 24 + shutter accept-all # Accept all new snapshots 25 + shutter reject-all # Reject all new snapshots 26 + `) 27 + } 28 + 29 + flag.Parse() 30 + 31 + var cmd string 32 + if flag.NArg() > 0 { 33 + cmd = flag.Arg(0) 34 + } 35 + 36 + var err error 37 + switch cmd { 38 + case "", "review": 39 + err = shutter.Review() 40 + case "accept-all": 41 + err = shutter.AcceptAll() 42 + case "reject-all": 43 + err = shutter.RejectAll() 44 + case "help", "-h", "--help": 45 + flag.Usage() 46 + return 47 + default: 48 + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) 49 + flag.Usage() 50 + os.Exit(1) 51 + } 52 + 53 + if err != nil { 54 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 55 + os.Exit(1) 56 + } 57 + }
+433 -30
cmd/shutter/main.go
··· 1 1 package main 2 2 3 3 import ( 4 - "flag" 5 4 "fmt" 6 5 "os" 6 + "strings" 7 7 8 - "github.com/ptdewey/shutter" 8 + "github.com/charmbracelet/bubbles/viewport" 9 + tea "github.com/charmbracelet/bubbletea" 10 + "github.com/charmbracelet/lipgloss" 11 + "github.com/ptdewey/shutter/internal/diff" 12 + "github.com/ptdewey/shutter/internal/files" 13 + "github.com/ptdewey/shutter/internal/pretty" 9 14 ) 10 15 16 + // Styles 17 + var ( 18 + titleStyle = lipgloss.NewStyle(). 19 + Bold(true). 20 + Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}). 21 + Padding(0, 1) 22 + 23 + counterStyle = lipgloss.NewStyle(). 24 + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). 25 + Padding(0, 1) 26 + 27 + helpStyle = lipgloss.NewStyle(). 28 + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). 29 + Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). 30 + Padding(0, 0) 31 + 32 + statusBarStyle = lipgloss.NewStyle(). 33 + Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). 34 + Foreground(lipgloss.AdaptiveColor{Light: "7", Dark: "7"}) 35 + 36 + contentStyle = lipgloss.NewStyle(). 37 + Padding(1, 2) 38 + 39 + // Action styles with semantic colors 40 + acceptStyle = lipgloss.NewStyle(). 41 + Foreground(lipgloss.AdaptiveColor{Light: "2", Dark: "2"}). // Green 42 + Bold(true) 43 + 44 + rejectStyle = lipgloss.NewStyle(). 45 + Foreground(lipgloss.AdaptiveColor{Light: "1", Dark: "1"}). // Red 46 + Bold(true) 47 + 48 + skipStyle = lipgloss.NewStyle(). 49 + Foreground(lipgloss.AdaptiveColor{Light: "3", Dark: "3"}). // Yellow 50 + Bold(true) 51 + 52 + keyStyle = lipgloss.NewStyle(). 53 + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) 54 + 55 + helpTextStyle = lipgloss.NewStyle(). 56 + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) 57 + ) 58 + 59 + type model struct { 60 + snapshots []files.SnapshotInfo 61 + current int 62 + newSnap *files.Snapshot 63 + accepted *files.Snapshot 64 + diffLines []diff.DiffLine 65 + choice string 66 + done bool 67 + err error 68 + acceptedAll int 69 + rejectedAll int 70 + skippedAll int 71 + actionResult string 72 + viewport viewport.Model 73 + ready bool 74 + width int 75 + height int 76 + } 77 + 78 + func initialModel() (model, error) { 79 + snapshots, err := files.ListNewSnapshots() 80 + if err != nil { 81 + return model{}, err 82 + } 83 + 84 + if len(snapshots) == 0 { 85 + return model{done: true}, nil 86 + } 87 + 88 + m := model{ 89 + snapshots: snapshots, 90 + current: 0, 91 + } 92 + 93 + if err := m.loadCurrentSnapshot(); err != nil { 94 + return model{}, err 95 + } 96 + 97 + return m, nil 98 + } 99 + 100 + func (m *model) loadCurrentSnapshot() error { 101 + if m.current >= len(m.snapshots) { 102 + m.done = true 103 + return nil 104 + } 105 + 106 + snapshotInfo := m.snapshots[m.current] 107 + 108 + newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) 109 + if err != nil { 110 + return err 111 + } 112 + m.newSnap = newSnap 113 + 114 + accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") 115 + if err == nil { 116 + m.accepted = accepted 117 + diffLines := computeDiffLines(accepted, newSnap) 118 + m.diffLines = diffLines 119 + } else { 120 + m.accepted = nil 121 + m.diffLines = nil 122 + } 123 + 124 + return nil 125 + } 126 + 127 + func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { 128 + return diff.Histogram(old.Content, new.Content) 129 + } 130 + 131 + func (m model) Init() tea.Cmd { 132 + return tea.EnterAltScreen 133 + } 134 + 135 + func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 136 + var ( 137 + cmd tea.Cmd 138 + cmds []tea.Cmd 139 + ) 140 + 141 + switch msg := msg.(type) { 142 + case tea.WindowSizeMsg: 143 + m.width = msg.Width 144 + m.height = msg.Height 145 + 146 + headerHeight := 1 147 + footerHeight := 1 148 + verticalMarginHeight := headerHeight + footerHeight 149 + 150 + if !m.ready { 151 + m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 152 + m.viewport.YPosition = headerHeight 153 + m.ready = true 154 + m.updateViewportContent() 155 + } else { 156 + m.viewport.Width = msg.Width 157 + m.viewport.Height = msg.Height - verticalMarginHeight 158 + m.updateViewportContent() 159 + } 160 + 161 + case tea.KeyMsg: 162 + switch msg.String() { 163 + case "q", "ctrl+c", "esc": 164 + m.done = true 165 + return m, tea.Quit 166 + 167 + case "a": 168 + // Accept current snapshot 169 + snapshotInfo := m.snapshots[m.current] 170 + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 171 + m.err = err 172 + } else { 173 + m.acceptedAll++ 174 + m.current++ 175 + if err := m.loadCurrentSnapshot(); err != nil { 176 + m.err = err 177 + } 178 + if m.done { 179 + return m, tea.Quit 180 + } 181 + m.updateViewportContent() 182 + } 183 + 184 + case "r": 185 + // Reject current snapshot 186 + snapshotInfo := m.snapshots[m.current] 187 + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 188 + m.err = err 189 + } else { 190 + m.rejectedAll++ 191 + m.current++ 192 + if err := m.loadCurrentSnapshot(); err != nil { 193 + m.err = err 194 + } 195 + if m.done { 196 + return m, tea.Quit 197 + } 198 + m.updateViewportContent() 199 + } 200 + 201 + case "s": 202 + // Skip current snapshot 203 + m.skippedAll++ 204 + m.current++ 205 + if err := m.loadCurrentSnapshot(); err != nil { 206 + m.err = err 207 + } 208 + if m.done { 209 + return m, tea.Quit 210 + } 211 + m.updateViewportContent() 212 + 213 + case "A": 214 + // Accept all remaining 215 + for i := m.current; i < len(m.snapshots); i++ { 216 + if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil { 217 + m.err = err 218 + break 219 + } 220 + m.acceptedAll++ 221 + } 222 + m.done = true 223 + return m, tea.Quit 224 + 225 + case "R": 226 + // Reject all remaining 227 + for i := m.current; i < len(m.snapshots); i++ { 228 + if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil { 229 + m.err = err 230 + break 231 + } 232 + m.rejectedAll++ 233 + } 234 + m.done = true 235 + return m, tea.Quit 236 + 237 + case "S": 238 + // Skip all remaining 239 + m.skippedAll = len(m.snapshots) - m.current 240 + m.done = true 241 + return m, tea.Quit 242 + } 243 + } 244 + 245 + // Handle viewport scrolling 246 + m.viewport, cmd = m.viewport.Update(msg) 247 + cmds = append(cmds, cmd) 248 + 249 + return m, tea.Batch(cmds...) 250 + } 251 + 252 + func (m *model) updateViewportContent() { 253 + if !m.ready { 254 + return 255 + } 256 + 257 + var b strings.Builder 258 + 259 + // Show diff or new snapshot 260 + if m.accepted != nil && m.diffLines != nil { 261 + b.WriteString(pretty.DiffSnapshotBox(m.accepted, m.newSnap, m.diffLines)) 262 + } else { 263 + if m.newSnap != nil { 264 + b.WriteString(pretty.NewSnapshotBox(m.newSnap)) 265 + } 266 + } 267 + 268 + // Add action options below the snapshot/diff box 269 + b.WriteString("\n") 270 + acceptLine := lipgloss.JoinHorizontal(lipgloss.Left, 271 + keyStyle.Render("[a]"), 272 + helpTextStyle.Render(" "), 273 + acceptStyle.Render("accept"), 274 + ) 275 + b.WriteString(acceptLine) 276 + b.WriteString("\n") 277 + 278 + rejectLine := lipgloss.JoinHorizontal(lipgloss.Left, 279 + keyStyle.Render("[r]"), 280 + helpTextStyle.Render(" "), 281 + rejectStyle.Render("reject"), 282 + ) 283 + b.WriteString(rejectLine) 284 + b.WriteString("\n") 285 + 286 + skipLine := lipgloss.JoinHorizontal(lipgloss.Left, 287 + keyStyle.Render("[s]"), 288 + helpTextStyle.Render(" "), 289 + skipStyle.Render("skip"), 290 + ) 291 + b.WriteString(skipLine) 292 + 293 + m.viewport.SetContent(contentStyle.Render(b.String())) 294 + m.viewport.GotoTop() 295 + } 296 + 297 + func (m model) View() string { 298 + if m.done { 299 + if len(m.snapshots) == 0 { 300 + return pretty.Success("✓ No new snapshots to review\n") 301 + } 302 + 303 + // Build summary from counts 304 + var summary []string 305 + if m.acceptedAll > 0 { 306 + summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll)) 307 + } 308 + if m.rejectedAll > 0 { 309 + summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll)) 310 + } 311 + if m.skippedAll > 0 { 312 + summary = append(summary, fmt.Sprintf("⊘ Skipped %d", m.skippedAll)) 313 + } 314 + 315 + if len(summary) > 0 { 316 + return pretty.Success(strings.Join(summary, " • ") + "\n") 317 + } 318 + return pretty.Success("\n✓ Review complete\n") 319 + } 320 + 321 + if m.err != nil { 322 + return pretty.Error("Error: " + m.err.Error() + "\n") 323 + } 324 + 325 + if !m.ready { 326 + return "\n Initializing..." 327 + } 328 + 329 + // Header 330 + snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title 331 + if m.newSnap != nil && m.newSnap.Title != "" { 332 + snapshotTitle = m.newSnap.Title 333 + } 334 + header := lipgloss.JoinHorizontal( 335 + lipgloss.Left, 336 + titleStyle.Render("Review Snapshots"), 337 + counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), snapshotTitle)), 338 + ) 339 + headerStyled := statusBarStyle.Width(m.width).Render(header) 340 + 341 + // Footer with snapshot filename and scroll info 342 + snapshotFile := files.SnapshotFileName(m.snapshots[m.current].Title) + ".snap.new" 343 + fileInfo := helpStyle.Render(snapshotFile) 344 + scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) 345 + scrollStyled := helpStyle.Render(scrollInfo) 346 + 347 + // Calculate spacing between filename and scroll percentage 348 + totalFooterWidth := lipgloss.Width(fileInfo) + lipgloss.Width(scrollStyled) 349 + spacing := max(m.width-totalFooterWidth-2, 1) 350 + 351 + // Create footer with filename on left and scroll info on right 352 + footer := lipgloss.JoinHorizontal(lipgloss.Bottom, 353 + fileInfo, 354 + strings.Repeat(" ", spacing), 355 + scrollStyled, 356 + ) 357 + footerStyled := statusBarStyle.Width(m.width).Render(footer) 358 + 359 + // Viewport content 360 + // TODO: it would be nice if we could show the input on the right side? 361 + // - (probably optionally, with a keybind -- hidden by default) 362 + viewportContent := m.viewport.View() 363 + 364 + return lipgloss.JoinVertical( 365 + lipgloss.Left, 366 + headerStyled, 367 + viewportContent, 368 + footerStyled, 369 + ) 370 + } 371 + 372 + func acceptAll() error { 373 + snapshots, err := files.ListNewSnapshots() 374 + if err != nil { 375 + return err 376 + } 377 + 378 + for _, snapshotInfo := range snapshots { 379 + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 380 + return err 381 + } 382 + } 383 + 384 + fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)) 385 + return nil 386 + } 387 + 388 + func rejectAll() error { 389 + snapshots, err := files.ListNewSnapshots() 390 + if err != nil { 391 + return err 392 + } 393 + 394 + for _, snapshotInfo := range snapshots { 395 + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 396 + return err 397 + } 398 + } 399 + 400 + fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) 401 + return nil 402 + } 403 + 11 404 func main() { 12 - flag.Usage = func() { 13 - fmt.Fprintf(os.Stderr, `Usage: shutter [COMMAND] 405 + if len(os.Args) > 1 { 406 + switch os.Args[1] { 407 + case "accept-all": 408 + if err := acceptAll(); err != nil { 409 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 410 + os.Exit(1) 411 + } 412 + return 413 + case "reject-all": 414 + if err := rejectAll(); err != nil { 415 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 416 + os.Exit(1) 417 + } 418 + return 419 + case "help", "-h", "--help": 420 + fmt.Println(`Usage: shutter-tui [COMMAND] 14 421 15 422 Commands: 16 423 review Review and accept/reject new snapshots (default) ··· 18 425 reject-all Reject all new snapshots 19 426 help Show this help message 20 427 21 - Examples: 22 - shutter # Start interactive review 23 - shutter review # Same as above 24 - shutter accept-all # Accept all new snapshots 25 - shutter reject-all # Reject all new snapshots 26 - `) 428 + Interactive Controls: 429 + a Accept current snapshot 430 + r Reject current snapshot 431 + s Skip current snapshot 432 + A Accept all remaining snapshots 433 + R Reject all remaining snapshots 434 + S Skip all remaining snapshots 435 + q Quit`) 436 + return 437 + } 27 438 } 28 439 29 - flag.Parse() 30 - 31 - var cmd string 32 - if flag.NArg() > 0 { 33 - cmd = flag.Arg(0) 440 + m, err := initialModel() 441 + if err != nil { 442 + fmt.Fprintf(os.Stderr, "Error: %v\n", err) 443 + os.Exit(1) 34 444 } 35 445 36 - var err error 37 - switch cmd { 38 - case "", "review": 39 - err = shutter.Review() 40 - case "accept-all": 41 - err = shutter.AcceptAll() 42 - case "reject-all": 43 - err = shutter.RejectAll() 44 - case "help", "-h", "--help": 45 - flag.Usage() 446 + if m.done && len(m.snapshots) == 0 { 447 + fmt.Println(m.View()) 46 448 return 47 - default: 48 - fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) 49 - flag.Usage() 50 - os.Exit(1) 51 449 } 52 450 53 - if err != nil { 451 + p := tea.NewProgram( 452 + m, 453 + tea.WithAltScreen(), 454 + tea.WithMouseCellMotion(), 455 + ) 456 + if _, err := p.Run(); err != nil { 54 457 fmt.Fprintf(os.Stderr, "Error: %v\n", err) 55 458 os.Exit(1) 56 459 }
+1 -3
cmd/tui/go.mod cmd/shutter/go.mod
··· 6 6 github.com/charmbracelet/bubbles v0.21.0 7 7 github.com/charmbracelet/bubbletea v1.3.10 8 8 github.com/charmbracelet/lipgloss v1.1.0 9 - github.com/ptdewey/shutter v0.0.0 9 + github.com/ptdewey/shutter v0.1.3 10 10 ) 11 11 12 12 require ( ··· 28 28 golang.org/x/sys v0.36.0 // indirect 29 29 golang.org/x/text v0.3.8 // indirect 30 30 ) 31 - 32 - replace github.com/ptdewey/shutter => ../..
+2
cmd/tui/go.sum cmd/shutter/go.sum
··· 32 32 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 33 33 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 34 34 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 35 + github.com/ptdewey/shutter v0.1.3 h1:S/KIt+PotLtMrj7UQUlFzjS32UbwfyZGB9vay+pUDIU= 36 + github.com/ptdewey/shutter v0.1.3/go.mod h1:teeIXF4LdgsE9E4kjHk9nGzDxl2cjdbVb1qbdzAHSR4= 35 37 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 36 38 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 37 39 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-460
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/shutter/internal/diff" 12 - "github.com/ptdewey/shutter/internal/files" 13 - "github.com/ptdewey/shutter/internal/pretty" 14 - ) 15 - 16 - // Styles 17 - var ( 18 - titleStyle = lipgloss.NewStyle(). 19 - Bold(true). 20 - Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}). 21 - Padding(0, 1) 22 - 23 - counterStyle = lipgloss.NewStyle(). 24 - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). 25 - Padding(0, 1) 26 - 27 - helpStyle = lipgloss.NewStyle(). 28 - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). 29 - Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). 30 - Padding(0, 0) 31 - 32 - statusBarStyle = lipgloss.NewStyle(). 33 - Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). 34 - Foreground(lipgloss.AdaptiveColor{Light: "7", Dark: "7"}) 35 - 36 - contentStyle = lipgloss.NewStyle(). 37 - Padding(1, 2) 38 - 39 - // Action styles with semantic colors 40 - acceptStyle = lipgloss.NewStyle(). 41 - Foreground(lipgloss.AdaptiveColor{Light: "2", Dark: "2"}). // Green 42 - Bold(true) 43 - 44 - rejectStyle = lipgloss.NewStyle(). 45 - Foreground(lipgloss.AdaptiveColor{Light: "1", Dark: "1"}). // Red 46 - Bold(true) 47 - 48 - skipStyle = lipgloss.NewStyle(). 49 - Foreground(lipgloss.AdaptiveColor{Light: "3", Dark: "3"}). // Yellow 50 - Bold(true) 51 - 52 - keyStyle = lipgloss.NewStyle(). 53 - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) 54 - 55 - helpTextStyle = lipgloss.NewStyle(). 56 - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) 57 - ) 58 - 59 - type model struct { 60 - snapshots []files.SnapshotInfo 61 - current int 62 - newSnap *files.Snapshot 63 - accepted *files.Snapshot 64 - diffLines []diff.DiffLine 65 - choice string 66 - done bool 67 - err error 68 - acceptedAll int 69 - rejectedAll int 70 - skippedAll int 71 - actionResult string 72 - viewport viewport.Model 73 - ready bool 74 - width int 75 - height int 76 - } 77 - 78 - func initialModel() (model, error) { 79 - snapshots, err := files.ListNewSnapshots() 80 - if err != nil { 81 - return model{}, err 82 - } 83 - 84 - if len(snapshots) == 0 { 85 - return model{done: true}, nil 86 - } 87 - 88 - m := model{ 89 - snapshots: snapshots, 90 - current: 0, 91 - } 92 - 93 - if err := m.loadCurrentSnapshot(); err != nil { 94 - return model{}, err 95 - } 96 - 97 - return m, nil 98 - } 99 - 100 - func (m *model) loadCurrentSnapshot() error { 101 - if m.current >= len(m.snapshots) { 102 - m.done = true 103 - return nil 104 - } 105 - 106 - snapshotInfo := m.snapshots[m.current] 107 - 108 - newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) 109 - if err != nil { 110 - return err 111 - } 112 - m.newSnap = newSnap 113 - 114 - accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") 115 - if err == nil { 116 - m.accepted = accepted 117 - diffLines := computeDiffLines(accepted, newSnap) 118 - m.diffLines = diffLines 119 - } else { 120 - m.accepted = nil 121 - m.diffLines = nil 122 - } 123 - 124 - return nil 125 - } 126 - 127 - func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { 128 - return diff.Histogram(old.Content, new.Content) 129 - } 130 - 131 - func (m model) Init() tea.Cmd { 132 - return tea.EnterAltScreen 133 - } 134 - 135 - func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 136 - var ( 137 - cmd tea.Cmd 138 - cmds []tea.Cmd 139 - ) 140 - 141 - switch msg := msg.(type) { 142 - case tea.WindowSizeMsg: 143 - m.width = msg.Width 144 - m.height = msg.Height 145 - 146 - headerHeight := 1 147 - footerHeight := 1 148 - verticalMarginHeight := headerHeight + footerHeight 149 - 150 - if !m.ready { 151 - m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 152 - m.viewport.YPosition = headerHeight 153 - m.ready = true 154 - m.updateViewportContent() 155 - } else { 156 - m.viewport.Width = msg.Width 157 - m.viewport.Height = msg.Height - verticalMarginHeight 158 - m.updateViewportContent() 159 - } 160 - 161 - case tea.KeyMsg: 162 - switch msg.String() { 163 - case "q", "ctrl+c", "esc": 164 - m.done = true 165 - return m, tea.Quit 166 - 167 - case "a": 168 - // Accept current snapshot 169 - snapshotInfo := m.snapshots[m.current] 170 - if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 171 - m.err = err 172 - } else { 173 - m.acceptedAll++ 174 - m.current++ 175 - if err := m.loadCurrentSnapshot(); err != nil { 176 - m.err = err 177 - } 178 - if m.done { 179 - return m, tea.Quit 180 - } 181 - m.updateViewportContent() 182 - } 183 - 184 - case "r": 185 - // Reject current snapshot 186 - snapshotInfo := m.snapshots[m.current] 187 - if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 188 - m.err = err 189 - } else { 190 - m.rejectedAll++ 191 - m.current++ 192 - if err := m.loadCurrentSnapshot(); err != nil { 193 - m.err = err 194 - } 195 - if m.done { 196 - return m, tea.Quit 197 - } 198 - m.updateViewportContent() 199 - } 200 - 201 - case "s": 202 - // Skip current snapshot 203 - m.skippedAll++ 204 - m.current++ 205 - if err := m.loadCurrentSnapshot(); err != nil { 206 - m.err = err 207 - } 208 - if m.done { 209 - return m, tea.Quit 210 - } 211 - m.updateViewportContent() 212 - 213 - case "A": 214 - // Accept all remaining 215 - for i := m.current; i < len(m.snapshots); i++ { 216 - if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil { 217 - m.err = err 218 - break 219 - } 220 - m.acceptedAll++ 221 - } 222 - m.done = true 223 - return m, tea.Quit 224 - 225 - case "R": 226 - // Reject all remaining 227 - for i := m.current; i < len(m.snapshots); i++ { 228 - if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil { 229 - m.err = err 230 - break 231 - } 232 - m.rejectedAll++ 233 - } 234 - m.done = true 235 - return m, tea.Quit 236 - 237 - case "S": 238 - // Skip all remaining 239 - m.skippedAll = len(m.snapshots) - m.current 240 - m.done = true 241 - return m, tea.Quit 242 - } 243 - } 244 - 245 - // Handle viewport scrolling 246 - m.viewport, cmd = m.viewport.Update(msg) 247 - cmds = append(cmds, cmd) 248 - 249 - return m, tea.Batch(cmds...) 250 - } 251 - 252 - func (m *model) updateViewportContent() { 253 - if !m.ready { 254 - return 255 - } 256 - 257 - var b strings.Builder 258 - 259 - // Show diff or new snapshot 260 - if m.accepted != nil && m.diffLines != nil { 261 - b.WriteString(pretty.DiffSnapshotBox(m.accepted, m.newSnap, m.diffLines)) 262 - } else { 263 - if m.newSnap != nil { 264 - b.WriteString(pretty.NewSnapshotBox(m.newSnap)) 265 - } 266 - } 267 - 268 - // Add action options below the snapshot/diff box 269 - b.WriteString("\n") 270 - acceptLine := lipgloss.JoinHorizontal(lipgloss.Left, 271 - keyStyle.Render("[a]"), 272 - helpTextStyle.Render(" "), 273 - acceptStyle.Render("accept"), 274 - ) 275 - b.WriteString(acceptLine) 276 - b.WriteString("\n") 277 - 278 - rejectLine := lipgloss.JoinHorizontal(lipgloss.Left, 279 - keyStyle.Render("[r]"), 280 - helpTextStyle.Render(" "), 281 - rejectStyle.Render("reject"), 282 - ) 283 - b.WriteString(rejectLine) 284 - b.WriteString("\n") 285 - 286 - skipLine := lipgloss.JoinHorizontal(lipgloss.Left, 287 - keyStyle.Render("[s]"), 288 - helpTextStyle.Render(" "), 289 - skipStyle.Render("skip"), 290 - ) 291 - b.WriteString(skipLine) 292 - 293 - m.viewport.SetContent(contentStyle.Render(b.String())) 294 - m.viewport.GotoTop() 295 - } 296 - 297 - func (m model) View() string { 298 - if m.done { 299 - if len(m.snapshots) == 0 { 300 - return pretty.Success("✓ No new snapshots to review\n") 301 - } 302 - 303 - // Build summary from counts 304 - var summary []string 305 - if m.acceptedAll > 0 { 306 - summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll)) 307 - } 308 - if m.rejectedAll > 0 { 309 - summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll)) 310 - } 311 - if m.skippedAll > 0 { 312 - summary = append(summary, fmt.Sprintf("⊘ Skipped %d", m.skippedAll)) 313 - } 314 - 315 - if len(summary) > 0 { 316 - return pretty.Success(strings.Join(summary, " • ") + "\n") 317 - } 318 - return pretty.Success("\n✓ Review complete\n") 319 - } 320 - 321 - if m.err != nil { 322 - return pretty.Error("Error: " + m.err.Error() + "\n") 323 - } 324 - 325 - if !m.ready { 326 - return "\n Initializing..." 327 - } 328 - 329 - // Header 330 - snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title 331 - if m.newSnap != nil && m.newSnap.Title != "" { 332 - snapshotTitle = m.newSnap.Title 333 - } 334 - header := lipgloss.JoinHorizontal( 335 - lipgloss.Left, 336 - titleStyle.Render("Review Snapshots"), 337 - counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), snapshotTitle)), 338 - ) 339 - headerStyled := statusBarStyle.Width(m.width).Render(header) 340 - 341 - // Footer with snapshot filename and scroll info 342 - snapshotFile := files.SnapshotFileName(m.snapshots[m.current].Title) + ".snap.new" 343 - fileInfo := helpStyle.Render(snapshotFile) 344 - scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) 345 - scrollStyled := helpStyle.Render(scrollInfo) 346 - 347 - // Calculate spacing between filename and scroll percentage 348 - totalFooterWidth := lipgloss.Width(fileInfo) + lipgloss.Width(scrollStyled) 349 - spacing := max(m.width-totalFooterWidth-2, 1) 350 - 351 - // Create footer with filename on left and scroll info on right 352 - footer := lipgloss.JoinHorizontal(lipgloss.Bottom, 353 - fileInfo, 354 - strings.Repeat(" ", spacing), 355 - scrollStyled, 356 - ) 357 - footerStyled := statusBarStyle.Width(m.width).Render(footer) 358 - 359 - // Viewport content 360 - // TODO: it would be nice if we could show the input on the right side? 361 - // - (probably optionally, with a keybind -- hidden by default) 362 - viewportContent := m.viewport.View() 363 - 364 - return lipgloss.JoinVertical( 365 - lipgloss.Left, 366 - headerStyled, 367 - viewportContent, 368 - footerStyled, 369 - ) 370 - } 371 - 372 - func acceptAll() error { 373 - snapshots, err := files.ListNewSnapshots() 374 - if err != nil { 375 - return err 376 - } 377 - 378 - for _, snapshotInfo := range snapshots { 379 - if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { 380 - return err 381 - } 382 - } 383 - 384 - fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)) 385 - return nil 386 - } 387 - 388 - func rejectAll() error { 389 - snapshots, err := files.ListNewSnapshots() 390 - if err != nil { 391 - return err 392 - } 393 - 394 - for _, snapshotInfo := range snapshots { 395 - if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { 396 - return err 397 - } 398 - } 399 - 400 - fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) 401 - return nil 402 - } 403 - 404 - func main() { 405 - if len(os.Args) > 1 { 406 - switch os.Args[1] { 407 - case "accept-all": 408 - if err := acceptAll(); err != nil { 409 - fmt.Fprintf(os.Stderr, "Error: %v\n", err) 410 - os.Exit(1) 411 - } 412 - return 413 - case "reject-all": 414 - if err := rejectAll(); err != nil { 415 - fmt.Fprintf(os.Stderr, "Error: %v\n", err) 416 - os.Exit(1) 417 - } 418 - return 419 - case "help", "-h", "--help": 420 - fmt.Println(`Usage: shutter-tui [COMMAND] 421 - 422 - Commands: 423 - review Review and accept/reject new snapshots (default) 424 - accept-all Accept all new snapshots 425 - reject-all Reject all new snapshots 426 - help Show this help message 427 - 428 - Interactive Controls: 429 - a Accept current snapshot 430 - r Reject current snapshot 431 - s Skip current snapshot 432 - A Accept all remaining snapshots 433 - R Reject all remaining snapshots 434 - S Skip all remaining snapshots 435 - q Quit`) 436 - return 437 - } 438 - } 439 - 440 - m, err := initialModel() 441 - if err != nil { 442 - fmt.Fprintf(os.Stderr, "Error: %v\n", err) 443 - os.Exit(1) 444 - } 445 - 446 - if m.done && len(m.snapshots) == 0 { 447 - fmt.Println(m.View()) 448 - return 449 - } 450 - 451 - p := tea.NewProgram( 452 - m, 453 - tea.WithAltScreen(), 454 - tea.WithMouseCellMotion(), 455 - ) 456 - if _, err := p.Run(); err != nil { 457 - fmt.Fprintf(os.Stderr, "Error: %v\n", err) 458 - os.Exit(1) 459 - } 460 - }
+11 -9
justfile
··· 1 + [private] 2 + default: build review 3 + 4 + build: 5 + @pushd ./cmd/shutter && go build -o shutter ./main.go && popd 6 + 7 + review: 8 + @./cmd/shutter/shutter 9 + 1 10 clean-test: 2 11 @rm -rf ./__snapshots__ 3 12 @go test ./... -cover -coverprofile=cover.out ··· 5 14 test: 6 15 @go test ./... -cover -coverprofile=cover.out 7 16 8 - run: 9 - @go run cmd/shutter/main.go 17 + cli: 18 + @go run cmd/cli/main.go 10 19 11 20 clean: 12 21 @rm -rf ./__snapshots__ 13 - 14 - tui: 15 - @pushd ./cmd/tui && go build -o shutter ./main.go && popd 16 - @./cmd/tui/shutter 17 - 18 - review: 19 - @./cmd/tui/shutter