···1212go get github.com/ptdewey/shutter
1313```
14141515+The review TUI is shipped separately to avoid adding unnecessary project dependencies (installation of the TUI is recommended):
1616+1717+```sh
1818+# executable is installed as `shutter`
1919+go install github.com/ptdewey/shutter/cmd/shutter@latest
2020+```
2121+1522## Usage
16231724### Basic Usage
···176183177184### Reviewing Snapshots
178185179179-To review a set of snapshots, run:
186186+To review a set of snapshots, run (CLI version -- not recommended):
180187181188```sh
182182-go run github.com/ptdewey/shutter/cmd/shutter review
189189+go run github.com/ptdewey/shutter/cmd/cli review
183190```
184191185192Shutter can also be used programmatically:
···206213(The TUI is shipped in a separate module to make the added dependencies optional)
207214208215### TUI Usage
216216+217217+After installing the TUI:
209218210219```sh
211211-go run github.com/ptdewey/shutter/cmd/tui review
220220+shutter
212221```
213222214223#### Interactive Controls
···225234226235```sh
227236# Accept all new snapshots without review
228228-go run github.com/ptdewey/shutter/cmd/tui accept-all
237237+shutter accept-all
229238230239# Reject all new snapshots without review
231231-go run github.com/ptdewey/shutter/cmd/tui reject-all
240240+shutter reject-all
232241```
233242234243## Other Libraries
+57
cmd/cli/main.go
···11+package main
22+33+import (
44+ "flag"
55+ "fmt"
66+ "os"
77+88+ "github.com/ptdewey/shutter"
99+)
1010+1111+func main() {
1212+ flag.Usage = func() {
1313+ fmt.Fprintf(os.Stderr, `Usage: shutter-cli [COMMAND]
1414+1515+Commands:
1616+ review Review and accept/reject new snapshots (default)
1717+ accept-all Accept all new snapshots
1818+ reject-all Reject all new snapshots
1919+ help Show this help message
2020+2121+Examples:
2222+ shutter # Start interactive review
2323+ shutter review # Same as above
2424+ shutter accept-all # Accept all new snapshots
2525+ shutter reject-all # Reject all new snapshots
2626+`)
2727+ }
2828+2929+ flag.Parse()
3030+3131+ var cmd string
3232+ if flag.NArg() > 0 {
3333+ cmd = flag.Arg(0)
3434+ }
3535+3636+ var err error
3737+ switch cmd {
3838+ case "", "review":
3939+ err = shutter.Review()
4040+ case "accept-all":
4141+ err = shutter.AcceptAll()
4242+ case "reject-all":
4343+ err = shutter.RejectAll()
4444+ case "help", "-h", "--help":
4545+ flag.Usage()
4646+ return
4747+ default:
4848+ fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd)
4949+ flag.Usage()
5050+ os.Exit(1)
5151+ }
5252+5353+ if err != nil {
5454+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
5555+ os.Exit(1)
5656+ }
5757+}
+433-30
cmd/shutter/main.go
···11package main
2233import (
44- "flag"
54 "fmt"
65 "os"
66+ "strings"
7788- "github.com/ptdewey/shutter"
88+ "github.com/charmbracelet/bubbles/viewport"
99+ tea "github.com/charmbracelet/bubbletea"
1010+ "github.com/charmbracelet/lipgloss"
1111+ "github.com/ptdewey/shutter/internal/diff"
1212+ "github.com/ptdewey/shutter/internal/files"
1313+ "github.com/ptdewey/shutter/internal/pretty"
914)
10151616+// Styles
1717+var (
1818+ titleStyle = lipgloss.NewStyle().
1919+ Bold(true).
2020+ Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}).
2121+ Padding(0, 1)
2222+2323+ counterStyle = lipgloss.NewStyle().
2424+ Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}).
2525+ Padding(0, 1)
2626+2727+ helpStyle = lipgloss.NewStyle().
2828+ Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}).
2929+ Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}).
3030+ Padding(0, 0)
3131+3232+ statusBarStyle = lipgloss.NewStyle().
3333+ Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}).
3434+ Foreground(lipgloss.AdaptiveColor{Light: "7", Dark: "7"})
3535+3636+ contentStyle = lipgloss.NewStyle().
3737+ Padding(1, 2)
3838+3939+ // Action styles with semantic colors
4040+ acceptStyle = lipgloss.NewStyle().
4141+ Foreground(lipgloss.AdaptiveColor{Light: "2", Dark: "2"}). // Green
4242+ Bold(true)
4343+4444+ rejectStyle = lipgloss.NewStyle().
4545+ Foreground(lipgloss.AdaptiveColor{Light: "1", Dark: "1"}). // Red
4646+ Bold(true)
4747+4848+ skipStyle = lipgloss.NewStyle().
4949+ Foreground(lipgloss.AdaptiveColor{Light: "3", Dark: "3"}). // Yellow
5050+ Bold(true)
5151+5252+ keyStyle = lipgloss.NewStyle().
5353+ Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"})
5454+5555+ helpTextStyle = lipgloss.NewStyle().
5656+ Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"})
5757+)
5858+5959+type model struct {
6060+ snapshots []files.SnapshotInfo
6161+ current int
6262+ newSnap *files.Snapshot
6363+ accepted *files.Snapshot
6464+ diffLines []diff.DiffLine
6565+ choice string
6666+ done bool
6767+ err error
6868+ acceptedAll int
6969+ rejectedAll int
7070+ skippedAll int
7171+ actionResult string
7272+ viewport viewport.Model
7373+ ready bool
7474+ width int
7575+ height int
7676+}
7777+7878+func initialModel() (model, error) {
7979+ snapshots, err := files.ListNewSnapshots()
8080+ if err != nil {
8181+ return model{}, err
8282+ }
8383+8484+ if len(snapshots) == 0 {
8585+ return model{done: true}, nil
8686+ }
8787+8888+ m := model{
8989+ snapshots: snapshots,
9090+ current: 0,
9191+ }
9292+9393+ if err := m.loadCurrentSnapshot(); err != nil {
9494+ return model{}, err
9595+ }
9696+9797+ return m, nil
9898+}
9999+100100+func (m *model) loadCurrentSnapshot() error {
101101+ if m.current >= len(m.snapshots) {
102102+ m.done = true
103103+ return nil
104104+ }
105105+106106+ snapshotInfo := m.snapshots[m.current]
107107+108108+ newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path)
109109+ if err != nil {
110110+ return err
111111+ }
112112+ m.newSnap = newSnap
113113+114114+ accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted")
115115+ if err == nil {
116116+ m.accepted = accepted
117117+ diffLines := computeDiffLines(accepted, newSnap)
118118+ m.diffLines = diffLines
119119+ } else {
120120+ m.accepted = nil
121121+ m.diffLines = nil
122122+ }
123123+124124+ return nil
125125+}
126126+127127+func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine {
128128+ return diff.Histogram(old.Content, new.Content)
129129+}
130130+131131+func (m model) Init() tea.Cmd {
132132+ return tea.EnterAltScreen
133133+}
134134+135135+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
136136+ var (
137137+ cmd tea.Cmd
138138+ cmds []tea.Cmd
139139+ )
140140+141141+ switch msg := msg.(type) {
142142+ case tea.WindowSizeMsg:
143143+ m.width = msg.Width
144144+ m.height = msg.Height
145145+146146+ headerHeight := 1
147147+ footerHeight := 1
148148+ verticalMarginHeight := headerHeight + footerHeight
149149+150150+ if !m.ready {
151151+ m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
152152+ m.viewport.YPosition = headerHeight
153153+ m.ready = true
154154+ m.updateViewportContent()
155155+ } else {
156156+ m.viewport.Width = msg.Width
157157+ m.viewport.Height = msg.Height - verticalMarginHeight
158158+ m.updateViewportContent()
159159+ }
160160+161161+ case tea.KeyMsg:
162162+ switch msg.String() {
163163+ case "q", "ctrl+c", "esc":
164164+ m.done = true
165165+ return m, tea.Quit
166166+167167+ case "a":
168168+ // Accept current snapshot
169169+ snapshotInfo := m.snapshots[m.current]
170170+ if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil {
171171+ m.err = err
172172+ } else {
173173+ m.acceptedAll++
174174+ m.current++
175175+ if err := m.loadCurrentSnapshot(); err != nil {
176176+ m.err = err
177177+ }
178178+ if m.done {
179179+ return m, tea.Quit
180180+ }
181181+ m.updateViewportContent()
182182+ }
183183+184184+ case "r":
185185+ // Reject current snapshot
186186+ snapshotInfo := m.snapshots[m.current]
187187+ if err := files.RejectSnapshotInfo(snapshotInfo); err != nil {
188188+ m.err = err
189189+ } else {
190190+ m.rejectedAll++
191191+ m.current++
192192+ if err := m.loadCurrentSnapshot(); err != nil {
193193+ m.err = err
194194+ }
195195+ if m.done {
196196+ return m, tea.Quit
197197+ }
198198+ m.updateViewportContent()
199199+ }
200200+201201+ case "s":
202202+ // Skip current snapshot
203203+ m.skippedAll++
204204+ m.current++
205205+ if err := m.loadCurrentSnapshot(); err != nil {
206206+ m.err = err
207207+ }
208208+ if m.done {
209209+ return m, tea.Quit
210210+ }
211211+ m.updateViewportContent()
212212+213213+ case "A":
214214+ // Accept all remaining
215215+ for i := m.current; i < len(m.snapshots); i++ {
216216+ if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil {
217217+ m.err = err
218218+ break
219219+ }
220220+ m.acceptedAll++
221221+ }
222222+ m.done = true
223223+ return m, tea.Quit
224224+225225+ case "R":
226226+ // Reject all remaining
227227+ for i := m.current; i < len(m.snapshots); i++ {
228228+ if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil {
229229+ m.err = err
230230+ break
231231+ }
232232+ m.rejectedAll++
233233+ }
234234+ m.done = true
235235+ return m, tea.Quit
236236+237237+ case "S":
238238+ // Skip all remaining
239239+ m.skippedAll = len(m.snapshots) - m.current
240240+ m.done = true
241241+ return m, tea.Quit
242242+ }
243243+ }
244244+245245+ // Handle viewport scrolling
246246+ m.viewport, cmd = m.viewport.Update(msg)
247247+ cmds = append(cmds, cmd)
248248+249249+ return m, tea.Batch(cmds...)
250250+}
251251+252252+func (m *model) updateViewportContent() {
253253+ if !m.ready {
254254+ return
255255+ }
256256+257257+ var b strings.Builder
258258+259259+ // Show diff or new snapshot
260260+ if m.accepted != nil && m.diffLines != nil {
261261+ b.WriteString(pretty.DiffSnapshotBox(m.accepted, m.newSnap, m.diffLines))
262262+ } else {
263263+ if m.newSnap != nil {
264264+ b.WriteString(pretty.NewSnapshotBox(m.newSnap))
265265+ }
266266+ }
267267+268268+ // Add action options below the snapshot/diff box
269269+ b.WriteString("\n")
270270+ acceptLine := lipgloss.JoinHorizontal(lipgloss.Left,
271271+ keyStyle.Render("[a]"),
272272+ helpTextStyle.Render(" "),
273273+ acceptStyle.Render("accept"),
274274+ )
275275+ b.WriteString(acceptLine)
276276+ b.WriteString("\n")
277277+278278+ rejectLine := lipgloss.JoinHorizontal(lipgloss.Left,
279279+ keyStyle.Render("[r]"),
280280+ helpTextStyle.Render(" "),
281281+ rejectStyle.Render("reject"),
282282+ )
283283+ b.WriteString(rejectLine)
284284+ b.WriteString("\n")
285285+286286+ skipLine := lipgloss.JoinHorizontal(lipgloss.Left,
287287+ keyStyle.Render("[s]"),
288288+ helpTextStyle.Render(" "),
289289+ skipStyle.Render("skip"),
290290+ )
291291+ b.WriteString(skipLine)
292292+293293+ m.viewport.SetContent(contentStyle.Render(b.String()))
294294+ m.viewport.GotoTop()
295295+}
296296+297297+func (m model) View() string {
298298+ if m.done {
299299+ if len(m.snapshots) == 0 {
300300+ return pretty.Success("✓ No new snapshots to review\n")
301301+ }
302302+303303+ // Build summary from counts
304304+ var summary []string
305305+ if m.acceptedAll > 0 {
306306+ summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll))
307307+ }
308308+ if m.rejectedAll > 0 {
309309+ summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll))
310310+ }
311311+ if m.skippedAll > 0 {
312312+ summary = append(summary, fmt.Sprintf("⊘ Skipped %d", m.skippedAll))
313313+ }
314314+315315+ if len(summary) > 0 {
316316+ return pretty.Success(strings.Join(summary, " • ") + "\n")
317317+ }
318318+ return pretty.Success("\n✓ Review complete\n")
319319+ }
320320+321321+ if m.err != nil {
322322+ return pretty.Error("Error: " + m.err.Error() + "\n")
323323+ }
324324+325325+ if !m.ready {
326326+ return "\n Initializing..."
327327+ }
328328+329329+ // Header
330330+ snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title
331331+ if m.newSnap != nil && m.newSnap.Title != "" {
332332+ snapshotTitle = m.newSnap.Title
333333+ }
334334+ header := lipgloss.JoinHorizontal(
335335+ lipgloss.Left,
336336+ titleStyle.Render("Review Snapshots"),
337337+ counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), snapshotTitle)),
338338+ )
339339+ headerStyled := statusBarStyle.Width(m.width).Render(header)
340340+341341+ // Footer with snapshot filename and scroll info
342342+ snapshotFile := files.SnapshotFileName(m.snapshots[m.current].Title) + ".snap.new"
343343+ fileInfo := helpStyle.Render(snapshotFile)
344344+ scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)
345345+ scrollStyled := helpStyle.Render(scrollInfo)
346346+347347+ // Calculate spacing between filename and scroll percentage
348348+ totalFooterWidth := lipgloss.Width(fileInfo) + lipgloss.Width(scrollStyled)
349349+ spacing := max(m.width-totalFooterWidth-2, 1)
350350+351351+ // Create footer with filename on left and scroll info on right
352352+ footer := lipgloss.JoinHorizontal(lipgloss.Bottom,
353353+ fileInfo,
354354+ strings.Repeat(" ", spacing),
355355+ scrollStyled,
356356+ )
357357+ footerStyled := statusBarStyle.Width(m.width).Render(footer)
358358+359359+ // Viewport content
360360+ // TODO: it would be nice if we could show the input on the right side?
361361+ // - (probably optionally, with a keybind -- hidden by default)
362362+ viewportContent := m.viewport.View()
363363+364364+ return lipgloss.JoinVertical(
365365+ lipgloss.Left,
366366+ headerStyled,
367367+ viewportContent,
368368+ footerStyled,
369369+ )
370370+}
371371+372372+func acceptAll() error {
373373+ snapshots, err := files.ListNewSnapshots()
374374+ if err != nil {
375375+ return err
376376+ }
377377+378378+ for _, snapshotInfo := range snapshots {
379379+ if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil {
380380+ return err
381381+ }
382382+ }
383383+384384+ fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots))
385385+ return nil
386386+}
387387+388388+func rejectAll() error {
389389+ snapshots, err := files.ListNewSnapshots()
390390+ if err != nil {
391391+ return err
392392+ }
393393+394394+ for _, snapshotInfo := range snapshots {
395395+ if err := files.RejectSnapshotInfo(snapshotInfo); err != nil {
396396+ return err
397397+ }
398398+ }
399399+400400+ fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots))
401401+ return nil
402402+}
403403+11404func main() {
1212- flag.Usage = func() {
1313- fmt.Fprintf(os.Stderr, `Usage: shutter [COMMAND]
405405+ if len(os.Args) > 1 {
406406+ switch os.Args[1] {
407407+ case "accept-all":
408408+ if err := acceptAll(); err != nil {
409409+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
410410+ os.Exit(1)
411411+ }
412412+ return
413413+ case "reject-all":
414414+ if err := rejectAll(); err != nil {
415415+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
416416+ os.Exit(1)
417417+ }
418418+ return
419419+ case "help", "-h", "--help":
420420+ fmt.Println(`Usage: shutter-tui [COMMAND]
1442115422Commands:
16423 review Review and accept/reject new snapshots (default)
···18425 reject-all Reject all new snapshots
19426 help Show this help message
204272121-Examples:
2222- shutter # Start interactive review
2323- shutter review # Same as above
2424- shutter accept-all # Accept all new snapshots
2525- shutter reject-all # Reject all new snapshots
2626-`)
428428+Interactive Controls:
429429+ a Accept current snapshot
430430+ r Reject current snapshot
431431+ s Skip current snapshot
432432+ A Accept all remaining snapshots
433433+ R Reject all remaining snapshots
434434+ S Skip all remaining snapshots
435435+ q Quit`)
436436+ return
437437+ }
27438 }
284392929- flag.Parse()
3030-3131- var cmd string
3232- if flag.NArg() > 0 {
3333- cmd = flag.Arg(0)
440440+ m, err := initialModel()
441441+ if err != nil {
442442+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
443443+ os.Exit(1)
34444 }
354453636- var err error
3737- switch cmd {
3838- case "", "review":
3939- err = shutter.Review()
4040- case "accept-all":
4141- err = shutter.AcceptAll()
4242- case "reject-all":
4343- err = shutter.RejectAll()
4444- case "help", "-h", "--help":
4545- flag.Usage()
446446+ if m.done && len(m.snapshots) == 0 {
447447+ fmt.Println(m.View())
46448 return
4747- default:
4848- fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd)
4949- flag.Usage()
5050- os.Exit(1)
51449 }
524505353- if err != nil {
451451+ p := tea.NewProgram(
452452+ m,
453453+ tea.WithAltScreen(),
454454+ tea.WithMouseCellMotion(),
455455+ )
456456+ if _, err := p.Run(); err != nil {
54457 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
55458 os.Exit(1)
56459 }