Skip to content
Closed
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
11 changes: 11 additions & 0 deletions internal/gitx/gitx.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,14 @@ func CurrentBranch(repoRoot string) (string, error) {
}
return strings.TrimSpace(string(b)), nil
}

func Log(repoRoot string, options... string) ([]string, error) {
args := append([]string{"-C", repoRoot, "log"}, options...)
cmd := exec.Command("git", args...)

b, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git log %s: %w", strings.Join(options, " "),err)
}
return strings.Split(string(b), "\n"), nil
}
11 changes: 5 additions & 6 deletions internal/tui/theme.go → internal/theme/core.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package tui
package theme

import (
"os"
"encoding/json"
"os"
"path/filepath"

"github.com/charmbracelet/lipgloss"
Expand All @@ -16,7 +16,7 @@ type Theme struct {
DividerColor string `json:"dividerColor"` // e.g. "240"
}

func defaultTheme() Theme {
func DefaultTheme() Theme {
return Theme{
AddColor: "34",
DelColor: "196",
Expand All @@ -26,8 +26,8 @@ func defaultTheme() Theme {
}

// loadThemeFromRepo tries .diffium/theme.json at repoRoot.
func loadThemeFromRepo(repoRoot string) Theme {
t := defaultTheme()
func LoadThemeFromRepo(repoRoot string) Theme {
t := DefaultTheme()
path := filepath.Join(repoRoot, ".diffium", "theme.json")
b, err := os.ReadFile(path)
if err != nil {
Expand Down Expand Up @@ -64,4 +64,3 @@ func (t Theme) DelText(s string) string {
func (t Theme) DividerText(s string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(t.DividerColor)).Render(s)
}

78 changes: 78 additions & 0 deletions internal/tui/ansi/escape.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package ansi

import "unicode/utf8"

// ConsumeEscape consumes an ANSI escape sequence starting at position i.
// Returns the position after the escape sequence.
func ConsumeEscape(s string, i int) int {
if i >= len(s) || s[i] != 0x1b {
if i+1 > len(s) {
return len(s)
}
return i + 1
}

j := i + 1
if j >= len(s) {
return j
}

switch s[j] {
case '[': // CSI
j++
for j < len(s) {
c := s[j]
if c >= 0x40 && c <= 0x7e {
j++
break
}
j++
}
case ']': // OSC
j++
for j < len(s) && s[j] != 0x07 {
j++
}
if j < len(s) {
j++
}
case 'P', 'X', '^', '_': // DCS, SOS, PM, APC
j++
for j < len(s) {
if s[j] == 0x1b {
j++
break
}
j++
}
default:
j++
}

if j <= i {
return i + 1
}
return j
}

// Strip removes all ANSI escape sequences from the string.
func Strip(s string) string {
var result []byte
i := 0
for i < len(s) {
if s[i] == 0x1b {
next := ConsumeEscape(s, i)
i = next
continue
}
result = append(result, s[i])
i++
}
return string(result)
}

// VisualWidth returns the visual width of a string (rune count, excluding ANSI codes).
func VisualWidth(s string) int {
plain := Strip(s)
return utf8.RuneCountInString(plain)
}
39 changes: 39 additions & 0 deletions internal/tui/ansi/slice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package ansi

import (
"strings"

"github.com/charmbracelet/x/ansi"
)

// SliceHorizontal returns a substring starting at visual column start with at most width columns.
// Preserves ANSI escape sequences.
func SliceHorizontal(s string, start, width int) string {
if start <= 0 {
return ansi.Truncate(s, width, "")
}
head := ansi.Truncate(s, start+width, "")
return ansi.TruncateLeft(head, width, "")
}

// ClipToWidth truncates string to at most w visual columns without ellipsis.
func ClipToWidth(s string, w int) string {
if w <= 0 {
return ""
}
return ansi.Truncate(s, w, "")
}

// PadExact pads string with spaces to exactly width w (ANSI-aware).
func PadExact(s string, w int) string {
vw := VisualWidth(s)
if vw >= w {
return s
}
return s + strings.Repeat(" ", w-vw)
}

// TruncateToWidth truncates to width with ellipsis if needed.
func TruncateToWidth(s string, width int) string {
return ansi.Truncate(s, width, "…")
}
26 changes: 26 additions & 0 deletions internal/tui/ansi/wrap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ansi

import (
"strings"

"github.com/charmbracelet/x/ansi"
)

// WrapLine wraps a single line to the given width, preserving ANSI codes.
func WrapLine(s string, width int) []string {
if width <= 0 {
return []string{""}
}
wrapped := ansi.Hardwrap(s, width, false)
return strings.Split(wrapped, "\n")
}

// WrapLines wraps multiple lines.
func WrapLines(lines []string, width int) []string {
result := make([]string, 0, len(lines)*2)
for _, line := range lines {
wrapped := WrapLine(line, width)
result = append(result, wrapped...)
}
return result
}
90 changes: 90 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package tui

import (
"sort"
"time"

tea "github.com/charmbracelet/bubbletea"
"github.com/interpretive-systems/diffium/internal/diffview"
"github.com/interpretive-systems/diffium/internal/gitx"
"github.com/interpretive-systems/diffium/internal/prefs"
)

// loadFiles loads the changed files list.
func loadFiles(repoRoot, diffMode string) tea.Cmd {
return func() tea.Msg {
allFiles, err := gitx.ChangedFiles(repoRoot)
if err != nil {
return filesMsg{files: nil, err: err}
}

var filtered []gitx.FileChange
for _, file := range allFiles {
if diffMode == "staged" {
if file.Staged {
filtered = append(filtered, file)
}
} else {
if file.Unstaged || file.Untracked {
filtered = append(filtered, file)
}
}
}

// Stable sort for deterministic UI
sort.Slice(filtered, func(i, j int) bool {
return filtered[i].Path < filtered[j].Path
})

return filesMsg{files: filtered, err: nil}
}
}

// loadDiff loads the diff for a specific file.
func loadDiff(repoRoot, path, diffMode string) tea.Cmd {
return func() tea.Msg {
var d string
var err error
if diffMode == "staged" {
d, err = gitx.DiffStaged(repoRoot, path)
} else {
d, err = gitx.DiffHEAD(repoRoot, path)
}
if err != nil {
return diffMsg{path: path, err: err}
}
rows := diffview.BuildRowsFromUnified(d)
return diffMsg{path: path, rows: rows}
}
}

// loadLastCommit loads the last commit summary.
func loadLastCommit(repoRoot string) tea.Cmd {
return func() tea.Msg {
s, err := gitx.LastCommitSummary(repoRoot)
return lastCommitMsg{summary: s, err: err}
}
}

// loadCurrentBranch loads the current branch name.
func loadCurrentBranch(repoRoot string) tea.Cmd {
return func() tea.Msg {
name, err := gitx.CurrentBranch(repoRoot)
return currentBranchMsg{name: name, err: err}
}
}

// loadPrefs loads user preferences.
func loadPrefs(repoRoot string) tea.Cmd {
return func() tea.Msg {
p := prefs.Load(repoRoot)
return prefsMsg{p: p, err: nil}
}
}

// tickOnce schedules a single tick after 1 second.
func tickOnce() tea.Cmd {
return tea.Tick(time.Second, func(time.Time) tea.Msg {
return tickMsg{}
})
}
Loading
Loading