Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ jobs:
restore-keys: |
${{ runner.os }}-go-

- name: Format
run: go fmt ./...

- name: Build
run: go build ./...

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
releases/
bin/
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ BIN ?= bin/diffium

.PHONY: build test run fmt

build:
build: fmt
go build -o $(BIN) ./cmd/diffium

test:
Expand Down
11 changes: 5 additions & 6 deletions cmd/diffium/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package main

import (
"log"
"log"

"github.com/interpretive-systems/diffium/internal/cli"
"github.com/interpretive-systems/diffium/internal/cli"
)

func main() {
if err := cli.Execute(); err != nil {
log.Fatal(err)
}
if err := cli.Execute(); err != nil {
log.Fatal(err)
}
}

13 changes: 8 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ module github.com/interpretive-systems/diffium

go 1.25.1

require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.8
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/ansi v0.10.1
github.com/spf13/cobra v1.10.1
)

require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.21.0 // indirect
github.com/charmbracelet/bubbletea v1.3.8 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
Expand All @@ -22,7 +26,6 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
Expand Down
43 changes: 21 additions & 22 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
package cli

import (
"fmt"
"os"
"fmt"
"os"

"github.com/spf13/cobra"
"github.com/spf13/cobra"
)

func Execute() error {
root := &cobra.Command{
Use: "diffium",
Short: "Diff-first TUI for git changes",
Long: "Diffium: Explore and review git diffs in a side-by-side TUI.",
}
root := &cobra.Command{
Use: "diffium",
Short: "Diff-first TUI for git changes",
Long: "Diffium: Explore and review git diffs in a side-by-side TUI.",
}

root.PersistentFlags().StringP("repo", "r", ".", "Path to repository root (default: current dir)")
root.PersistentFlags().StringP("repo", "r", ".", "Path to repository root (default: current dir)")

// Add subcommands
root.AddCommand(newWatchCmd())
// Add subcommands
root.AddCommand(newWatchCmd())

if err := root.Execute(); err != nil {
return fmt.Errorf("execute: %w", err)
}
return nil
if err := root.Execute(); err != nil {
return fmt.Errorf("execute: %w", err)
}
return nil
}

func mustGetStringFlag(cmd *cobra.Command, name string) string {
v, err := cmd.Flags().GetString(name)
if err != nil {
fmt.Fprintln(os.Stderr, "flag error:", err)
os.Exit(2)
}
return v
v, err := cmd.Flags().GetString(name)
if err != nil {
fmt.Fprintln(os.Stderr, "flag error:", err)
os.Exit(2)
}
return v
}

35 changes: 17 additions & 18 deletions internal/cli/watch.go
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
package cli

import (
"fmt"
"fmt"

"github.com/interpretive-systems/diffium/internal/gitx"
"github.com/interpretive-systems/diffium/internal/tui"
"github.com/spf13/cobra"
"github.com/interpretive-systems/diffium/internal/gitx"
"github.com/interpretive-systems/diffium/internal/tui"
"github.com/spf13/cobra"
)

func newWatchCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "watch",
Short: "Open the TUI and watch for changes",
RunE: func(cmd *cobra.Command, args []string) error {
repoPath := mustGetStringFlag(cmd.Root(), "repo")
root, err := gitx.RepoRoot(repoPath)
if err != nil {
return fmt.Errorf("not a git repo: %w", err)
}
return tui.Run(root)
},
}
return cmd
cmd := &cobra.Command{
Use: "watch",
Short: "Open the TUI and watch for changes",
RunE: func(cmd *cobra.Command, args []string) error {
repoPath := mustGetStringFlag(cmd.Root(), "repo")
root, err := gitx.RepoRoot(repoPath)
if err != nil {
return fmt.Errorf("not a git repo: %w", err)
}
return tui.Run(root)
},
}
return cmd
}

153 changes: 76 additions & 77 deletions internal/diffview/side_by_side.go
Original file line number Diff line number Diff line change
@@ -1,106 +1,105 @@
package diffview

import (
"bufio"
"strings"
"bufio"
"strings"
)

// RowKind represents the semantic type of a side-by-side row.
type RowKind int

const (
RowContext RowKind = iota
RowAdd
RowDel
RowReplace
RowHunk
RowMeta
RowContext RowKind = iota
RowAdd
RowDel
RowReplace
RowHunk
RowMeta
)

