d247b19608
Move the Go TUI from github.com/charmbracelet/{bubbletea,lipgloss} to the v2
ecosystem at charm.land/{bubbletea,lipgloss}/v2 (Go 1.25). v2's kitty keyboard
disambiguation is on by default, so Shift+Enter now reliably inserts a newline
in the composer (Ctrl+J / Alt+Enter kept as fallbacks for non-kitty terminals).
Approach: rather than rewrite ~190 key-match sites to v2's (Code, Mod) idiom, a
small shim (internal/app/key.go) converts a v2 KeyPressMsg into the v1-shaped
keyMsg the handlers already expect, at the single Update boundary. The rest is
mechanical:
- key constants tea.Key* → shim consts; tea.KeyMsg → keyMsg.
- lipgloss.Color is now a func returning color.Color, not a type → fields/params
retyped to image/color.Color (theme/diff/overlays/view).
- v2 View() returns tea.View: render() builds the string, View() wraps it and
carries AltScreen (alt-screen is a per-frame View field now, not a program opt).
- WithWhitespaceBackground/Foreground → WithWhitespaceStyle(Style).
- preview: drop lipgloss.SetColorProfile (v2 renders truecolor by default).
Build, vet, gofmt, and the full test suite are green; preview renders truecolor
across kinds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
6.5 KiB
Go
231 lines
6.5 KiB
Go
package app
|
|
|
|
import (
|
|
"image/color"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
)
|
|
|
|
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 {
|
|
// File headers carry a trailing space ("+++ b/x"); requiring it means a real
|
|
// changed line whose content starts with "++"/"--" isn't mistaken for a header
|
|
// and silently dropped from the rendered diff (and its row count).
|
|
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 color.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
|
|
}
|