feat: config yaml support

This commit is contained in:
Tommy Guo 2026-01-30 12:46:02 -05:00
parent d8dd109c3f
commit 7f68d13676
6 changed files with 179 additions and 37 deletions

View File

@ -5,13 +5,19 @@ import (
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/oug-t/difi/internal/config"
"github.com/oug-t/difi/internal/ui"
)
func main() {
p := tea.NewProgram(ui.NewModel(), tea.WithAltScreen())
// Load config (defaults used if file missing)
cfg := config.Load()
// Pass config to model
p := tea.NewProgram(ui.NewModel(cfg), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v\n", err)
fmt.Printf("Error running difi: %v", err)
os.Exit(1)
}
}

1
go.mod
View File

@ -6,6 +6,7 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
gopkg.in/yaml.v3 v3.0.1
)
require (

4
go.sum
View File

@ -53,3 +53,7 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

49
internal/config/config.go Normal file
View File

@ -0,0 +1,49 @@
package config
import (
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type Config struct {
Colors struct {
Border string `yaml:"border"`
Focus string `yaml:"focus"`
LineNumber string `yaml:"line_number"`
} `yaml:"colors"`
UI struct {
LineNumbers string `yaml:"line_numbers"` // "absolute", "relative", "hybrid", "hidden"
ShowGuide bool `yaml:"show_guide"` // The vertical separation line
} `yaml:"ui"`
}
func DefaultConfig() Config {
var c Config
c.Colors.Border = "#D9DCCF"
c.Colors.Focus = "#000000" // Default neutral focus
c.Colors.LineNumber = "#808080"
c.UI.LineNumbers = "hybrid"
c.UI.ShowGuide = true
return c
}
func Load() Config {
cfg := DefaultConfig()
home, err := os.UserHomeDir()
if err != nil {
return cfg
}
configPath := filepath.Join(home, ".config", "difi", "config.yml")
data, err := os.ReadFile(configPath)
if err != nil {
return cfg
}
// Parse YAML
_ = yaml.Unmarshal(data, &cfg)
return cfg
}

View File

@ -12,6 +12,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/config"
"github.com/oug-t/difi/internal/git"
"github.com/oug-t/difi/internal/tree"
)
@ -29,27 +30,26 @@ type Model struct {
fileTree list.Model
diffViewport viewport.Model
// Data
selectedPath string
currentBranch string
repoName string
// Diff State
diffContent string
diffLines []string
diffCursor int
// Input State for Vim Motions
inputBuffer string
// UI State
focus Focus
showHelp bool
width, height int
}
func NewModel() Model {
func NewModel(cfg config.Config) Model {
// Initialize styles with the loaded config
InitStyles(cfg)
files, _ := git.ListChangedFiles(TargetBranch)
items := tree.Build(files)
@ -101,7 +101,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
// Flag to track if we manually handled navigation
keyHandled := false
switch msg := msg.(type) {
@ -155,9 +154,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, git.OpenEditorCmd(m.selectedPath, line)
}
// Vim Motions
case "j", "down":
keyHandled = true // Mark as handled so we don't pass to list.Update()
keyHandled = true
count := m.getRepeatCount()
for i := 0; i < count; i++ {
if m.focus == FocusDiff {
@ -174,7 +172,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputBuffer = ""
case "k", "up":
keyHandled = true // Mark as handled
keyHandled = true
count := m.getRepeatCount()
for i := 0; i < count; i++ {
if m.focus == FocusDiff {
@ -195,7 +193,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
// Update Components
if m.focus == FocusTree {
if !keyHandled {
m.fileTree, cmd = m.fileTree.Update(msg)
@ -251,6 +248,7 @@ func (m Model) View() string {
return "Loading..."
}
// 1. PANES
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
@ -270,13 +268,43 @@ func (m Model) View() string {
end = len(m.diffLines)
}
// RENDER LOOP
for i := start; i < end; i++ {
line := m.diffLines[i]
// Relative Numbers
distance := int(math.Abs(float64(i - m.diffCursor)))
relNum := fmt.Sprintf("%d", distance)
lineNumStr := LineNumberStyle.Render(relNum)
// --- LINE NUMBER LOGIC ---
var numStr string
mode := CurrentConfig.UI.LineNumbers
if mode == "hidden" {
numStr = ""
} else {
// Is this the cursor line?
isCursor := (i == m.diffCursor)
if isCursor && mode == "hybrid" {
// HYBRID: Show Real File Line Number
realLine := git.CalculateFileLine(m.diffContent, m.diffCursor)
numStr = fmt.Sprintf("%d", realLine)
} else if isCursor && mode == "relative" {
numStr = "0"
} else if mode == "absolute" {
// Note: Calculating absolute for every line is expensive,
// usually absolute view shows Diff Line Index or File Line.
// For simple 'absolute' view, we often show viewport index + 1
numStr = fmt.Sprintf("%d", i+1)
} else {
// Default / Hybrid-non-cursor: Show Relative Distance
dist := int(math.Abs(float64(i - m.diffCursor)))
numStr = fmt.Sprintf("%d", dist)
}
}
lineNumRendered := ""
if numStr != "" {
lineNumRendered = LineNumberStyle.Render(numStr)
}
// -------------------------
if m.focus == FocusDiff && i == m.diffCursor {
line = SelectedItemStyle.Render(line)
@ -284,7 +312,7 @@ func (m Model) View() string {
line = " " + line
}
renderedDiff.WriteString(lineNumStr + line + "\n")
renderedDiff.WriteString(lineNumRendered + line + "\n")
}
diffView := DiffStyle.Copy().
@ -294,6 +322,7 @@ func (m Model) View() string {
mainPanes := lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView)
// 2. BOTTOM AREA
repoSection := StatusKeyStyle.Render(" " + m.repoName)
divider := StatusDividerStyle.Render("│")
@ -352,6 +381,7 @@ func (m Model) View() string {
return finalView
}
// -- Delegates (unchanged) --
type listDelegate struct{}
func (d listDelegate) Height() int { return 1 }

View File

@ -1,10 +1,15 @@
package ui
import "github.com/charmbracelet/lipgloss"
import (
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/config"
)
// Global UI colors and styles.
// Initialized once at startup via InitStyles.
var (
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#E5E5E5"}
ColorBorder lipgloss.AdaptiveColor
ColorFocus lipgloss.AdaptiveColor
ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"}
ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"}
@ -12,31 +17,78 @@ var (
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
PaneStyle lipgloss.Style
FocusedPaneStyle lipgloss.Style
DiffStyle lipgloss.Style
ItemStyle lipgloss.Style
SelectedItemStyle lipgloss.Style
LineNumberStyle lipgloss.Style
StatusBarStyle lipgloss.Style
StatusKeyStyle lipgloss.Style
StatusDividerStyle lipgloss.Style
HelpTextStyle lipgloss.Style
HelpDrawerStyle lipgloss.Style
CurrentConfig config.Config
)
// InitStyles initializes global styles based on the provided config.
// This should be called once during application startup.
func InitStyles(cfg config.Config) {
CurrentConfig = cfg
// Colors derived from user config
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: cfg.Colors.Border}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: cfg.Colors.Focus}
// Pane styles
PaneStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, true, false, false).
BorderForeground(ColorBorder)
Border(lipgloss.NormalBorder(), false, cfg.UI.ShowGuide, false, false).
BorderForeground(ColorBorder)
FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus)
BorderForeground(ColorFocus)
// Diff and list item styles
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
SelectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
// Line numbers
LineNumberStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#707070")). // Solid gray, easy to read
PaddingRight(1).
Width(4)
Foreground(lipgloss.Color(cfg.Colors.LineNumber)).
PaddingRight(1).
Width(4)
StatusBarStyle = lipgloss.NewStyle().Foreground(ColorBarFg).Background(ColorBarBg).Padding(0, 1)
StatusKeyStyle = lipgloss.NewStyle().Foreground(ColorText).Background(ColorBarBg).Bold(true).Padding(0, 1)
StatusDividerStyle = lipgloss.NewStyle().Foreground(ColorSubtle).Background(ColorBarBg).Padding(0, 0)
HelpTextStyle = lipgloss.NewStyle().Foreground(ColorSubtle).Padding(0, 1)
HelpDrawerStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(ColorBorder).Padding(1, 2)
)
// Status bar
StatusBarStyle = lipgloss.NewStyle().
Foreground(ColorBarFg).
Background(ColorBarBg).
Padding(0, 1)
StatusKeyStyle = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorBarBg).
Bold(true).
Padding(0, 1)
StatusDividerStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Background(ColorBarBg)
// Help drawer
HelpTextStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Padding(0, 1)
HelpDrawerStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(ColorBorder).
Padding(1, 2)
}