From cf284e57aaf45e25424e64a72da1eeb313818734 Mon Sep 17 00:00:00 2001 From: Patrick Dewey <57921252+ptdewey@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:30:38 -0500 Subject: [PATCH] fix: tui/cli now walk project tree to find `__snapshots__` dirs --- cmd/tui/go.mod | 2 +- cmd/tui/go.sum | 2 + cmd/tui/main.go | 32 ++++----- go.mod | 4 +- internal/files/files.go | 125 ++++++++++++++++++++++++++++++++--- internal/files/files_test.go | 29 ++++++-- internal/review/review.go | 28 ++++---- 7 files changed, 177 insertions(+), 45 deletions(-) diff --git a/cmd/tui/go.mod b/cmd/tui/go.mod index e403978..0c88115 100644 --- a/cmd/tui/go.mod +++ b/cmd/tui/go.mod @@ -3,9 +3,9 @@ module github.com/ptdewey/shutter/cmd/tui go 1.25.2 require ( + github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/bubbles v0.21.0 github.com/ptdewey/shutter v0.0.0 ) diff --git a/cmd/tui/go.sum b/cmd/tui/go.sum index d1a20cb..c152eba 100644 --- a/cmd/tui/go.sum +++ b/cmd/tui/go.sum @@ -16,6 +16,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ 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/kortschak/utter v1.7.0 h1:6NKMynvGUyqfeMTawfah4zyInlrgwzjkDAHrT+skx/w= +github.com/kortschak/utter v1.7.0/go.mod h1:vSmSjbyrlKjjsL71193LmzBOKgwePk9DH6uFaWHIInc= 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= diff --git a/cmd/tui/main.go b/cmd/tui/main.go index d997e22..c1da124 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -57,7 +57,7 @@ var ( ) type model struct { - snapshots []string + snapshots []files.SnapshotInfo current int newSnap *files.Snapshot accepted *files.Snapshot @@ -103,15 +103,15 @@ func (m *model) loadCurrentSnapshot() error { return nil } - testName := m.snapshots[m.current] + snapshotInfo := m.snapshots[m.current] - newSnap, err := files.ReadSnapshot(testName, "new") + newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) if err != nil { return err } m.newSnap = newSnap - accepted, err := files.ReadSnapshot(testName, "accepted") + accepted, err := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") if err == nil { m.accepted = accepted diffLines := computeDiffLines(accepted, newSnap) @@ -166,8 +166,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "a": // Accept current snapshot - testName := m.snapshots[m.current] - if err := files.AcceptSnapshot(testName); err != nil { + snapshotInfo := m.snapshots[m.current] + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { m.err = err } else { m.acceptedAll++ @@ -183,8 +183,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "r": // Reject current snapshot - testName := m.snapshots[m.current] - if err := files.RejectSnapshot(testName); err != nil { + snapshotInfo := m.snapshots[m.current] + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { m.err = err } else { m.rejectedAll++ @@ -213,7 +213,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "A": // Accept all remaining for i := m.current; i < len(m.snapshots); i++ { - if err := files.AcceptSnapshot(m.snapshots[i]); err != nil { + if err := files.AcceptSnapshotInfo(m.snapshots[i]); err != nil { m.err = err break } @@ -225,7 +225,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "R": // Reject all remaining for i := m.current; i < len(m.snapshots); i++ { - if err := files.RejectSnapshot(m.snapshots[i]); err != nil { + if err := files.RejectSnapshotInfo(m.snapshots[i]); err != nil { m.err = err break } @@ -327,7 +327,7 @@ func (m model) View() string { } // Header - snapshotTitle := m.snapshots[m.current] // fallback to test name + snapshotTitle := m.snapshots[m.current].Title // fallback to snapshot title if m.newSnap != nil && m.newSnap.Title != "" { snapshotTitle = m.newSnap.Title } @@ -339,7 +339,7 @@ func (m model) View() string { headerStyled := statusBarStyle.Width(m.width).Render(header) // Footer with snapshot filename and scroll info - snapshotFile := files.SnapshotFileName(m.snapshots[m.current]) + ".snap.new" + 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) @@ -375,8 +375,8 @@ func acceptAll() error { return err } - for _, testName := range snapshots { - if err := files.AcceptSnapshot(testName); err != nil { + for _, snapshotInfo := range snapshots { + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { return err } } @@ -391,8 +391,8 @@ func rejectAll() error { return err } - for _, testName := range snapshots { - if err := files.RejectSnapshot(testName); err != nil { + for _, snapshotInfo := range snapshots { + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { return err } } diff --git a/go.mod b/go.mod index 0269636..703dfbb 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/ptdewey/shutter -go 1.25.2 +go 1.23.12 + +toolchain go1.25.2 require github.com/kortschak/utter v1.7.0 diff --git a/internal/files/files.go b/internal/files/files.go index 2405397..0b9154a 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -63,6 +63,8 @@ func Deserialize(raw string) (*Snapshot, error) { return snap, nil } +// getSnapshotDir finds the nearest __snapshots__ directory relative to the caller, +// creating one if it doesn't exist. This is used when creating new snapshots. func getSnapshotDir() (string, error) { // NOTE: maybe this could be configurable? // Storing snapshots in root may be desirable in some cases @@ -74,6 +76,56 @@ func getSnapshotDir() (string, error) { return snapshotDir, nil } +// findAllSnapshotDirs recursively finds all __snapshots__ directories starting from root +func findAllSnapshotDirs(root string) ([]string, error) { + var snapshotDirs []string + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip hidden directories and common ignore paths + if info.IsDir() && len(info.Name()) > 0 && info.Name()[0] == '.' { + return filepath.SkipDir + } + if info.IsDir() && (info.Name() == "node_modules" || info.Name() == "vendor") { + return filepath.SkipDir + } + + if info.IsDir() && info.Name() == "__snapshots__" { + snapshotDirs = append(snapshotDirs, path) + } + + return nil + }) + + return snapshotDirs, err +} + +// findProjectRoot finds the root of the project by looking for go.mod +func findProjectRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + dir := cwd + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root without finding go.mod + // Fall back to current directory + return cwd, nil + } + dir = parent + } +} + // TODO: make this use the snapshot title rather than the test name func SnapshotFileName(snapTitle string) string { return strings.ReplaceAll(strings.ToLower(snapTitle), " ", "_") @@ -121,6 +173,21 @@ func ReadSnapshot(snapTitle string, state string) (*Snapshot, error) { return nil, err } + return ReadSnapshotWithDir(snapshotDir, snapTitle, state) +} + +// ReadSnapshotFromPath reads a snapshot directly from a full file path +func ReadSnapshotFromPath(filePath string) (*Snapshot, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + return Deserialize(string(data)) +} + +// ReadSnapshotWithDir reads a snapshot from a specific directory +func ReadSnapshotWithDir(snapshotDir, snapTitle string, state string) (*Snapshot, error) { fileName := getSnapshotFileName(snapTitle, state) filePath := filepath.Join(snapshotDir, fileName) @@ -140,28 +207,65 @@ func ReadNew(snapTitle string) (*Snapshot, error) { return ReadSnapshot(snapTitle, "new") } -func ListNewSnapshots() ([]string, error) { - snapshotDir, err := getSnapshotDir() +// SnapshotInfo contains metadata about a snapshot file including its full path +type SnapshotInfo struct { + Title string // The snapshot title (used as identifier) + Path string // Full path to the snapshot file + Dir string // Directory containing the snapshot +} + +func ListNewSnapshots() ([]SnapshotInfo, error) { + projectRoot, err := findProjectRoot() if err != nil { return nil, err } - entries, err := os.ReadDir(snapshotDir) + snapshotDirs, err := findAllSnapshotDirs(projectRoot) if err != nil { return nil, err } - var newSnapshots []string - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".snap.new") { - name := strings.TrimSuffix(entry.Name(), ".snap.new") - newSnapshots = append(newSnapshots, name) + var newSnapshots []SnapshotInfo + for _, dir := range snapshotDirs { + entries, err := os.ReadDir(dir) + if err != nil { + // Skip directories we can't read + continue + } + + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".snap.new") { + name := strings.TrimSuffix(entry.Name(), ".snap.new") + fullPath := filepath.Join(dir, entry.Name()) + newSnapshots = append(newSnapshots, SnapshotInfo{ + Title: name, + Path: fullPath, + Dir: dir, + }) + } } } return newSnapshots, nil } +// AcceptSnapshotInfo accepts a snapshot using SnapshotInfo +func AcceptSnapshotInfo(info SnapshotInfo) error { + newPath := info.Path + acceptedPath := filepath.Join(info.Dir, getSnapshotFileName(info.Title, "accepted")) + + data, err := os.ReadFile(newPath) + if err != nil { + return err + } + + if err := os.WriteFile(acceptedPath, data, 0644); err != nil { + return err + } + + return os.Remove(newPath) +} + func AcceptSnapshot(snapTitle string) error { newPath, err := getSnapshotPath(snapTitle, "new") if err != nil { @@ -185,6 +289,11 @@ func AcceptSnapshot(snapTitle string) error { return os.Remove(newPath) } +// RejectSnapshotInfo rejects a snapshot using SnapshotInfo +func RejectSnapshotInfo(info SnapshotInfo) error { + return os.Remove(info.Path) +} + func RejectSnapshot(snapTitle string) error { filePath, err := getSnapshotPath(snapTitle, "new") if err != nil { diff --git a/internal/files/files_test.go b/internal/files/files_test.go index 84b633b..51cf739 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -157,7 +157,7 @@ func TestSaveAndReadSnapshot(t *testing.T) { t.Fatalf("SaveSnapshot failed: %v", err) } - read, err := files.ReadSnapshot("TestSaveRead", "test") + read, err := files.ReadSnapshot("Save Read Title", "test") if err != nil { t.Fatalf("ReadSnapshot failed: %v", err) } @@ -166,7 +166,7 @@ func TestSaveAndReadSnapshot(t *testing.T) { t.Errorf("Content mismatch: %s != %s", read.Content, snap.Content) } - cleanupSnapshot(t, "TestSaveRead", "test") + cleanupSnapshot(t, "Save Read Title", "test") } func TestReadSnapshotNotFound(t *testing.T) { @@ -191,7 +191,7 @@ func TestAcceptSnapshot(t *testing.T) { t.Fatalf("AcceptSnapshot failed: %v", err) } - accepted, err := files.ReadSnapshot("TestAccept", "accepted") + accepted, err := files.ReadSnapshot("Accept Title", "accepted") if err != nil { t.Fatalf("ReadSnapshot failed: %v", err) } @@ -200,12 +200,12 @@ func TestAcceptSnapshot(t *testing.T) { t.Errorf("Content mismatch: %s != %s", accepted.Content, newSnap.Content) } - _, err = files.ReadSnapshot("TestAccept", "new") + _, err = files.ReadSnapshot("Accept Title", "new") if err == nil { t.Error("expected error: .new file should be deleted after accept") } - cleanupSnapshot(t, "TestAccept", "accepted") + cleanupSnapshot(t, "Accept Title", "accepted") } func TestRejectSnapshot(t *testing.T) { @@ -249,3 +249,22 @@ func cleanupSnapshot(t *testing.T, testName, state string) { filePath := filepath.Join(root, "__snapshots__", fileName) _ = os.Remove(filePath) } + +func TestRecursiveSnapshots(t *testing.T) { + // This test verifies that ListNewSnapshots finds snapshots recursively + snapshots, err := files.ListNewSnapshots() + if err != nil { + t.Fatalf("ListNewSnapshots failed: %v", err) + } + + t.Logf("Found %d snapshots", len(snapshots)) + for _, snap := range snapshots { + t.Logf(" - Title: %s, Path: %s", snap.Title, snap.Path) + } + + // Just verify it doesn't error - we can't make assumptions about which + // snapshots exist since this depends on the test environment + if err != nil { + t.Errorf("Error listing snapshots: %v", err) + } +} diff --git a/internal/review/review.go b/internal/review/review.go index f7ef398..feb9ec0 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -28,10 +28,10 @@ func computeDiffLines(old, new *files.Snapshot) []diff.DiffLine { } // applyToSnapshots applies an operation to all snapshots and returns the count of successful operations -func applyToSnapshots(snapshots []string, operation func(string) error) (int, error) { +func applyToSnapshots(snapshots []files.SnapshotInfo, operation func(files.SnapshotInfo) error) (int, error) { successCount := 0 - for _, snapTitle := range snapshots { - if err := operation(snapTitle); err != nil { + for _, snapshotInfo := range snapshots { + if err := operation(snapshotInfo); err != nil { return successCount, err } successCount++ @@ -56,19 +56,19 @@ func Review() error { return reviewLoop(snapshots) } -func reviewLoop(snapshots []string) error { +func reviewLoop(snapshots []files.SnapshotInfo) error { reader := bufio.NewReader(os.Stdin) - for i, snapTitle := range snapshots { - fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(snapTitle)) + for i, snapshotInfo := range snapshots { + fmt.Printf("\n[%d/%d] %s\n", i+1, len(snapshots), pretty.Header(snapshotInfo.Title)) - newSnap, err := files.ReadSnapshot(snapTitle, "new") + newSnap, err := files.ReadSnapshotFromPath(snapshotInfo.Path) if err != nil { fmt.Println(pretty.Error("✗ Failed to read new snapshot: " + err.Error())) continue } - accepted, acceptErr := files.ReadSnapshot(snapTitle, "accepted") + accepted, acceptErr := files.ReadSnapshotWithDir(snapshotInfo.Dir, snapshotInfo.Title, "accepted") if acceptErr == nil { diffLines := computeDiffLines(accepted, newSnap) @@ -85,13 +85,13 @@ func reviewLoop(snapshots []string) error { switch choice { case Accept: - if err := files.AcceptSnapshot(snapTitle); err != nil { + if err := files.AcceptSnapshotInfo(snapshotInfo); err != nil { fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) } else { fmt.Println(pretty.Success("✓ Snapshot accepted")) } case Reject: - if err := files.RejectSnapshot(snapTitle); err != nil { + if err := files.RejectSnapshotInfo(snapshotInfo); err != nil { fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) } else { fmt.Println(pretty.Warning("⊘ Snapshot rejected")) @@ -100,7 +100,7 @@ func reviewLoop(snapshots []string) error { fmt.Println(pretty.Warning("⊘ Snapshot skipped")) case AcceptAllChoice: remaining := snapshots[i:] - if _, err := applyToSnapshots(remaining, files.AcceptSnapshot); err != nil { + if _, err := applyToSnapshots(remaining, files.AcceptSnapshotInfo); err != nil { fmt.Println(pretty.Error("✗ Failed to accept snapshot: " + err.Error())) return err } @@ -108,7 +108,7 @@ func reviewLoop(snapshots []string) error { return nil case RejectAllChoice: remaining := snapshots[i:] - if _, err := applyToSnapshots(remaining, files.RejectSnapshot); err != nil { + if _, err := applyToSnapshots(remaining, files.RejectSnapshotInfo); err != nil { fmt.Println(pretty.Error("✗ Failed to reject snapshot: " + err.Error())) return err } @@ -166,7 +166,7 @@ func AcceptAll() error { return err } - count, err := applyToSnapshots(snapshots, files.AcceptSnapshot) + count, err := applyToSnapshots(snapshots, files.AcceptSnapshotInfo) if err != nil { return err } @@ -181,7 +181,7 @@ func RejectAll() error { return err } - count, err := applyToSnapshots(snapshots, files.RejectSnapshot) + count, err := applyToSnapshots(snapshots, files.RejectSnapshotInfo) if err != nil { return err } -- 2.51.2