diff --git a/README.md b/README.md index dd493fa..418cec1 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,13 @@ A [birdie](https://github.com/giacomocavalieri/birdie) and [insta](https://githu go get github.com/ptdewey/shutter ``` +The review TUI is shipped separately to avoid adding unnecessary project dependencies (installation of the TUI is recommended): + +```sh +# executable is installed as `shutter` +go install github.com/ptdewey/shutter/cmd/shutter@latest +``` + ## Usage ### Basic Usage @@ -176,10 +183,10 @@ shutter.SnapString(t, "title", content, options...) ### Reviewing Snapshots -To review a set of snapshots, run: +To review a set of snapshots, run (CLI version -- not recommended): ```sh -go run github.com/ptdewey/shutter/cmd/shutter review +go run github.com/ptdewey/shutter/cmd/cli review ``` Shutter can also be used programmatically: @@ -207,8 +214,10 @@ Shutter also includes (in a separate Go module) a [Bubbletea](https://github.com ### TUI Usage +After installing the TUI: + ```sh -go run github.com/ptdewey/shutter/cmd/tui review +shutter ``` #### Interactive Controls @@ -225,10 +234,10 @@ go run github.com/ptdewey/shutter/cmd/tui review ```sh # Accept all new snapshots without review -go run github.com/ptdewey/shutter/cmd/tui accept-all +shutter accept-all # Reject all new snapshots without review -go run github.com/ptdewey/shutter/cmd/tui reject-all +shutter reject-all ``` ## Other Libraries diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..213f0ea --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/ptdewey/shutter" +) + +func main() { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, `Usage: shutter-cli [COMMAND] + +Commands: + review Review and accept/reject new snapshots (default) + accept-all Accept all new snapshots + reject-all Reject all new snapshots + help Show this help message + +Examples: + shutter # Start interactive review + shutter review # Same as above + shutter accept-all # Accept all new snapshots + shutter reject-all # Reject all new snapshots +`) + } + + flag.Parse() + + var cmd string + if flag.NArg() > 0 { + cmd = flag.Arg(0) + } + + var err error + switch cmd { + case "", "review": + err = shutter.Review() + case "accept-all": + err = shutter.AcceptAll() + case "reject-all": + err = shutter.RejectAll() + case "help", "-h", "--help": + flag.Usage() + return + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) + flag.Usage() + os.Exit(1) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/tui/go.mod b/cmd/shutter/go.mod similarity index 93% rename from cmd/tui/go.mod rename to cmd/shutter/go.mod index 0c88115..0f5f3a8 100644 --- a/cmd/tui/go.mod +++ b/cmd/shutter/go.mod @@ -6,7 +6,7 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/ptdewey/shutter v0.0.0 + github.com/ptdewey/shutter v0.1.3 ) require ( @@ -28,5 +28,3 @@ require ( golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.3.8 // indirect ) - -replace github.com/ptdewey/shutter => ../.. diff --git a/cmd/tui/go.sum b/cmd/shutter/go.sum similarity index 96% rename from cmd/tui/go.sum rename to cmd/shutter/go.sum index c152eba..865f3e0 100644 --- a/cmd/tui/go.sum +++ b/cmd/shutter/go.sum @@ -32,6 +32,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/ptdewey/shutter v0.1.3 h1:S/KIt+PotLtMrj7UQUlFzjS32UbwfyZGB9vay+pUDIU= +github.com/ptdewey/shutter v0.1.3/go.mod h1:teeIXF4LdgsE9E4kjHk9nGzDxl2cjdbVb1qbdzAHSR4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/cmd/shutter/main.go b/cmd/shutter/main.go index bc8175c..c1da124 100644 --- a/cmd/shutter/main.go +++ b/cmd/shutter/main.go @@ -1,16 +1,423 @@ package main import ( - "flag" "fmt" "os" + "strings" - "github.com/ptdewey/shutter" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ptdewey/shutter/internal/diff" + "github.com/ptdewey/shutter/internal/files" + "github.com/ptdewey/shutter/internal/pretty" ) +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}). + Padding(0, 1) + + counterStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). + Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). + Padding(0, 0) + + statusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). + Foreground(lipgloss.AdaptiveColor{Light: "7", Dark: "7"}) + + contentStyle = lipgloss.NewStyle(). + Padding(1, 2) + + // Action styles with semantic colors + acceptStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "2", Dark: "2"}). // Green + Bold(true) + + rejectStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "1", Dark: "1"}). // Red + Bold(true) + + skipStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "3", Dark: "3"}). // Yellow + Bold(true) + + keyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) + + helpTextStyle = lipgloss.NewStyle(). + Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) +) + +type model struct { + snapshots []files.SnapshotInfo + current int + newSnap *files.Snapshot + accepted *files.Snapshot + diffLines []diff.DiffLine + choice string + done bool + err error + acceptedAll int + rejectedAll int + skippedAll int + actionResult string + viewport viewport.Model + ready bool + width int + height int +} + +func initialModel() (model, error) { + snapshots, err := files.ListNewSnapshots() + if err != nil { + return model{}, err + } + + if len(snapshots) == 0 { + return model{done: true}, nil + } + + m := model{ + snapshots: snapshots, + current: 0, + } + + if err := m.loadCurrentSnapshot(); err != nil { + return model{}, err + } + + return m, nil +} + +func (m *model) loadCurrentSnapshot() error { + if m.current >= len(m.snapshots) { + m.done = true + return nil + } + + snapshotInfo := m.snapshots[m.current] + + newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) + if err != nil { + return err + } + m.newSnap = newSnap + + accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") + if err == nil { + m.accepted = accepted + diffLines := computeDiffLines(accepted, newSnap) + m.diffLines = diffLines + } else { + m.accepted = nil + m.diffLines = nil + } + + return nil +} + +func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { + return diff.Histogram(old.Content, new.Content) +} + +func (m model) Init() tea.Cmd { + return tea.EnterAltScreen +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + headerHeight := 1 + footerHeight := 1 + verticalMarginHeight := headerHeight + footerHeight + + if !m.ready { + m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) + m.viewport.YPosition = headerHeight + m.ready = true + m.updateViewportContent() + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - verticalMarginHeight + m.updateViewportContent() + } + + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c", "esc": + m.done = true + return m, tea.Quit + + case "a": + // Accept current snapshot + snapshotInfo := m.snapshots[m.current] + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { + m.err = err + } else { + m.acceptedAll++ + m.current++ + if err := m.loadCurrentSnapshot(); err != nil { + m.err = err + } + if m.done { + return m, tea.Quit + } + m.updateViewportContent() + } + + case "r": + // Reject current snapshot + snapshotInfo := m.snapshots[m.current] + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { + m.err = err + } else { + m.rejectedAll++ + m.current++ + if err := m.loadCurrentSnapshot(); err != nil { + m.err = err + } + if m.done { + return m, tea.Quit + } + m.updateViewportContent() + } + + case "s": + // Skip current snapshot + m.skippedAll++ + m.current++ + if err := m.loadCurrentSnapshot(); err != nil { + m.err = err + } + if m.done { + return m, tea.Quit + } + m.updateViewportContent() + + case "A": + // Accept all remaining + for i := m.current; i < len(m.snapshots); i++ { + if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil { + m.err = err + break + } + m.acceptedAll++ + } + m.done = true + return m, tea.Quit + + case "R": + // Reject all remaining + for i := m.current; i < len(m.snapshots); i++ { + if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil { + m.err = err + break + } + m.rejectedAll++ + } + m.done = true + return m, tea.Quit + + case "S": + // Skip all remaining + m.skippedAll = len(m.snapshots) - m.current + m.done = true + return m, tea.Quit + } + } + + // Handle viewport scrolling + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *model) updateViewportContent() { + if !m.ready { + return + } + + var b strings.Builder + + // Show diff or new snapshot + if m.accepted != nil && m.diffLines != nil { + b.WriteString(pretty.DiffSnapshotBox(m.accepted, m.newSnap, m.diffLines)) + } else { + if m.newSnap != nil { + b.WriteString(pretty.NewSnapshotBox(m.newSnap)) + } + } + + // Add action options below the snapshot/diff box + b.WriteString("\n") + acceptLine := lipgloss.JoinHorizontal(lipgloss.Left, + keyStyle.Render("[a]"), + helpTextStyle.Render(" "), + acceptStyle.Render("accept"), + ) + b.WriteString(acceptLine) + b.WriteString("\n") + + rejectLine := lipgloss.JoinHorizontal(lipgloss.Left, + keyStyle.Render("[r]"), + helpTextStyle.Render(" "), + rejectStyle.Render("reject"), + ) + b.WriteString(rejectLine) + b.WriteString("\n") + + skipLine := lipgloss.JoinHorizontal(lipgloss.Left, + keyStyle.Render("[s]"), + helpTextStyle.Render(" "), + skipStyle.Render("skip"), + ) + b.WriteString(skipLine) + + m.viewport.SetContent(contentStyle.Render(b.String())) + m.viewport.GotoTop() +} + +func (m model) View() string { + if m.done { + if len(m.snapshots) == 0 { + return pretty.Success("✓ No new snapshots to review\n") + } + + // Build summary from counts + var summary []string + if m.acceptedAll > 0 { + summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll)) + } + if m.rejectedAll > 0 { + summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll)) + } + if m.skippedAll > 0 { + summary = append(summary, fmt.Sprintf("⊘ Skipped %d", m.skippedAll)) + } + + if len(summary) > 0 { + return pretty.Success(strings.Join(summary, " • ") + "\n") + } + return pretty.Success("\n✓ Review complete\n") + } + + if m.err != nil { + return pretty.Error("Error: " + m.err.Error() + "\n") + } + + if !m.ready { + return "\n Initializing..." + } + + // Header + snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title + if m.newSnap != nil && m.newSnap.Title != "" { + snapshotTitle = m.newSnap.Title + } + header := lipgloss.JoinHorizontal( + lipgloss.Left, + titleStyle.Render("Review Snapshots"), + counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), snapshotTitle)), + ) + headerStyled := statusBarStyle.Width(m.width).Render(header) + + // Footer with snapshot filename and scroll info + snapshotFile := files.SnapshotFileName(m.snapshots[m.current].Title) + ".snap.new" + fileInfo := helpStyle.Render(snapshotFile) + scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) + scrollStyled := helpStyle.Render(scrollInfo) + + // Calculate spacing between filename and scroll percentage + totalFooterWidth := lipgloss.Width(fileInfo) + lipgloss.Width(scrollStyled) + spacing := max(m.width-totalFooterWidth-2, 1) + + // Create footer with filename on left and scroll info on right + footer := lipgloss.JoinHorizontal(lipgloss.Bottom, + fileInfo, + strings.Repeat(" ", spacing), + scrollStyled, + ) + footerStyled := statusBarStyle.Width(m.width).Render(footer) + + // Viewport content + // TODO: it would be nice if we could show the input on the right side? + // - (probably optionally, with a keybind -- hidden by default) + viewportContent := m.viewport.View() + + return lipgloss.JoinVertical( + lipgloss.Left, + headerStyled, + viewportContent, + footerStyled, + ) +} + +func acceptAll() error { + snapshots, err := files.ListNewSnapshots() + if err != nil { + return err + } + + for _, snapshotInfo := range snapshots { + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { + return err + } + } + + fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)) + return nil +} + +func rejectAll() error { + snapshots, err := files.ListNewSnapshots() + if err != nil { + return err + } + + for _, snapshotInfo := range snapshots { + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { + return err + } + } + + fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) + return nil +} + func main() { - flag.Usage = func() { - fmt.Fprintf(os.Stderr, `Usage: shutter [COMMAND] + if len(os.Args) > 1 { + switch os.Args[1] { + case "accept-all": + if err := acceptAll(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + case "reject-all": + if err := rejectAll(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + case "help", "-h", "--help": + fmt.Println(`Usage: shutter-tui [COMMAND] Commands: review Review and accept/reject new snapshots (default) @@ -18,39 +425,35 @@ Commands: reject-all Reject all new snapshots help Show this help message -Examples: - shutter # Start interactive review - shutter review # Same as above - shutter accept-all # Accept all new snapshots - shutter reject-all # Reject all new snapshots -`) +Interactive Controls: + a Accept current snapshot + r Reject current snapshot + s Skip current snapshot + A Accept all remaining snapshots + R Reject all remaining snapshots + S Skip all remaining snapshots + q Quit`) + return + } } - flag.Parse() - - var cmd string - if flag.NArg() > 0 { - cmd = flag.Arg(0) + m, err := initialModel() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } - var err error - switch cmd { - case "", "review": - err = shutter.Review() - case "accept-all": - err = shutter.AcceptAll() - case "reject-all": - err = shutter.RejectAll() - case "help", "-h", "--help": - flag.Usage() + if m.done && len(m.snapshots) == 0 { + fmt.Println(m.View()) return - default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) - flag.Usage() - os.Exit(1) } - if err != nil { + p := tea.NewProgram( + m, + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } diff --git a/cmd/tui/main.go b/cmd/tui/main.go deleted file mode 100644 index c1da124..0000000 --- a/cmd/tui/main.go +++ /dev/null @@ -1,460 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/ptdewey/shutter/internal/diff" - "github.com/ptdewey/shutter/internal/files" - "github.com/ptdewey/shutter/internal/pretty" -) - -// Styles -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.AdaptiveColor{Light: "5", Dark: "5"}). - Padding(0, 1) - - counterStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). - Padding(0, 1) - - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}). - Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). - Padding(0, 0) - - statusBarStyle = lipgloss.NewStyle(). - Background(lipgloss.AdaptiveColor{Light: "0", Dark: "0"}). - Foreground(lipgloss.AdaptiveColor{Light: "7", Dark: "7"}) - - contentStyle = lipgloss.NewStyle(). - Padding(1, 2) - - // Action styles with semantic colors - acceptStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "2", Dark: "2"}). // Green - Bold(true) - - rejectStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "1", Dark: "1"}). // Red - Bold(true) - - skipStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "3", Dark: "3"}). // Yellow - Bold(true) - - keyStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) - - helpTextStyle = lipgloss.NewStyle(). - Foreground(lipgloss.AdaptiveColor{Light: "8", Dark: "8"}) -) - -type model struct { - snapshots []files.SnapshotInfo - current int - newSnap *files.Snapshot - accepted *files.Snapshot - diffLines []diff.DiffLine - choice string - done bool - err error - acceptedAll int - rejectedAll int - skippedAll int - actionResult string - viewport viewport.Model - ready bool - width int - height int -} - -func initialModel() (model, error) { - snapshots, err := files.ListNewSnapshots() - if err != nil { - return model{}, err - } - - if len(snapshots) == 0 { - return model{done: true}, nil - } - - m := model{ - snapshots: snapshots, - current: 0, - } - - if err := m.loadCurrentSnapshot(); err != nil { - return model{}, err - } - - return m, nil -} - -func (m *model) loadCurrentSnapshot() error { - if m.current >= len(m.snapshots) { - m.done = true - return nil - } - - snapshotInfo := m.snapshots[m.current] - - newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) - if err != nil { - return err - } - m.newSnap = newSnap - - accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") - if err == nil { - m.accepted = accepted - diffLines := computeDiffLines(accepted, newSnap) - m.diffLines = diffLines - } else { - m.accepted = nil - m.diffLines = nil - } - - return nil -} - -func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { - return diff.Histogram(old.Content, new.Content) -} - -func (m model) Init() tea.Cmd { - return tea.EnterAltScreen -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var ( - cmd tea.Cmd - cmds []tea.Cmd - ) - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - headerHeight := 1 - footerHeight := 1 - verticalMarginHeight := headerHeight + footerHeight - - if !m.ready { - m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) - m.viewport.YPosition = headerHeight - m.ready = true - m.updateViewportContent() - } else { - m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - verticalMarginHeight - m.updateViewportContent() - } - - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c", "esc": - m.done = true - return m, tea.Quit - - case "a": - // Accept current snapshot - snapshotInfo := m.snapshots[m.current] - if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { - m.err = err - } else { - m.acceptedAll++ - m.current++ - if err := m.loadCurrentSnapshot(); err != nil { - m.err = err - } - if m.done { - return m, tea.Quit - } - m.updateViewportContent() - } - - case "r": - // Reject current snapshot - snapshotInfo := m.snapshots[m.current] - if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { - m.err = err - } else { - m.rejectedAll++ - m.current++ - if err := m.loadCurrentSnapshot(); err != nil { - m.err = err - } - if m.done { - return m, tea.Quit - } - m.updateViewportContent() - } - - case "s": - // Skip current snapshot - m.skippedAll++ - m.current++ - if err := m.loadCurrentSnapshot(); err != nil { - m.err = err - } - if m.done { - return m, tea.Quit - } - m.updateViewportContent() - - case "A": - // Accept all remaining - for i := m.current; i < len(m.snapshots); i++ { - if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil { - m.err = err - break - } - m.acceptedAll++ - } - m.done = true - return m, tea.Quit - - case "R": - // Reject all remaining - for i := m.current; i < len(m.snapshots); i++ { - if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil { - m.err = err - break - } - m.rejectedAll++ - } - m.done = true - return m, tea.Quit - - case "S": - // Skip all remaining - m.skippedAll = len(m.snapshots) - m.current - m.done = true - return m, tea.Quit - } - } - - // Handle viewport scrolling - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m *model) updateViewportContent() { - if !m.ready { - return - } - - var b strings.Builder - - // Show diff or new snapshot - if m.accepted != nil && m.diffLines != nil { - b.WriteString(pretty.DiffSnapshotBox(m.accepted, m.newSnap, m.diffLines)) - } else { - if m.newSnap != nil { - b.WriteString(pretty.NewSnapshotBox(m.newSnap)) - } - } - - // Add action options below the snapshot/diff box - b.WriteString("\n") - acceptLine := lipgloss.JoinHorizontal(lipgloss.Left, - keyStyle.Render("[a]"), - helpTextStyle.Render(" "), - acceptStyle.Render("accept"), - ) - b.WriteString(acceptLine) - b.WriteString("\n") - - rejectLine := lipgloss.JoinHorizontal(lipgloss.Left, - keyStyle.Render("[r]"), - helpTextStyle.Render(" "), - rejectStyle.Render("reject"), - ) - b.WriteString(rejectLine) - b.WriteString("\n") - - skipLine := lipgloss.JoinHorizontal(lipgloss.Left, - keyStyle.Render("[s]"), - helpTextStyle.Render(" "), - skipStyle.Render("skip"), - ) - b.WriteString(skipLine) - - m.viewport.SetContent(contentStyle.Render(b.String())) - m.viewport.GotoTop() -} - -func (m model) View() string { - if m.done { - if len(m.snapshots) == 0 { - return pretty.Success("✓ No new snapshots to review\n") - } - - // Build summary from counts - var summary []string - if m.acceptedAll > 0 { - summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll)) - } - if m.rejectedAll > 0 { - summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll)) - } - if m.skippedAll > 0 { - summary = append(summary, fmt.Sprintf("⊘ Skipped %d", m.skippedAll)) - } - - if len(summary) > 0 { - return pretty.Success(strings.Join(summary, " • ") + "\n") - } - return pretty.Success("\n✓ Review complete\n") - } - - if m.err != nil { - return pretty.Error("Error: " + m.err.Error() + "\n") - } - - if !m.ready { - return "\n Initializing..." - } - - // Header - snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title - if m.newSnap != nil && m.newSnap.Title != "" { - snapshotTitle = m.newSnap.Title - } - header := lipgloss.JoinHorizontal( - lipgloss.Left, - titleStyle.Render("Review Snapshots"), - counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), snapshotTitle)), - ) - headerStyled := statusBarStyle.Width(m.width).Render(header) - - // Footer with snapshot filename and scroll info - snapshotFile := files.SnapshotFileName(m.snapshots[m.current].Title) + ".snap.new" - fileInfo := helpStyle.Render(snapshotFile) - scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) - scrollStyled := helpStyle.Render(scrollInfo) - - // Calculate spacing between filename and scroll percentage - totalFooterWidth := lipgloss.Width(fileInfo) + lipgloss.Width(scrollStyled) - spacing := max(m.width-totalFooterWidth-2, 1) - - // Create footer with filename on left and scroll info on right - footer := lipgloss.JoinHorizontal(lipgloss.Bottom, - fileInfo, - strings.Repeat(" ", spacing), - scrollStyled, - ) - footerStyled := statusBarStyle.Width(m.width).Render(footer) - - // Viewport content - // TODO: it would be nice if we could show the input on the right side? - // - (probably optionally, with a keybind -- hidden by default) - viewportContent := m.viewport.View() - - return lipgloss.JoinVertical( - lipgloss.Left, - headerStyled, - viewportContent, - footerStyled, - ) -} - -func acceptAll() error { - snapshots, err := files.ListNewSnapshots() - if err != nil { - return err - } - - for _, snapshotInfo := range snapshots { - if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { - return err - } - } - - fmt.Printf(pretty.Success("✓ Accepted %d snapshot(s)\n"), len(snapshots)) - return nil -} - -func rejectAll() error { - snapshots, err := files.ListNewSnapshots() - if err != nil { - return err - } - - for _, snapshotInfo := range snapshots { - if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { - return err - } - } - - fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) - return nil -} - -func main() { - if len(os.Args) > 1 { - switch os.Args[1] { - case "accept-all": - if err := acceptAll(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - case "reject-all": - if err := rejectAll(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - case "help", "-h", "--help": - fmt.Println(`Usage: shutter-tui [COMMAND] - -Commands: - review Review and accept/reject new snapshots (default) - accept-all Accept all new snapshots - reject-all Reject all new snapshots - help Show this help message - -Interactive Controls: - a Accept current snapshot - r Reject current snapshot - s Skip current snapshot - A Accept all remaining snapshots - R Reject all remaining snapshots - S Skip all remaining snapshots - q Quit`) - return - } - } - - m, err := initialModel() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if m.done && len(m.snapshots) == 0 { - fmt.Println(m.View()) - return - } - - p := tea.NewProgram( - m, - tea.WithAltScreen(), - tea.WithMouseCellMotion(), - ) - if _, err := p.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} diff --git a/justfile b/justfile index 9a02483..cf5a344 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,12 @@ +[private] +default: build review + +build: + @pushd ./cmd/shutter && go build -o shutter ./main.go && popd + +review: + @./cmd/shutter/shutter + clean-test: @rm -rf ./__snapshots__ @go test ./... -cover -coverprofile=cover.out @@ -5,15 +14,8 @@ clean-test: test: @go test ./... -cover -coverprofile=cover.out -run: - @go run cmd/shutter/main.go +cli: + @go run cmd/cli/main.go clean: @rm -rf ./__snapshots__ - -tui: - @pushd ./cmd/tui && go build -o shutter ./main.go && popd - @./cmd/tui/shutter - -review: - @./cmd/tui/shutter