Initial commit
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user