Files
correx/apps/tui-go/internal/app/diff.go
T
kami b56f0e88ca fix(tui): approval nav lockups, shell non-diff preview, hint dedup
Three QA-found issues in the approval gate:

- displayState gated the in-session/approval surfaces on hasApproval
  before sessionEntered, so moving the list cursor onto a session with a
  pending gate auto-opened it, and `l` back-to-list couldn't escape
  (the gate re-popped). Require sessionEntered first.
- The band ran every preview through the two-column diff renderer, so a
  shell tool's argv JSON rendered as an identical-column "diff". Detect
  real unified diffs (isUnifiedDiff); render other previews as plain
  text, and drop the ^x fullscreen hint when there's nothing to expand.
- The footer duplicated the band's approve/reject/steer/diff keys. It now
  shows navigation (l back / e events / q quit) instead.
2026-06-03 01:16:04 +04:00

227 lines
6.3 KiB
Go

package app
import (
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
)
type diffKind int
const (
diffBlank diffKind = iota // no line on this side
diffContext
diffDel
diffAdd
)
// diffRow is one aligned row of a split (side-by-side) diff: the old line on the
// left, the new line on the right. Either side may be blank (pure add/remove).
type diffRow struct {
oldNum, newNum int // 0 = blank cell
oldText, newText string
oldKind, newKind diffKind
}
// parseUnifiedDiff turns a unified diff into aligned old|new rows. File headers
// (---, +++, diff, index) are dropped; @@ markers seed the line numbers.
// Consecutive removals and additions are zipped so an edit lines up; a pure
// create yields blank left cells (and vice-versa for a delete).
func parseUnifiedDiff(diff string) []diffRow {
var rows []diffRow
oldN, newN := 1, 1
var dels, adds []string
flush := func() {
n := len(dels)
if len(adds) > n {
n = len(adds)
}
for i := 0; i < n; i++ {
r := diffRow{}
if i < len(dels) {
r.oldNum, r.oldText, r.oldKind = oldN, dels[i], diffDel
oldN++
}
if i < len(adds) {
r.newNum, r.newText, r.newKind = newN, adds[i], diffAdd
newN++
}
rows = append(rows, r)
}
dels, adds = dels[:0], adds[:0]
}
for _, ln := range strings.Split(strings.TrimRight(diff, "\n"), "\n") {
switch {
case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"),
strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "index "):
continue
case strings.HasPrefix(ln, "@@"):
flush()
oldN, newN = hunkStarts(ln)
case strings.HasPrefix(ln, "-"):
dels = append(dels, ln[1:])
case strings.HasPrefix(ln, "+"):
adds = append(adds, ln[1:])
default: // context (leading space) or stray blank line
flush()
text := strings.TrimPrefix(ln, " ")
rows = append(rows, diffRow{
oldNum: oldN, newNum: newN, oldText: text, newText: text,
oldKind: diffContext, newKind: diffContext,
})
oldN++
newN++
}
}
flush()
return rows
}
// isUnifiedDiff reports whether s looks like a unified diff (file/hunk headers)
// rather than arbitrary preview text such as a shell argv JSON. Non-diff previews
// must not go through the two-column renderer (it would show identical columns).
func isUnifiedDiff(s string) bool {
return strings.Contains(s, "@@ -") || strings.HasPrefix(s, "--- ") || strings.Contains(s, "\n--- ")
}
// previewPlainLines splits non-diff preview text into display lines.
func previewPlainLines(s string) []string {
return strings.Split(strings.TrimRight(s, "\n"), "\n")
}
// previewRowCount is the number of content rows a preview occupies — diff rows
// for a real diff, plain lines otherwise. Drives band sizing and scroll bounds.
func previewRowCount(s string) int {
if isUnifiedDiff(s) {
return len(parseUnifiedDiff(s))
}
return len(previewPlainLines(s))
}
// renderPreviewLines renders non-diff preview text as dim plain lines (single
// column), up to maxRows starting at off, each truncated to width.
func (m Model) renderPreviewLines(content string, width, maxRows, off int) []string {
lines := previewPlainLines(content)
if off < 0 {
off = 0
}
if off > len(lines) {
off = len(lines)
}
end := off + maxRows
if end > len(lines) {
end = len(lines)
}
out := make([]string, 0, end-off)
for _, ln := range lines[off:end] {
out = append(out, m.theme.span(truncate(ln, width), m.theme.P.Dim))
}
return out
}
// hunkStarts reads the starting old/new line numbers from an @@ -a,b +c,d @@ marker.
func hunkStarts(h string) (int, int) {
old, newer := 1, 1
for _, f := range strings.Fields(h) {
switch {
case strings.HasPrefix(f, "-"):
old = atoiComma(f[1:])
case strings.HasPrefix(f, "+"):
newer = atoiComma(f[1:])
}
}
return old, newer
}
func atoiComma(s string) int {
if i := strings.IndexByte(s, ','); i >= 0 {
s = s[:i]
}
n, _ := strconv.Atoi(s)
return n
}
// diffTarget returns the path the diff writes to (the +++ side), stripped of the
// b/ prefix. Empty if the diff carries no header.
func diffTarget(diff string) string {
for _, ln := range strings.Split(diff, "\n") {
if strings.HasPrefix(ln, "+++ ") {
p := strings.Fields(strings.TrimPrefix(ln, "+++ "))[0]
p = strings.TrimPrefix(p, "b/")
if p != "/dev/null" {
return p
}
}
}
return ""
}
const diffGutter = 4 // line-number column width
// renderSplitDiff renders aligned rows as a two-column old|new view exactly
// `width` cells wide, returning up to maxRows lines starting at offset off.
func (m Model) renderSplitDiff(rows []diffRow, width, maxRows, off int) []string {
colW := (width - 1) / 2
if colW < diffGutter+4 {
colW = diffGutter + 4
}
if off < 0 {
off = 0
}
if off > len(rows) {
off = len(rows)
}
end := off + maxRows
if end > len(rows) {
end = len(rows)
}
sep := lipgloss.NewStyle().Foreground(m.theme.P.Border).Background(m.theme.P.Bg).Render("│")
out := make([]string, 0, end-off)
for _, r := range rows[off:end] {
out = append(out,
m.diffCell(r.oldNum, r.oldText, r.oldKind, colW)+sep+
m.diffCell(r.newNum, r.newText, r.newKind, colW))
}
return out
}
// diffCell renders one side of a diff row (gutter number, +/- sign, text) padded
// to width w with the kind's background tint.
func (m Model) diffCell(num int, text string, kind diffKind, w int) string {
t := m.theme
if kind == diffBlank {
return lipgloss.NewStyle().Background(t.P.Bg).Render(strings.Repeat(" ", w))
}
var bg, textFg, signFg lipgloss.Color
sign := " "
switch kind {
case diffAdd:
bg, textFg, signFg, sign = t.P.DiffAdd, t.P.FgStrong, t.P.OK, "+"
case diffDel:
bg, textFg, signFg, sign = t.P.DiffDel, t.P.FgStrong, t.P.Bad, "-"
default: // context
bg, textFg, signFg = t.P.Bg, t.P.Dim, t.P.Faint
}
numStr := ""
if num > 0 {
numStr = itoa(num)
}
numCell := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(padLeft(numStr, diffGutter) + " ")
signCell := lipgloss.NewStyle().Foreground(signFg).Background(bg).Bold(true).Render(sign + " ")
textW := w - diffGutter - 3
if textW < 1 {
textW = 1
}
body := lipgloss.NewStyle().Foreground(textFg).Background(bg).Render(truncate(text, textW))
return padTo(numCell+signCell+body, w, bg)
}
// padLeft right-aligns s in a field of width w (space-padded on the left).
func padLeft(s string, w int) string {
if len(s) >= w {
return s
}
return strings.Repeat(" ", w-len(s)) + s
}