mirror of
https://github.com/anotherhadi/spilltea.git
synced 2026-05-20 09:42:34 +02:00
Init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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(" <(^_^)>\nsend 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(" (・3・)\nwaiting 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), kind: lineAdded})
|
||||
j--
|
||||
default:
|
||||
left = append(left, diffLine{text: hlA(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
|
||||
}
|
||||
|
||||
func diffBindings() []key.Binding {
|
||||
g := keys.Keys.Global
|
||||
return []key.Binding{
|
||||
g.Up, g.Down, g.ScrollUp, g.ScrollDown,
|
||||
g.CycleFocus, keys.Keys.Diff.Clear,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
all := append(diffBindings(), keys.Keys.Global.Bindings()...)
|
||||
return keys.ChunkByWidth(all, m.width)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/keys"
|
||||
notificationsUI "github.com/anotherhadi/spilltea/internal/ui/components/notifications"
|
||||
)
|
||||
|
||||
// SendToDiffMsg carries a raw HTTP request or response to the diff page.
|
||||
type SendToDiffMsg struct {
|
||||
Label string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// DiffReadyMsg is emitted when both slots are filled and the diff is ready to view.
|
||||
type DiffReadyMsg struct{}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case SendToDiffMsg:
|
||||
if m.left.raw == "" {
|
||||
m.left = slot{label: msg.Label, raw: msg.Raw}
|
||||
m.refreshViewports()
|
||||
return m, func() tea.Msg {
|
||||
return notificationsUI.NotificationMsg{
|
||||
Title: "Entry selected",
|
||||
Body: "Select a second entry to compare",
|
||||
Kind: notificationsUI.KindInfo,
|
||||
}
|
||||
}
|
||||
} else if m.right.raw == "" {
|
||||
m.right = slot{label: msg.Label, raw: msg.Raw}
|
||||
m.computeDiff()
|
||||
m.focus = bothSlots
|
||||
m.leftViewport.SetYOffset(0)
|
||||
m.rightViewport.SetYOffset(0)
|
||||
m.leftViewport.SetXOffset(0)
|
||||
m.rightViewport.SetXOffset(0)
|
||||
m.refreshViewports()
|
||||
return m, func() tea.Msg { return DiffReadyMsg{} }
|
||||
} else {
|
||||
// Both full: reset and start new comparison
|
||||
m.left = slot{label: msg.Label, raw: msg.Raw}
|
||||
m.right = slot{}
|
||||
m.leftLines = nil
|
||||
m.rightLines = nil
|
||||
m.focus = bothSlots
|
||||
m.leftViewport.SetYOffset(0)
|
||||
m.rightViewport.SetYOffset(0)
|
||||
m.leftViewport.SetXOffset(0)
|
||||
m.rightViewport.SetXOffset(0)
|
||||
m.refreshViewports()
|
||||
return m, func() tea.Msg {
|
||||
return notificationsUI.NotificationMsg{
|
||||
Title: "Entry replaced",
|
||||
Body: "Select a second entry to compare",
|
||||
Kind: notificationsUI.KindInfo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
switch msg.Button {
|
||||
case tea.MouseWheelUp:
|
||||
if msg.Mod.Contains(tea.ModShift) {
|
||||
m.scrollH(-6)
|
||||
} else {
|
||||
m.scroll(-1)
|
||||
}
|
||||
case tea.MouseWheelDown:
|
||||
if msg.Mod.Contains(tea.ModShift) {
|
||||
m.scrollH(6)
|
||||
} else {
|
||||
m.scroll(1)
|
||||
}
|
||||
case tea.MouseWheelLeft:
|
||||
m.scrollH(-6)
|
||||
case tea.MouseWheelRight:
|
||||
m.scrollH(6)
|
||||
}
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keys.Keys.Global.CycleFocus):
|
||||
m.focus = m.focus.next()
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.Up):
|
||||
m.scroll(-1)
|
||||
case key.Matches(msg, keys.Keys.Global.Down):
|
||||
m.scroll(1)
|
||||
case key.Matches(msg, keys.Keys.Global.ScrollUp):
|
||||
step := m.leftViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.scroll(-step)
|
||||
case key.Matches(msg, keys.Keys.Global.ScrollDown):
|
||||
step := m.leftViewport.Height() / 2
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
m.scroll(step)
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.Left):
|
||||
m.scrollH(-6)
|
||||
case key.Matches(msg, keys.Keys.Global.Right):
|
||||
m.scrollH(6)
|
||||
|
||||
case key.Matches(msg, keys.Keys.Diff.Clear):
|
||||
switch m.focus {
|
||||
case leftSlot:
|
||||
m.left = m.right
|
||||
m.right = slot{}
|
||||
m.leftLines = nil
|
||||
m.rightLines = nil
|
||||
m.focus = bothSlots
|
||||
case rightSlot:
|
||||
m.right = slot{}
|
||||
m.leftLines = nil
|
||||
m.rightLines = nil
|
||||
m.focus = bothSlots
|
||||
default:
|
||||
m.left = slot{}
|
||||
m.right = slot{}
|
||||
m.leftLines = nil
|
||||
m.rightLines = nil
|
||||
m.focus = bothSlots
|
||||
}
|
||||
m.leftViewport.SetYOffset(0)
|
||||
m.rightViewport.SetYOffset(0)
|
||||
m.leftViewport.SetXOffset(0)
|
||||
m.rightViewport.SetXOffset(0)
|
||||
m.refreshViewports()
|
||||
|
||||
case key.Matches(msg, keys.Keys.Global.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
m.recalcSizes()
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/anotherhadi/spilltea/internal/icons"
|
||||
"github.com/anotherhadi/spilltea/internal/style"
|
||||
)
|
||||
|
||||
func (m Model) View() tea.View {
|
||||
if m.width == 0 {
|
||||
return tea.NewView("Loading...")
|
||||
}
|
||||
|
||||
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
|
||||
panelH := m.height - statusH
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.renderPanels(panelH),
|
||||
m.renderStatusBar(),
|
||||
)
|
||||
return tea.NewView(content)
|
||||
}
|
||||
|
||||
func (m *Model) renderPanels(panelH int) string {
|
||||
s := style.S
|
||||
|
||||
leftW := m.width / 2
|
||||
rightW := m.width - leftW
|
||||
|
||||
leftTitle := icons.I.Diff + "First"
|
||||
if m.left.label != "" {
|
||||
leftTitle = icons.I.Diff + "First: " + m.left.label
|
||||
}
|
||||
rightTitle := icons.I.Diff + "Second"
|
||||
if m.right.label != "" {
|
||||
rightTitle = icons.I.Diff + "Second: " + m.right.label
|
||||
}
|
||||
|
||||
leftBorder := s.Panel
|
||||
rightBorder := s.Panel
|
||||
switch m.focus {
|
||||
case bothSlots:
|
||||
leftBorder = s.PanelFocused
|
||||
rightBorder = s.PanelFocused
|
||||
case leftSlot:
|
||||
leftBorder = s.PanelFocused
|
||||
case rightSlot:
|
||||
rightBorder = s.PanelFocused
|
||||
}
|
||||
|
||||
left := style.RenderWithTitle(leftBorder, leftTitle, m.leftViewport.View(), leftW, panelH)
|
||||
right := style.RenderWithTitle(rightBorder, rightTitle, m.rightViewport.View(), rightW, panelH)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, right)
|
||||
}
|
||||
|
||||
func (m *Model) renderStatusBar() string {
|
||||
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(diffKeyMap{width: m.width}))
|
||||
}
|
||||
|
||||
func renderLeftLines(lines []diffLine) string {
|
||||
s := style.S
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
switch l.kind {
|
||||
case lineRemoved:
|
||||
sb.WriteString(style.Paint(s.Error, "- ") + l.text + "\n")
|
||||
case lineAdded:
|
||||
sb.WriteString("\n")
|
||||
default:
|
||||
sb.WriteString(" " + l.text + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderRightLines(lines []diffLine) string {
|
||||
s := style.S
|
||||
var sb strings.Builder
|
||||
for _, l := range lines {
|
||||
switch l.kind {
|
||||
case lineAdded:
|
||||
sb.WriteString(style.Paint(s.Success, "+ ") + l.text + "\n")
|
||||
case lineRemoved:
|
||||
sb.WriteString("\n")
|
||||
default:
|
||||
sb.WriteString(" " + l.text + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
Reference in New Issue
Block a user