package diff import ( "strings" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/anotherhadi/spilltea/internal/keys" "github.com/anotherhadi/spilltea/internal/style" "github.com/anotherhadi/spilltea/internal/util" ) // isWordChar reports whether c belongs to a "word" token (letter, digit, underscore). func isWordChar(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' } // tokenize splits s into runs of word characters and individual non-word bytes. func tokenize(s string) []string { var out []string i := 0 for i < len(s) { if isWordChar(s[i]) { j := i for j < len(s) && isWordChar(s[j]) { j++ } out = append(out, s[i:j]) i = j } else { out = append(out, s[i:i+1]) i++ } } return out } // wordDiff computes a token-level diff between leftLine and rightLine and // returns the two rendered strings with changed tokens highlighted. func wordDiff(leftLine, rightLine string) (leftRendered, rightRendered string) { lToks := tokenize(leftLine) rToks := tokenize(rightLine) n, m := len(lToks), len(rToks) dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, m+1) } for i := 1; i <= n; i++ { for j := 1; j <= m; j++ { if lToks[i-1] == rToks[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] } } } type segment struct { kind int // 0=same, 1=left-only, 2=right-only tok string } segs := make([]segment, 0, n+m) i, j := n, m for i > 0 || j > 0 { switch { case i > 0 && j > 0 && lToks[i-1] == rToks[j-1]: segs = append(segs, segment{0, lToks[i-1]}) i-- j-- case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): segs = append(segs, segment{2, rToks[j-1]}) j-- default: segs = append(segs, segment{1, lToks[i-1]}) i-- } } for lo, hi := 0, len(segs)-1; lo < hi; lo, hi = lo+1, hi-1 { segs[lo], segs[hi] = segs[hi], segs[lo] } s := style.S boldErr := lipgloss.NewStyle().Foreground(s.Error).Bold(true) boldOk := lipgloss.NewStyle().Foreground(s.Success).Bold(true) dim := lipgloss.NewStyle().Foreground(s.Subtle) var lb, rb strings.Builder for _, seg := range segs { switch seg.kind { case 0: lb.WriteString(dim.Render(seg.tok)) rb.WriteString(dim.Render(seg.tok)) case 1: lb.WriteString(boldErr.Render(seg.tok)) case 2: rb.WriteString(boldOk.Render(seg.tok)) } } return lb.String(), rb.String() } // pairAndHighlight collapses adjacent removed/added blocks onto the same rows // (eliminating the interleaved padding lines) and applies word-level diff // highlighting to each paired line. Unpaired excess removals/additions keep // their original single-sided padding row. func pairAndHighlight(left, right []diffLine) ([]diffLine, []diffLine) { newLeft := make([]diffLine, 0, len(left)) newRight := make([]diffLine, 0, len(right)) i := 0 for i < len(left) { if left[i].kind != lineRemoved { newLeft = append(newLeft, left[i]) newRight = append(newRight, right[i]) i++ continue } rStart := i for i < len(left) && left[i].kind == lineRemoved { i++ } rEnd := i aStart := i for i < len(left) && left[i].kind == lineAdded { i++ } aEnd := i nRemoved := rEnd - rStart nAdded := aEnd - aStart pairs := nRemoved if nAdded < pairs { pairs = nAdded } for k := 0; k < pairs; k++ { lLine := left[rStart+k] rLine := right[aStart+k] lLine.text, rLine.text = wordDiff(lLine.plainText, rLine.plainText) newLeft = append(newLeft, lLine) newRight = append(newRight, rLine) } for k := pairs; k < nRemoved; k++ { newLeft = append(newLeft, left[rStart+k]) newRight = append(newRight, diffLine{kind: lineRemoved}) } for k := pairs; k < nAdded; k++ { newLeft = append(newLeft, diffLine{kind: lineAdded}) newRight = append(newRight, right[aStart+k]) } } return newLeft, newRight } type slot struct { label string raw string } type focusedSlot int const ( bothSlots focusedSlot = iota leftSlot rightSlot ) func (f focusedSlot) next() focusedSlot { return (f + 1) % 3 } type lineKind int const ( lineUnchanged lineKind = iota lineAdded lineRemoved ) type diffLine struct { text string // displayed text (highlighted, possibly word-diff decorated) plainText string // plain text for word-diff pairing (empty for padding lines) kind lineKind } type Model struct { left slot right slot focus focusedSlot leftLines []diffLine rightLines []diffLine leftViewport viewport.Model rightViewport viewport.Model help help.Model width int height int } func New() Model { return Model{ leftViewport: style.NewViewport(), rightViewport: style.NewViewport(), help: style.NewHelp(), } } func (m Model) Init() tea.Cmd { return nil } // CurrentRaw returns the raw content of the focused slot (left when both are focused). func (m Model) CurrentRaw() string { if m.focus == rightSlot { return m.right.raw } return m.left.raw } func (m *Model) SetSize(w, h int) { m.width = w m.height = h m.recalcSizes() } func (m *Model) recalcSizes() { m.help.SetWidth(m.width - 2) statusH := strings.Count(m.renderStatusBar(), "\n") + 1 panelH := m.height - statusH if panelH < 0 { panelH = 0 } leftW := m.width / 2 rightW := m.width - leftW leftInner := leftW - 2 rightInner := rightW - 2 if leftInner < 0 { leftInner = 0 } if rightInner < 0 { rightInner = 0 } viewportH := style.PanelContentH(panelH) m.leftViewport.SetWidth(leftInner) m.leftViewport.SetHeight(viewportH) m.rightViewport.SetWidth(rightInner) m.rightViewport.SetHeight(viewportH) m.refreshViewports() } func (m *Model) computeDiff() { if m.left.raw == "" || m.right.raw == "" { m.leftLines = nil m.rightLines = nil return } leftNorm := normRaw(m.left.raw) rightNorm := normRaw(m.right.raw) leftPlain := strings.Split(leftNorm, "\n") rightPlain := strings.Split(rightNorm, "\n") leftHL := hlLines(leftNorm) rightHL := hlLines(rightNorm) m.leftLines, m.rightLines = lcsAlignedDiff(leftPlain, rightPlain, leftHL, rightHL) m.leftLines, m.rightLines = pairAndHighlight(m.leftLines, m.rightLines) } func normRaw(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") return strings.TrimRight(s, "\n") } func hlLines(raw string) []string { s := strings.TrimRight(style.HighlightHTTP(raw), "\n") if s == "" { return nil } return strings.Split(s, "\n") } func (m *Model) refreshViewports() { s := style.S if m.left.raw == "" { placeholder := lipgloss.Place( m.leftViewport.Width(), m.leftViewport.Height(), lipgloss.Center, lipgloss.Center, s.Faint.Render(util.CenterLines("<(^_^)>", "send two entries here to compare")), ) m.leftViewport.SetContent(placeholder) m.rightViewport.SetContent("") return } if m.right.raw == "" { m.leftViewport.SetContent(style.HighlightHTTP(normRaw(m.left.raw))) placeholder := lipgloss.Place( m.rightViewport.Width(), m.rightViewport.Height(), lipgloss.Center, lipgloss.Center, s.Faint.Render(util.CenterLines("(・3・)", "waiting for second entry…")), ) m.rightViewport.SetContent(placeholder) return } m.leftViewport.SetContent(renderLeftLines(m.leftLines)) m.rightViewport.SetContent(renderRightLines(m.rightLines)) } func (m *Model) scroll(delta int) { offset := m.leftViewport.YOffset() + delta m.leftViewport.SetYOffset(offset) m.rightViewport.SetYOffset(offset) } func (m *Model) scrollH(delta int) { offset := m.leftViewport.XOffset() + delta m.leftViewport.SetXOffset(offset) m.rightViewport.SetXOffset(offset) } func lcsAlignedDiff(a, b, aHL, bHL []string) (left, right []diffLine) { hlA := func(i int) string { if i < len(aHL) { return aHL[i] } return a[i] } hlB := func(j int) string { if j < len(bHL) { return bHL[j] } return b[j] } n, m := len(a), len(b) dp := make([][]int, n+1) for i := range dp { dp[i] = make([]int, m+1) } for i := 1; i <= n; i++ { for j := 1; j <= m; 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] } } } left = make([]diffLine, 0, n+m) right = make([]diffLine, 0, n+m) i, j := n, m for i > 0 || j > 0 { switch { case i > 0 && j > 0 && a[i-1] == b[j-1]: left = append(left, diffLine{text: hlA(i - 1), kind: lineUnchanged}) right = append(right, diffLine{text: hlB(j - 1), kind: lineUnchanged}) i-- j-- case j > 0 && (i == 0 || dp[i][j-1] >= dp[i-1][j]): left = append(left, diffLine{kind: lineAdded}) right = append(right, diffLine{text: hlB(j - 1), plainText: b[j-1], kind: lineAdded}) j-- default: left = append(left, diffLine{text: hlA(i - 1), plainText: a[i-1], kind: lineRemoved}) right = append(right, diffLine{kind: lineRemoved}) i-- } } for lo, hi := 0, len(left)-1; lo < hi; lo, hi = lo+1, hi-1 { left[lo], left[hi] = left[hi], left[lo] right[lo], right[hi] = right[hi], right[lo] } return left, right } type diffKeyMap struct{ width int } func (diffKeyMap) ShortHelp() []key.Binding { g := keys.Keys.Global return []key.Binding{g.Up, g.Down, g.CycleFocus, keys.Keys.Diff.Clear, g.Help} } func (m diffKeyMap) FullHelp() [][]key.Binding { g := keys.Keys.Global pageGlobals := []key.Binding{g.Up, g.Down, g.CycleFocus, g.ScrollUp, g.ScrollDown, g.Left, g.Right, g.Copy, g.CopyAs} all := append(keys.Keys.Diff.Bindings(), pageGlobals...) all = append(all, g.CommonBindings()...) return keys.ChunkByWidth(all, m.width) }