diff --git a/.gitignore b/.gitignore index c496d47..89a7f70 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,3 @@ go.work go.work.sum .env cmd/tui/freeze-tui -cmd/tui/tui -cmd/freeze/freeze -freeze-tui -tui -freeze diff --git a/README.md b/README.md index afe8354..cc83149 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A [birdie](https://github.com/giacomocavalieri/birdie) and [insta](https://githu ![New snapshot screen](./assets/screenshot-new.png "New snapshot view") -![Snapshot review CLI](./assets/screenshots-diff-cli "Snapshot diff view (CLI)") +![Snapshot review CLI](./assets/screenshots-diff-cli.png "Snapshot diff view (CLI)") ## Installation @@ -34,10 +34,32 @@ go run github.com/ptdewey/freeze/cmd/freeze review -Freeze also includes (in a separate Go module) with a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). (The TUI is shipped in a separate module to make the added dependencies optional) +Freeze also includes (in a separate Go module) a [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI in [cmd/tui/main.go](./cmd/tui/main.go). (The TUI is shipped in a separate module to make the added dependencies optional) + +### TUI Usage + +```sh +go run github.com/ptdewey/freeze/cmd/tui review +``` + +#### 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 + +#### Alternative Commands ```sh -# TODO: tui usage +# Accept all new snapshots without review +go run github.com/ptdewey/freeze/cmd/tui accept-all + +# Reject all new snapshots without review +go run github.com/ptdewey/freeze/cmd/tui reject-all ``` ## Disclaimer diff --git a/__snapshots__/test_accept.snap b/__snapshots__/test_accept.snap index 20b7354..2c8903b 100644 --- a/__snapshots__/test_accept.snap +++ b/__snapshots__/test_accept.snap @@ -3,5 +3,6 @@ title: Accept Title test_name: TestAccept file_path: func_name: +version: --- new content to accept \ No newline at end of file diff --git a/__snapshots__/test_map.snap b/__snapshots__/test_map.snap index fe03c9d..b8b33a0 100644 --- a/__snapshots__/test_map.snap +++ b/__snapshots__/test_map.snap @@ -1,8 +1,9 @@ --- title: Map Test test_name: TestMap -file_path: /home/patrick/projects/freeze/freeze.go +file_path: func_name: +version: 0.1.0 --- map[string]interface{}{ "foo": "bar", diff --git a/__snapshots__/test_snap_custom_type.snap b/__snapshots__/test_snap_custom_type.snap index 58538bb..dfd844a 100644 --- a/__snapshots__/test_snap_custom_type.snap +++ b/__snapshots__/test_snap_custom_type.snap @@ -1,8 +1,9 @@ --- title: Custom Type Test test_name: TestSnapCustomType -file_path: /home/patrick/projects/freeze/freeze.go +file_path: func_name: +version: 0.1.0 --- freeze_test.CustomStruct{ Name: "Alice", diff --git a/__snapshots__/test_snap_func.snap b/__snapshots__/test_snap_func.snap index f1d78e7..5daa53b 100644 --- a/__snapshots__/test_snap_func.snap +++ b/__snapshots__/test_snap_func.snap @@ -1,7 +1,8 @@ --- title: TestSnapFunc test_name: TestSnapFunc -file_path: /home/patrick/projects/freeze/freeze_test.go +file_path: func_name: +version: 0.1.0 --- "helper result" diff --git a/__snapshots__/test_snap_func_another_helper.snap b/__snapshots__/test_snap_func_another_helper.snap index d21a65c..9d17838 100644 --- a/__snapshots__/test_snap_func_another_helper.snap +++ b/__snapshots__/test_snap_func_another_helper.snap @@ -1,7 +1,8 @@ --- title: TestSnapFuncAnotherHelper test_name: TestSnapFuncAnotherHelper -file_path: /home/patrick/projects/freeze/freeze_test.go +file_path: func_name: +version: 0.1.0 --- 10 diff --git a/__snapshots__/test_snap_multiple.snap b/__snapshots__/test_snap_multiple.snap index 3ef2e69..e0d99e4 100644 --- a/__snapshots__/test_snap_multiple.snap +++ b/__snapshots__/test_snap_multiple.snap @@ -1,8 +1,9 @@ --- title: Multiple Values Test test_name: TestSnapMultiple -file_path: /home/patrick/projects/freeze/freeze.go +file_path: func_name: +version: 0.1.0 --- "value1" "value2" diff --git a/__snapshots__/test_snap_string.snap b/__snapshots__/test_snap_string.snap index 7d2dcfd..2d49043 100644 --- a/__snapshots__/test_snap_string.snap +++ b/__snapshots__/test_snap_string.snap @@ -1,7 +1,8 @@ --- title: Simple String Test test_name: TestSnapString -file_path: /home/patrick/projects/freeze/freeze.go +file_path: func_name: +version: 0.1.0 --- hello world \ No newline at end of file diff --git a/cmd/tui/main.go b/cmd/tui/main.go index bb5aa6c..2b7492a 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -35,6 +35,25 @@ var ( contentStyle = lipgloss.NewStyle(). Padding(1, 2) + + // Action styles with semantic colors + acceptStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("10")). // Green + Bold(true) + + rejectStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("9")). // Red + Bold(true) + + skipStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("11")). // Yellow + Bold(true) + + keyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + helpTextStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")) ) type model struct { @@ -135,7 +154,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height headerHeight := 3 - footerHeight := 2 + footerHeight := 1 verticalMarginHeight := headerHeight + footerHeight if !m.ready { @@ -161,11 +180,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := files.AcceptSnapshot(testName); err != nil { m.err = err } else { - m.actionResult = "Snapshot accepted" + m.acceptedAll++ m.current++ if err := m.loadCurrentSnapshot(); err != nil { m.err = err } + if m.done { + return m, tea.Quit + } m.updateViewportContent() } @@ -175,21 +197,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err := files.RejectSnapshot(testName); err != nil { m.err = err } else { - m.actionResult = "Snapshot rejected" + 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.actionResult = "Snapshot skipped" + m.skippedAll++ m.current++ if err := m.loadCurrentSnapshot(); err != nil { m.err = err } + if m.done { + return m, tea.Quit + } m.updateViewportContent() case "A": @@ -251,10 +279,30 @@ func (m *model) updateViewportContent() { } } - if m.actionResult != "" { - b.WriteString("\n\n") - b.WriteString(pretty.Success("✓ " + m.actionResult)) - } + // 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() @@ -266,14 +314,20 @@ func (m model) View() string { return pretty.Success("✓ No new snapshots to review\n") } + // Build summary from counts + var summary []string if m.acceptedAll > 0 { - return pretty.Success(fmt.Sprintf("✓ Accepted %d snapshot(s)\n", m.acceptedAll)) + summary = append(summary, fmt.Sprintf("✓ Accepted %d", m.acceptedAll)) } if m.rejectedAll > 0 { - return pretty.Warning(fmt.Sprintf("⊘ Rejected %d snapshot(s)\n", m.rejectedAll)) + summary = append(summary, fmt.Sprintf("⊘ Rejected %d", m.rejectedAll)) } if m.skippedAll > 0 { - return pretty.Warning(fmt.Sprintf("⊘ Skipped %d snapshot(s)\n", m.skippedAll)) + 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") } @@ -289,35 +343,46 @@ func (m model) View() string { // Header header := lipgloss.JoinHorizontal( lipgloss.Left, - titleStyle.Render("Review Snapshots"), + titleStyle.Render("Review Snapshots "), counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), m.snapshots[m.current])), ) + headerStyled := statusBarStyle.Width(m.width).Render(header) - // Footer with help + // Footer with scroll info only scrollInfo := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) - helpText := "↑/↓/scroll: navigate • [a]ccept • [r]eject • [s]kip • [A]ll Accept • [R]ll Reject • [q]uit" + scrollStyled := helpStyle.Render(scrollInfo) - footerLeft := helpStyle.Render(helpText) - footerRight := helpStyle.Render(scrollInfo) + // Create footer with just scroll info on the right + footer := lipgloss.JoinHorizontal(lipgloss.Left, + strings.Repeat(" ", max(m.width-lipgloss.Width(scrollStyled)-1, 0)), + scrollStyled, + ) + footerStyled := statusBarStyle.Width(m.width).Render(footer) - gap := max(m.width-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight), 0) + // Viewport content + viewportContent := m.viewport.View() - footer := lipgloss.JoinHorizontal( - lipgloss.Left, - footerLeft, - strings.Repeat(" ", gap), - footerRight, - ) + // Calculate how much vertical space we have + // Total height = terminal height + // Used by header = ~1 line (the rendered header) + // Used by footer = ~1 line + // Middle = viewport (takes remaining space) - // Main content with viewport return lipgloss.JoinVertical( lipgloss.Left, - statusBarStyle.Width(m.width).Render(header), - m.viewport.View(), - statusBarStyle.Width(m.width).Render(footer), + headerStyled, + viewportContent, + footerStyled, ) } +func max(a, b int) int { + if a > b { + return a + } + return b +} + func main() { if len(os.Args) > 1 { switch os.Args[1] { diff --git a/freeze.go b/freeze.go index 8a74268..6fe6140 100644 --- a/freeze.go +++ b/freeze.go @@ -11,6 +11,8 @@ import ( "github.com/ptdewey/freeze/internal/review" ) +const version = "0.1.0" + // TODO: probably make this (and other things) configurable func init() { utter.Config.ElideType = true @@ -57,13 +59,11 @@ func snap(t testingT, title string, content string) { func snapWithTitle(t testingT, title string, testName string, content string) { t.Helper() - _, filePath, _, _ := runtime.Caller(2) - snapshot := &files.Snapshot{ - Title: title, - Name: testName, - FilePath: filePath, - Content: content, + Title: title, + Name: testName, + Content: content, + Version: version, } accepted, err := files.ReadAccepted(testName) diff --git a/freeze_test.go b/freeze_test.go index 498e62a..5f5701d 100644 --- a/freeze_test.go +++ b/freeze_test.go @@ -66,7 +66,7 @@ func TestSerializeDeserialize(t *testing.T) { } serialized := snap.Serialize() - expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_path: \nfunc_name: \n---\ntest content\nmultiline" + expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_path: \nfunc_name: \nversion: \n---\ntest content\nmultiline" if serialized != expected { t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) } diff --git a/internal/files/files.go b/internal/files/files.go index de447a2..d2355dd 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -13,11 +13,12 @@ type Snapshot struct { Name string FilePath string FuncName string + Version string Content string } func (s *Snapshot) Serialize() string { - header := fmt.Sprintf("---\ntitle: %s\ntest_name: %s\nfile_path: %s\nfunc_name: %s\n---\n", s.Title, s.Name, s.FilePath, s.FuncName) + header := fmt.Sprintf("---\ntitle: %s\ntest_name: %s\nfile_path: %s\nfunc_name: %s\nversion: %s\n---\n", s.Title, s.Name, s.FilePath, s.FuncName, s.Version) return header + s.Content } @@ -55,6 +56,8 @@ func Deserialize(raw string) (*Snapshot, error) { snap.FilePath = value case "func_name": snap.FuncName = value + case "version": + snap.Version = value } } diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 5410dfd..42807f6 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -37,11 +37,12 @@ func TestSerializeDeserialize(t *testing.T) { Title: "Example Title", Name: "TestExample", FilePath: "/path/to/test.go", + Version: "1.0.0", Content: "test content\nmultiline", } serialized := snap.Serialize() - expected := "---\ntitle: Example Title\ntest_name: TestExample\nfile_path: /path/to/test.go\nfunc_name: \n---\ntest content\nmultiline" + expected := "---\ntitle: Example Title\ntest_name: TestExample\nfile_path: /path/to/test.go\nfunc_name: \nversion: 1.0.0\n---\ntest content\nmultiline" if serialized != expected { t.Errorf("Serialize():\nexpected:\n%s\n\ngot:\n%s", expected, serialized) } @@ -60,6 +61,9 @@ func TestSerializeDeserialize(t *testing.T) { if deserialized.FilePath != snap.FilePath { t.Errorf("FilePath mismatch: %s != %s", deserialized.FilePath, snap.FilePath) } + if deserialized.Version != snap.Version { + t.Errorf("Version mismatch: %s != %s", deserialized.Version, snap.Version) + } if deserialized.Content != snap.Content { t.Errorf("Content mismatch: %s != %s", deserialized.Content, snap.Content) } @@ -91,6 +95,7 @@ func TestDeserializeValidFormats(t *testing.T) { input string wantTitle string wantTest string + wantVersion string wantContent string }{ { @@ -98,6 +103,15 @@ func TestDeserializeValidFormats(t *testing.T) { "---\ntitle: Simple Title\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", "Simple Title", "Test", + "", + "content", + }, + { + "with version", + "---\ntitle: With Version\ntest_name: Test\nfile_path: /path\nfunc_name: \nversion: 1.0.0\n---\ncontent", + "With Version", + "Test", + "1.0.0", "content", }, { @@ -105,6 +119,7 @@ func TestDeserializeValidFormats(t *testing.T) { "---\ntitle: Multi Title\ntest_name: MyTest\nfile_path: /path\nfunc_name: \n---\nline1\nline2\nline3", "Multi Title", "MyTest", + "", "line1\nline2\nline3", }, { @@ -112,6 +127,7 @@ func TestDeserializeValidFormats(t *testing.T) { "---\ntitle: Extra Title\ntest_name: Test\nfile_path: /path\nfunc_name: \nextra: ignored\n---\ncontent", "Extra Title", "Test", + "", "content", }, { @@ -119,6 +135,7 @@ func TestDeserializeValidFormats(t *testing.T) { "---\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", "", "Test", + "", "content", }, } @@ -135,6 +152,9 @@ func TestDeserializeValidFormats(t *testing.T) { if snap.Name != tt.wantTest { t.Errorf("Name = %s, want %s", snap.Name, tt.wantTest) } + if snap.Version != tt.wantVersion { + t.Errorf("Version = %s, want %s", snap.Version, tt.wantVersion) + } if snap.Content != tt.wantContent { t.Errorf("Content = %s, want %s", snap.Content, tt.wantContent) } diff --git a/internal/pretty/boxes.go b/internal/pretty/boxes.go index e297b02..a85d207 100644 --- a/internal/pretty/boxes.go +++ b/internal/pretty/boxes.go @@ -34,10 +34,7 @@ func newSnapshotBoxInternal(snap *files.Snapshot, isFuncSnapshot bool) string { if snap.Title != "" { sb.WriteString(fmt.Sprintf(" title: %s\n", Blue("\""+snap.Title+"\""))) } - if isFuncSnapshot && snap.FuncName != "" { - sb.WriteString(fmt.Sprintf(" func: %s\n", Blue("\""+snap.FuncName+"\""))) - sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) - } else { + if snap.Name != "" { sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) } sb.WriteString("\n") diff --git a/internal/review/review.go b/internal/review/review.go index 83be1d0..f4a3f04 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -76,11 +76,7 @@ func reviewLoop(snapshots []string) error { diffLines := computeDiffLines(accepted, newSnap) fmt.Println(pretty.DiffSnapshotBox(accepted, newSnap, diffLines)) } else { - if newSnap.FuncName != "" { - fmt.Println(pretty.NewSnapshotBoxFunc(newSnap)) - } else { - fmt.Println(pretty.NewSnapshotBox(newSnap)) - } + fmt.Println(pretty.NewSnapshotBox(newSnap)) } for {