177 lines
4.0 KiB
Go
177 lines
4.0 KiB
Go
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/<slug>.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
|
|
}
|