diff --git a/__snapshots__/test_accept.snap b/__snapshots__/test_accept.snap index 7daa3fa..20b7354 100644 --- a/__snapshots__/test_accept.snap +++ b/__snapshots__/test_accept.snap @@ -1,4 +1,5 @@ --- +title: Accept Title test_name: TestAccept file_path: func_name: diff --git a/__snapshots__/test_map.snap b/__snapshots__/test_map.snap index 94ec841..e94abd6 100644 --- a/__snapshots__/test_map.snap +++ b/__snapshots__/test_map.snap @@ -1,8 +1,11 @@ --- +title: Map Test test_name: TestMap -file_path: /home/patrick/projects/freeze/freeze.go +file_path: /home/patrick/projects/scratchpad/freeze/freeze.go func_name: --- map[string]interface{}{ + "wibble": "wobble", "foo": "bar", + "wibbling": "wobble", } diff --git a/__snapshots__/test_snap_custom_type.snap b/__snapshots__/test_snap_custom_type.snap index cdbca53..b28cd08 100644 --- a/__snapshots__/test_snap_custom_type.snap +++ b/__snapshots__/test_snap_custom_type.snap @@ -1,6 +1,7 @@ --- +title: Custom Type Test test_name: TestSnapCustomType -file_path: /home/patrick/projects/freeze/freeze.go +file_path: /home/patrick/projects/scratchpad/freeze/freeze.go func_name: --- freeze_test.CustomStruct{ diff --git a/__snapshots__/test_snap_func.snap b/__snapshots__/test_snap_func.snap index e053431..fd48c5a 100644 --- a/__snapshots__/test_snap_func.snap +++ b/__snapshots__/test_snap_func.snap @@ -1,6 +1,7 @@ --- +title: TestSnapFunc test_name: TestSnapFunc -file_path: /home/patrick/projects/freeze/freeze_test.go +file_path: /home/patrick/projects/scratchpad/freeze/freeze_test.go func_name: --- "helper result" diff --git a/__snapshots__/test_snap_func_another_helper.snap b/__snapshots__/test_snap_func_another_helper.snap index 03997a7..237d3af 100644 --- a/__snapshots__/test_snap_func_another_helper.snap +++ b/__snapshots__/test_snap_func_another_helper.snap @@ -1,6 +1,7 @@ --- +title: TestSnapFuncAnotherHelper test_name: TestSnapFuncAnotherHelper -file_path: /home/patrick/projects/freeze/freeze_test.go +file_path: /home/patrick/projects/scratchpad/freeze/freeze_test.go func_name: --- 10 diff --git a/__snapshots__/test_snap_multiple.snap b/__snapshots__/test_snap_multiple.snap index ad8a9ff..1f0b490 100644 --- a/__snapshots__/test_snap_multiple.snap +++ b/__snapshots__/test_snap_multiple.snap @@ -1,6 +1,7 @@ --- +title: Multiple Values Test test_name: TestSnapMultiple -file_path: /home/patrick/projects/freeze/freeze.go +file_path: /home/patrick/projects/scratchpad/freeze/freeze.go func_name: --- "value1" diff --git a/__snapshots__/test_snap_string.snap b/__snapshots__/test_snap_string.snap new file mode 100644 index 0000000..c045882 --- /dev/null +++ b/__snapshots__/test_snap_string.snap @@ -0,0 +1,7 @@ +--- +title: Simple String Test +test_name: TestSnapString +file_path: /home/patrick/projects/scratchpad/freeze/freeze.go +func_name: +--- +hello world \ No newline at end of file diff --git a/__snapshots__/test_snap_string.snap.new b/__snapshots__/test_snap_string.snap.new deleted file mode 100644 index 1f12b23..0000000 --- a/__snapshots__/test_snap_string.snap.new +++ /dev/null @@ -1,6 +0,0 @@ ---- -test_name: TestSnapString -file_path: /home/patrick/projects/freeze/freeze.go -func_name: ---- -hello world \ No newline at end of file diff --git a/cmd/tui/go.mod b/cmd/tui/go.mod new file mode 100644 index 0000000..3c3c872 --- /dev/null +++ b/cmd/tui/go.mod @@ -0,0 +1,32 @@ +module github.com/ptdewey/freeze/cmd/tui + +go 1.25.2 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/ptdewey/freeze v0.0.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) + +replace github.com/ptdewey/freeze => ../.. diff --git a/cmd/tui/go.sum b/cmd/tui/go.sum new file mode 100644 index 0000000..d1a20cb --- /dev/null +++ b/cmd/tui/go.sum @@ -0,0 +1,45 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +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/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= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 0000000..bb5aa6c --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,409 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/ptdewey/freeze/internal/diff" + "github.com/ptdewey/freeze/internal/files" + "github.com/ptdewey/freeze/internal/pretty" +) + +// Styles +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("205")). + Padding(0, 1) + + counterStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("240")). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Background(lipgloss.Color("236")). + Padding(0, 1) + + statusBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("236")). + Foreground(lipgloss.Color("230")) + + contentStyle = lipgloss.NewStyle(). + Padding(1, 2) +) + +type model struct { + snapshots []string + current int + newSnap *files.Snapshot + accepted *files.Snapshot + diffLines []pretty.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 + } + + testName := m.snapshots[m.current] + + newSnap, err := files.ReadSnapshot(testName, "new") + if err != nil { + return err + } + m.newSnap = newSnap + + accepted, err := files.ReadSnapshot(testName, "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) []pretty.DiffLine { + diffLines := diff.Histogram(old.Content, new.Content) + result := make([]pretty.DiffLine, len(diffLines)) + for i, dl := range diffLines { + result[i] = pretty.DiffLine{ + OldNumber: dl.OldNumber, + NewNumber: dl.NewNumber, + Line: dl.Line, + Kind: pretty.DiffKind(dl.Kind), + } + } + return result +} + +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 := 3 + footerHeight := 2 + 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 + testName := m.snapshots[m.current] + if err := files.AcceptSnapshot(testName); err != nil { + m.err = err + } else { + m.actionResult = "Snapshot accepted" + m.current++ + if err := m.loadCurrentSnapshot(); err != nil { + m.err = err + } + m.updateViewportContent() + } + + case "r": + // Reject current snapshot + testName := m.snapshots[m.current] + if err := files.RejectSnapshot(testName); err != nil { + m.err = err + } else { + m.actionResult = "Snapshot rejected" + m.current++ + if err := m.loadCurrentSnapshot(); err != nil { + m.err = err + } + m.updateViewportContent() + } + + case "s": + // Skip current snapshot + m.actionResult = "Snapshot skipped" + m.current++ + if err := m.loadCurrentSnapshot(); err != nil { + m.err = err + } + m.updateViewportContent() + + case "A": + // Accept all remaining + for i := m.current; i < len(m.snapshots); i++ { + if err := files.AcceptSnapshot(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.RejectSnapshot(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 { + if m.newSnap.FuncName != "" { + b.WriteString(pretty.NewSnapshotBoxFunc(m.newSnap)) + } else { + b.WriteString(pretty.NewSnapshotBox(m.newSnap)) + } + } + } + + if m.actionResult != "" { + b.WriteString("\n\n") + b.WriteString(pretty.Success("✓ " + m.actionResult)) + } + + 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") + } + + if m.acceptedAll > 0 { + return pretty.Success(fmt.Sprintf("✓ Accepted %d snapshot(s)\n", m.acceptedAll)) + } + if m.rejectedAll > 0 { + return pretty.Warning(fmt.Sprintf("⊘ Rejected %d snapshot(s)\n", m.rejectedAll)) + } + if m.skippedAll > 0 { + return pretty.Warning(fmt.Sprintf("⊘ Skipped %d snapshot(s)\n", m.skippedAll)) + } + 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 + header := lipgloss.JoinHorizontal( + lipgloss.Left, + titleStyle.Render("Review Snapshots"), + counterStyle.Render(fmt.Sprintf("[%d/%d] %s", m.current+1, len(m.snapshots), m.snapshots[m.current])), + ) + + // Footer with help + 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" + + footerLeft := helpStyle.Render(helpText) + footerRight := helpStyle.Render(scrollInfo) + + gap := max(m.width-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight), 0) + + footer := lipgloss.JoinHorizontal( + lipgloss.Left, + footerLeft, + strings.Repeat(" ", gap), + footerRight, + ) + + // Main content with viewport + return lipgloss.JoinVertical( + lipgloss.Left, + statusBarStyle.Width(m.width).Render(header), + m.viewport.View(), + statusBarStyle.Width(m.width).Render(footer), + ) +} + +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: freeze-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) + } +} + +func acceptAll() error { + snapshots, err := files.ListNewSnapshots() + if err != nil { + return err + } + + for _, testName := range snapshots { + if err := files.AcceptSnapshot(testName); 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 _, testName := range snapshots { + if err := files.RejectSnapshot(testName); err != nil { + return err + } + } + + fmt.Printf(pretty.Warning("⊘ Rejected %d snapshot(s)\n"), len(snapshots)) + return nil +} diff --git a/freeze.go b/freeze.go index acc3483..8a74268 100644 --- a/freeze.go +++ b/freeze.go @@ -16,21 +16,15 @@ func init() { utter.Config.ElideType = true } -func SnapString(t testingT, content string) { +func SnapString(t testingT, title string, content string) { t.Helper() - snap(t, content) + snap(t, title, content) } -func Snap(t testingT, values ...any) { +func Snap(t testingT, title string, values ...any) { t.Helper() content := formatValues(values...) - snap(t, content) -} - -func SnapWithTitle(t testingT, title string, values ...any) { - t.Helper() - content := formatValues(values...) - snapWithTitle(t, title, content) + snap(t, title, content) } func SnapFunc(t testingT, values ...any) { @@ -49,47 +43,30 @@ func SnapFunc(t testingT, values ...any) { } funcName = fullName[parts:] } + testName := t.Name() content := formatValues(values...) - snapWithTitle(t, funcName, content) -} - -func getFunctionName() string { - pc, _, _, _ := runtime.Caller(2) - fn := runtime.FuncForPC(pc) - if fn == nil { - return "unknown" - } - - fullName := fn.Name() - parts := len(fullName) - 1 - for i := len(fullName) - 1; i >= 0; i-- { - if fullName[i] == '.' { - parts = i + 1 - break - } - } - - return fullName[parts:] + snapWithTitle(t, funcName, testName, content) } -func snap(t testingT, content string) { +func snap(t testingT, title string, content string) { t.Helper() testName := t.Name() - snapWithTitle(t, testName, content) + snapWithTitle(t, title, testName, content) } -func snapWithTitle(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{ - Name: title, + Title: title, + Name: testName, FilePath: filePath, Content: content, } - accepted, err := files.ReadAccepted(title) + accepted, err := files.ReadAccepted(testName) if err == nil { if accepted.Content == content { return @@ -119,9 +96,10 @@ func convertDiffLines(diffLines []diff.DiffLine) []pretty.DiffLine { result := make([]pretty.DiffLine, len(diffLines)) for i, dl := range diffLines { result[i] = pretty.DiffLine{ - Number: dl.Number, - Line: dl.Line, - Kind: pretty.DiffKind(dl.Kind), + OldNumber: dl.OldNumber, + NewNumber: dl.NewNumber, + Line: dl.Line, + Kind: pretty.DiffKind(dl.Kind), } } return result diff --git a/freeze_test.go b/freeze_test.go index c56476a..8ab4bbf 100644 --- a/freeze_test.go +++ b/freeze_test.go @@ -11,11 +11,11 @@ import ( ) func TestSnapString(t *testing.T) { - freeze.SnapString(t, "hello world") + freeze.SnapString(t, "Simple String Test", "hello world") } func TestSnapMultiple(t *testing.T) { - freeze.Snap(t, "value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil) + freeze.Snap(t, "Multiple Values Test", "value1", "value2", 42, "foo", "bar", "baz", "wibble", "wobble", "tock", nil) } type CustomStruct struct { @@ -32,12 +32,14 @@ func TestSnapCustomType(t *testing.T) { Name: "Alice", Age: 30, } - freeze.Snap(t, cs) + freeze.Snap(t, "Custom Type Test", cs) } func TestMap(t *testing.T) { - freeze.Snap(t, map[string]any{ - "foo": "bar", + freeze.Snap(t, "Map Test", map[string]any{ + "foo": "bar", + "wibbling": "wobble", + "wibble": "wobble", }) } @@ -59,12 +61,13 @@ func calculateSomething(n int) int { func TestSerializeDeserialize(t *testing.T) { snap := &freeze.Snapshot{ + Title: "My Test Title", Name: "TestExample", Content: "test content\nmultiline", } serialized := snap.Serialize() - expected := "---\ntest_name: TestExample\nfile_path: \nfunc_name: \n---\ntest content\nmultiline" + expected := "---\ntitle: My Test Title\ntest_name: TestExample\nfile_path: \nfunc_name: \n---\ntest content\nmultiline" if serialized != expected { t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) } @@ -74,6 +77,9 @@ func TestSerializeDeserialize(t *testing.T) { t.Fatalf("failed to deserialize: %v", err) } + if deserialized.Title != snap.Title { + t.Errorf("title mismatch: %s != %s", deserialized.Title, snap.Title) + } if deserialized.Name != snap.Name { t.Errorf("test name mismatch: %s != %s", deserialized.Name, snap.Name) } @@ -84,6 +90,7 @@ func TestSerializeDeserialize(t *testing.T) { func TestFileOperations(t *testing.T) { snap := &freeze.Snapshot{ + Title: "File Ops Title", Name: "TestFileOps", Content: "file test content", } @@ -163,11 +170,13 @@ func TestHistogramDiff(t *testing.T) { func TestDiffSnapshotBox(t *testing.T) { old := &freeze.Snapshot{ + Title: "Diff Test Title", Name: "TestDiff", Content: "old content", } new := &freeze.Snapshot{ + Title: "Diff Test Title", Name: "TestDiff", Content: "new content", } @@ -184,6 +193,7 @@ func TestDiffSnapshotBox(t *testing.T) { func TestNewSnapshotBox(t *testing.T) { snap := &freeze.Snapshot{ + Title: "New Test Title", Name: "TestNew", Content: "test content", } diff --git a/internal/api/api.go b/internal/api/api.go index 2cdaa0b..6937f57 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -53,9 +53,10 @@ func convertDiffLines(diffLines []diff.DiffLine) []pretty.DiffLine { result := make([]pretty.DiffLine, len(diffLines)) for i, dl := range diffLines { result[i] = pretty.DiffLine{ - Number: dl.Number, - Line: dl.Line, - Kind: pretty.DiffKind(dl.Kind), + OldNumber: dl.OldNumber, + NewNumber: dl.NewNumber, + Line: dl.Line, + Kind: pretty.DiffKind(dl.Kind), } } return result diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 2186381..4c31c0a 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -13,9 +13,10 @@ const ( ) type DiffLine struct { - Number int - Line string - Kind DiffKind + OldNumber int + NewNumber int + Line string + Kind DiffKind } func Histogram(old, new string) []DiffLine { @@ -66,25 +67,26 @@ func traceback(old, new []string, matrix [][]int) []DiffLine { for i > 0 || j > 0 { if i > 0 && j > 0 && old[i-1] == new[j-1] { - result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared}}, result...) + // Lines match - shared + result = append([]DiffLine{{Line: old[i-1], Kind: DiffShared, OldNumber: i, NewNumber: j}}, result...) i-- j-- - } else if j > 0 && (i == 0 || matrix[i][j-1] < matrix[i-1][j]) { - result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) + } else if i > 0 && j > 0 && matrix[i-1][j] == matrix[i][j-1] { + // Equal cost - prefer showing deletions first, then additions + // This groups changes better + result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld, OldNumber: i}}, result...) + i-- + } else if j > 0 && (i == 0 || matrix[i][j-1] <= matrix[i-1][j]) { + // Addition + result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew, NewNumber: j}}, result...) j-- } else if i > 0 { - result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld}}, result...) + // Deletion + result = append([]DiffLine{{Line: old[i-1], Kind: DiffOld, OldNumber: i}}, result...) i-- - } else { - result = append([]DiffLine{{Line: new[j-1], Kind: DiffNew}}, result...) - j-- } } - for idx := range result { - result[idx].Number = idx + 1 - } - return result } diff --git a/internal/diff/diff_test.go b/internal/diff/diff_test.go index da287cd..73893cd 100644 --- a/internal/diff/diff_test.go +++ b/internal/diff/diff_test.go @@ -37,8 +37,12 @@ func TestHistogramIdentical(t *testing.T) { if dl.Kind != diff.DiffShared { t.Errorf("line %d: expected DiffShared, got %v", i, dl.Kind) } - if dl.Number != i+1 { - t.Errorf("line %d: expected Number=%d, got %d", i, i+1, dl.Number) + expectedLineNum := i + 1 + if dl.OldNumber != expectedLineNum { + t.Errorf("line %d: expected OldNumber=%d, got %d", i, expectedLineNum, dl.OldNumber) + } + if dl.NewNumber != expectedLineNum { + t.Errorf("line %d: expected NewNumber=%d, got %d", i, expectedLineNum, dl.NewNumber) } } } @@ -146,8 +150,12 @@ func TestHistogramLineNumbers(t *testing.T) { result := diff.Histogram(old, new) for i, dl := range result { - if dl.Number != i+1 { - t.Errorf("line %d: expected Number=%d, got %d", i, i+1, dl.Number) + expectedLineNum := i + 1 + if dl.OldNumber != expectedLineNum { + t.Errorf("line %d: expected OldNumber=%d, got %d", i, expectedLineNum, dl.OldNumber) + } + if dl.NewNumber != expectedLineNum { + t.Errorf("line %d: expected NewNumber=%d, got %d", i, expectedLineNum, dl.NewNumber) } } } diff --git a/internal/files/files.go b/internal/files/files.go index 4d2c9e9..de447a2 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -9,6 +9,7 @@ import ( ) type Snapshot struct { + Title string Name string FilePath string FuncName string @@ -16,7 +17,7 @@ type Snapshot struct { } func (s *Snapshot) Serialize() string { - header := fmt.Sprintf("---\ntest_name: %s\nfile_path: %s\nfunc_name: %s\n---\n", s.Name, s.FilePath, s.FuncName) + header := fmt.Sprintf("---\ntitle: %s\ntest_name: %s\nfile_path: %s\nfunc_name: %s\n---\n", s.Title, s.Name, s.FilePath, s.FuncName) return header + s.Content } @@ -46,6 +47,8 @@ func Deserialize(raw string) (*Snapshot, error) { key, value := kv[0], kv[1] switch key { + case "title": + snap.Title = value case "test_name": snap.Name = value case "file_path": diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 3275361..5410dfd 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -34,13 +34,14 @@ func TestSnapshotFileName(t *testing.T) { func TestSerializeDeserialize(t *testing.T) { snap := &files.Snapshot{ + Title: "Example Title", Name: "TestExample", FilePath: "/path/to/test.go", Content: "test content\nmultiline", } serialized := snap.Serialize() - expected := "---\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: \n---\ntest content\nmultiline" if serialized != expected { t.Errorf("Serialize():\nexpected:\n%s\n\ngot:\n%s", expected, serialized) } @@ -50,6 +51,9 @@ func TestSerializeDeserialize(t *testing.T) { t.Fatalf("Deserialize failed: %v", err) } + if deserialized.Title != snap.Title { + t.Errorf("Title mismatch: %s != %s", deserialized.Title, snap.Title) + } if deserialized.Name != snap.Name { t.Errorf("Name mismatch: %s != %s", deserialized.Name, snap.Name) } @@ -85,24 +89,35 @@ func TestDeserializeValidFormats(t *testing.T) { tests := []struct { name string input string + wantTitle string wantTest string wantContent string }{ { "simple", - "---\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", + "---\ntitle: Simple Title\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", + "Simple Title", "Test", "content", }, { "multiline content", - "---\ntest_name: MyTest\nfile_path: /path\nfunc_name: \n---\nline1\nline2\nline3", + "---\ntitle: Multi Title\ntest_name: MyTest\nfile_path: /path\nfunc_name: \n---\nline1\nline2\nline3", + "Multi Title", "MyTest", "line1\nline2\nline3", }, { "with extra fields", - "---\ntest_name: Test\nfile_path: /path\nfunc_name: \nextra: ignored\n---\ncontent", + "---\ntitle: Extra Title\ntest_name: Test\nfile_path: /path\nfunc_name: \nextra: ignored\n---\ncontent", + "Extra Title", + "Test", + "content", + }, + { + "backward compatibility - no title", + "---\ntest_name: Test\nfile_path: /path\nfunc_name: \n---\ncontent", + "", "Test", "content", }, @@ -114,6 +129,9 @@ func TestDeserializeValidFormats(t *testing.T) { if err != nil { t.Fatalf("Deserialize failed: %v", err) } + if snap.Title != tt.wantTitle { + t.Errorf("Title = %s, want %s", snap.Title, tt.wantTitle) + } if snap.Name != tt.wantTest { t.Errorf("Name = %s, want %s", snap.Name, tt.wantTest) } @@ -126,6 +144,7 @@ func TestDeserializeValidFormats(t *testing.T) { func TestSaveAndReadSnapshot(t *testing.T) { snap := &files.Snapshot{ + Title: "Save Read Title", Name: "TestSaveRead", Content: "saved content", } @@ -155,6 +174,7 @@ func TestReadSnapshotNotFound(t *testing.T) { func TestAcceptSnapshot(t *testing.T) { newSnap := &files.Snapshot{ + Title: "Accept Title", Name: "TestAccept", Content: "new content to accept", } @@ -186,6 +206,7 @@ func TestAcceptSnapshot(t *testing.T) { func TestRejectSnapshot(t *testing.T) { snap := &files.Snapshot{ + Title: "Reject Title", Name: "TestReject", Content: "content to reject", } diff --git a/internal/pretty/boxes.go b/internal/pretty/boxes.go index dc27e4d..e297b02 100644 --- a/internal/pretty/boxes.go +++ b/internal/pretty/boxes.go @@ -9,9 +9,10 @@ import ( ) type DiffLine struct { - Number int - Line string - Kind DiffKind + OldNumber int + NewNumber int + Line string + Kind DiffKind } type DiffKind int @@ -24,21 +25,21 @@ const ( func newSnapshotBoxInternal(snap *files.Snapshot, isFuncSnapshot bool) string { width := TerminalWidth() + snapshotFileName := files.SnapshotFileName(snap.Name) + ".snap.new" var sb strings.Builder - sb.WriteString("─── " + "New Snapshot " + strings.Repeat("─", width-15) + "\n\n") + sb.WriteString("─── " + "New Snapshot " + strings.Repeat("─", width-15) + "\n") + sb.WriteString(fmt.Sprintf(" file: %s\n\n", Gray(snapshotFileName))) + 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 { sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+snap.Name+"\""))) } - - sb.WriteString(fmt.Sprintf(" snapshot: %s\n", Gray(files.SnapshotFileName(snap.Name)+".snap.new"))) - if snap.FilePath != "" { - sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(snap.FilePath))) - } sb.WriteString("\n") lines := strings.Split(snap.Content, "\n") @@ -74,38 +75,66 @@ func NewSnapshotBoxFunc(snap *files.Snapshot) string { return newSnapshotBoxInternal(snap, true) } -// TODO: diff should show old and new line numbers func DiffSnapshotBox(old, new *files.Snapshot, diffLines []DiffLine) string { width := TerminalWidth() + snapshotFileName := files.SnapshotFileName(new.Name) + ".snap" var sb strings.Builder sb.WriteString(strings.Repeat("─", width) + "\n") + sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(snapshotFileName))) sb.WriteString(fmt.Sprintf(" %s\n", Blue("Snapshot Diff"))) - if new.FilePath != "" { - sb.WriteString(fmt.Sprintf(" file: %s\n", Gray(new.FilePath))) + if new.Title != "" { + sb.WriteString(fmt.Sprintf(" title: %s\n", Blue("\""+new.Title+"\""))) } + sb.WriteString(fmt.Sprintf(" test: %s\n", Blue("\""+new.Name+"\""))) sb.WriteString(strings.Repeat("─", width) + "\n") + // Calculate max line numbers for proper spacing + maxOldNum := 0 + maxNewNum := 0 + for _, dl := range diffLines { + if dl.OldNumber > maxOldNum { + maxOldNum = dl.OldNumber + } + if dl.NewNumber > maxNewNum { + maxNewNum = dl.NewNumber + } + } + oldWidth := len(fmt.Sprintf("%d", maxOldNum)) + newWidth := len(fmt.Sprintf("%d", maxNewNum)) + for _, dl := range diffLines { + var oldNumStr, newNumStr string var prefix string var formatted string switch dl.Kind { case DiffOld: + oldNumStr = fmt.Sprintf("%*d", oldWidth, dl.OldNumber) + newNumStr = strings.Repeat(" ", newWidth) prefix = Red("−") formatted = Red(dl.Line) case DiffNew: + oldNumStr = strings.Repeat(" ", oldWidth) + newNumStr = fmt.Sprintf("%*d", newWidth, dl.NewNumber) prefix = Green("+") formatted = Green(dl.Line) case DiffShared: + oldNumStr = fmt.Sprintf("%*d", oldWidth, dl.OldNumber) + newNumStr = fmt.Sprintf("%*d", newWidth, dl.NewNumber) prefix = " " formatted = dl.Line } - display := fmt.Sprintf("%s %s", prefix, formatted) - if len(display) > width-4 { - display = display[:width-7] + "..." + linePrefix := fmt.Sprintf("%s %s %s", Gray(oldNumStr), Gray(newNumStr), prefix) + display := fmt.Sprintf("%s %s", linePrefix, formatted) + + // Adjust for actual display length considering ANSI codes + if len(dl.Line) > width-oldWidth-newWidth-8 { + formatted = formatted[:width-oldWidth-newWidth-11] + "..." + display = fmt.Sprintf("%s %s", linePrefix, formatted) } + sb.WriteString(fmt.Sprintf(" %s\n", display)) } diff --git a/internal/review/review.go b/internal/review/review.go index 6dd0674..83be1d0 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -28,9 +28,10 @@ func computeDiffLines(old, new *files.Snapshot) []pretty.DiffLine { result := make([]pretty.DiffLine, len(diffLines)) for i, dl := range diffLines { result[i] = pretty.DiffLine{ - Number: dl.Number, - Line: dl.Line, - Kind: pretty.DiffKind(dl.Kind), + OldNumber: dl.OldNumber, + NewNumber: dl.NewNumber, + Line: dl.Line, + Kind: pretty.DiffKind(dl.Kind), } } return result