// Row represents a single visual row for side-by-side rendering.
type Row struct {
Left string
Right string
Kind RowKind
Meta string // for hunk header text
Left string
Right string
Kind RowKind
Meta string // for hunk header text
}

// BuildRowsFromUnified parses a unified diff string into side-by-side rows.
// It uses a simple pairing strategy within each hunk: deletions are paired
// with subsequent additions as replacements; any remaining lines are shown
// as left-only (deletions) or right-only (additions).
func BuildRowsFromUnified(unified string) []Row {
s := bufio.NewScanner(strings.NewReader(unified))
s.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // allow large lines
s := bufio.NewScanner(strings.NewReader(unified))
s.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // allow large lines

rows := make([]Row, 0, 256)
pendingDel := make([]string, 0)
rows := make([]Row, 0, 256)
pendingDel := make([]string, 0)

flushPending := func() {
for _, dl := range pendingDel {
rows = append(rows, Row{Left: trimPrefix(dl), Right: "", Kind: RowDel})
}
pendingDel = pendingDel[:0]
}
flushPending := func() {
for _, dl := range pendingDel {
rows = append(rows, Row{Left: trimPrefix(dl), Right: "", Kind: RowDel})
}
pendingDel = pendingDel[:0]
}

inHunk := false
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
// Metadata; flush any pending deletions
flushPending()
rows = append(rows, Row{Kind: RowMeta, Meta: line})
continue
}
if strings.HasPrefix(line, "@@ ") {
flushPending()
rows = append(rows, Row{Kind: RowHunk, Meta: line})
inHunk = true
continue
}
if !inHunk {
// Outside hunks, we don't have meaningful line-level info; skip
continue
}
inHunk := false
for s.Scan() {
line := s.Text()
if strings.HasPrefix(line, "diff --git ") || strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") {
// Metadata; flush any pending deletions
flushPending()
rows = append(rows, Row{Kind: RowMeta, Meta: line})
continue
}
if strings.HasPrefix(line, "@@ ") {
flushPending()
rows = append(rows, Row{Kind: RowHunk, Meta: line})
inHunk = true
continue
}
if !inHunk {
// Outside hunks, we don't have meaningful line-level info; skip
continue
}

if len(line) == 0 {
// blank line inside hunk: treat as context
flushPending()
rows = append(rows, Row{Left: "", Right: "", Kind: RowContext})
continue
}
if len(line) == 0 {
// blank line inside hunk: treat as context
flushPending()
rows = append(rows, Row{Left: "", Right: "", Kind: RowContext})
continue
}

switch line[0] {
case ' ':
flushPending()
t := trimPrefix(line)
rows = append(rows, Row{Left: t, Right: t, Kind: RowContext})
case '-':
pendingDel = append(pendingDel, line)
case '+':
if len(pendingDel) > 0 {
// Pair with the earliest pending deletion
dl := pendingDel[0]
pendingDel = pendingDel[1:]
rows = append(rows, Row{Left: trimPrefix(dl), Right: trimPrefix(line), Kind: RowReplace})
} else {
rows = append(rows, Row{Left: "", Right: trimPrefix(line), Kind: RowAdd})
}
default:
// Unknown line; ignore
}
}
flushPending()
return rows
switch line[0] {
case ' ':
flushPending()
t := trimPrefix(line)
rows = append(rows, Row{Left: t, Right: t, Kind: RowContext})
case '-':
pendingDel = append(pendingDel, line)
case '+':
if len(pendingDel) > 0 {
// Pair with the earliest pending deletion
dl := pendingDel[0]
pendingDel = pendingDel[1:]
rows = append(rows, Row{Left: trimPrefix(dl), Right: trimPrefix(line), Kind: RowReplace})
} else {
rows = append(rows, Row{Left: "", Right: trimPrefix(line), Kind: RowAdd})
}
default:
// Unknown line; ignore
}
}
flushPending()
return rows
}

func trimPrefix(s string) string {
if s == "" {
return s
}
if s[0] == ' ' || s[0] == '+' || s[0] == '-' {
return s[1:]
}
return s
if s == "" {
return s
}
if s[0] == ' ' || s[0] == '+' || s[0] == '-' {
return s[1:]
}
return s
}

Loading
Loading