package tui import ( "fmt" "os" "path/filepath" "strings" "greq/internal/storage" "github.com/charmbracelet/lipgloss" ) type diffKind int const ( diffEqual diffKind = iota diffAdded // present in new, absent in old (+) diffRemoved // present in old, absent in new (-) ) type diffLine struct { kind diffKind content string } // computeDiff performs a line-level LCS diff between oldStr and newStr. // Lines are capped at 500 each to keep the algorithm O(n²) bounded. func computeDiff(oldStr, newStr string) []diffLine { a := strings.Split(oldStr, "\n") b := strings.Split(newStr, "\n") if len(a) > 500 { a = a[:500] } if len(b) > 500 { b = b[:500] } return lcsBacktrack(a, b, lcsTable(a, b)) } func lcsTable(a, b []string) [][]int { m, n := len(a), len(b) dp := make([][]int, m+1) for i := range dp { dp[i] = make([]int, n+1) } for i := 1; i <= m; i++ { for j := 1; j <= n; j++ { if a[i-1] == b[j-1] { dp[i][j] = dp[i-1][j-1] + 1 } else if dp[i-1][j] >= dp[i][j-1] { dp[i][j] = dp[i-1][j] } else { dp[i][j] = dp[i][j-1] } } } return dp } func lcsBacktrack(a, b []string, dp [][]int) []diffLine { var result []diffLine i, j := len(a), len(b) for i > 0 || j > 0 { switch { case i > 0 && j > 0 && a[i-1] == b[j-1]: result = append(result, diffLine{diffEqual, a[i-1]}) i-- j-- case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): result = append(result, diffLine{diffAdded, b[j-1]}) j-- default: result = append(result, diffLine{diffRemoved, a[i-1]}) i-- } } // Reverse for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 { result[l], result[r] = result[r], result[l] } return result } // renderDiff formats diff lines with ANSI colours for the viewport. // Equal lines are dimmed; added lines are green (+); removed lines are red (-). func renderDiff(lines []diffLine, width int) string { if len(lines) == 0 { return hintStyle.Render(" No differences found.") } addStyle := lipgloss.NewStyle().Foreground(colorGreen) remStyle := lipgloss.NewStyle().Foreground(colorRed) eqStyle := lipgloss.NewStyle().Foreground(colorGray) maxLine := width - 3 if maxLine < 1 { maxLine = 1 } var sb strings.Builder for _, l := range lines { content := l.content if len(content) > maxLine { content = content[:maxLine-1] + "…" } switch l.kind { case diffAdded: sb.WriteString(addStyle.Render("+ "+content) + "\n") case diffRemoved: sb.WriteString(remStyle.Render("- "+content) + "\n") case diffEqual: sb.WriteString(eqStyle.Render(" "+content) + "\n") } } return sb.String() } // diffSummary returns a compact "+N -M" or "identical" summary. func diffSummary(lines []diffLine) string { var added, removed int for _, l := range lines { switch l.kind { case diffAdded: added++ case diffRemoved: removed++ } } if added == 0 && removed == 0 { return "identical" } var parts []string if added > 0 { parts = append(parts, fmt.Sprintf("+%d", added)) } if removed > 0 { parts = append(parts, fmt.Sprintf("-%d", removed)) } return strings.Join(parts, " ") } // --------------------------------------------------------------------------- // Snapshot storage (.greq/snapshots/.json) // --------------------------------------------------------------------------- func snapshotPath(name string) string { slug := strings.ToLower(strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { return r } return '_' }, name)) return filepath.Join(storage.RootDir, "snapshots", slug+".json") } func saveSnapshot(name, body string) error { p := snapshotPath(name) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } return os.WriteFile(p, []byte(body), 0o644) } func loadSnapshot(name string) (string, error) { data, err := os.ReadFile(snapshotPath(name)) if err != nil { return "", err } return string(data), nil } func snapshotExists(name string) bool { _, err := os.Stat(snapshotPath(name)) return err == nil }