Merge pull request #6 from oug-t/feat/empty-state

feat: add contents for empty-state
This commit is contained in:
Tommy Guo 2026-01-30 15:33:07 -05:00 committed by GitHub
commit 54842591f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 225 additions and 242 deletions

View File

@ -6,7 +6,7 @@
</p>
<p align="center">
<strong>The pixel-perfect terminal diff viewer.</strong><br />
<strong>A calm, focused way to review Git diffs.</strong><br />
Review code with clarity. Polish before you push.
</p>
@ -14,14 +14,13 @@
<img src="https://via.placeholder.com/800x450.png?text=Showcase+Your+UI+Here" alt="difi demo" width="100%" />
</p>
## Why difi?
- ⚡️ **Blazing Fast** — Built in Go. Starts instantly.
- 🎨 **Semantic UI** — Split-pane layout with syntax highlighting and Nerd Font icons.
- 🧠 **Context Aware** — Opens your editor (nvim/vim) at the exact line you are reviewing.
- ⌨️ **Vim Native** — Navigate with `h j k l`. Zero mouse required.
## Installation
### Homebrew (macOS & Linux)
@ -44,8 +43,10 @@ go install github.com/oug-t/difi/cmd/difi@latest
- Download the binary from Releases and add it to your $PATH.
## Workflow
- Run difi in any Git repository.
- By default, it compares your current branch against main.
```bash
cd my-project
difi
@ -54,7 +55,7 @@ difi
## Controls
| Key | Action |
|-----|--------|
| ------------- | -------------------------------------------- |
| `Tab` | Toggle focus between File Tree and Diff View |
| `j / k` | Move cursor down / up |
| `h / l` | Focus Left (Tree) / Focus Right (Diff) |
@ -72,5 +73,7 @@ git clone https://github.com/oug-t/difi
cd difi
go run cmd/difi/main.go
```
---
<p align="center"> Made with ❤️ by <a href="https://github.com/oug-t">oug-t</a> </p>

View File

@ -1,54 +1,20 @@
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"`
DiffSelectionBg string `yaml:"diff_selection_bg"` // New config
} `yaml:"colors"`
UI struct {
LineNumbers string `yaml:"line_numbers"`
ShowGuide bool `yaml:"show_guide"`
} `yaml:"ui"`
UI UIConfig
}
func DefaultConfig() Config {
var c Config
c.Colors.Border = "#D9DCCF"
c.Colors.Focus = "#6e7781"
c.Colors.LineNumber = "#808080"
// Default: "Neutral Light Transparent Blue"
// Dark Mode: Deep subtle blue-grey | Light Mode: Very faint blue
// We only set one default here, but AdaptiveColor handles the split in styles.go
c.Colors.DiffSelectionBg = "" // Empty means use internal defaults
c.UI.LineNumbers = "hybrid"
c.UI.ShowGuide = true
return c
type UIConfig struct {
LineNumbers string // "relative", "absolute", "hybrid", "hidden"
Theme string
}
func Load() Config {
cfg := DefaultConfig()
home, err := os.UserHomeDir()
if err != nil {
return cfg
// Default configuration
return Config{
UI: UIConfig{
LineNumbers: "relative", // Default to relative numbers (vim style)
Theme: "default",
},
}
configPath := filepath.Join(home, ".config", "difi", "config.yml")
data, err := os.ReadFile(configPath)
if err != nil {
return cfg
}
_ = yaml.Unmarshal(data, &cfg)
return cfg
}

View File

@ -3,12 +3,9 @@ package ui
import (
"fmt"
"io"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/tree"
)
@ -19,95 +16,25 @@ type TreeDelegate struct {
func (d TreeDelegate) Height() int { return 1 }
func (d TreeDelegate) Spacing() int { return 0 }
func (d TreeDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
i, ok := item.(tree.TreeItem)
if !ok {
return
}
// 1. Setup Indentation
indentSize := i.Depth * 2
indent := strings.Repeat(" ", indentSize)
// 2. Get Icon and Raw Name
iconStr, iconStyle := getIconInfo(i.Path, i.IsDir)
// 3. Truncation (Safety)
availableWidth := m.Width() - indentSize - 4
displayName := i.Path
if availableWidth > 0 && len(displayName) > availableWidth {
displayName = displayName[:max(0, availableWidth-1)] + "…"
}
// 4. Render Logic ("Oil" Block Cursor)
var row string
isSelected := index == m.Index()
if isSelected && d.Focused {
// -- SELECTED STATE (Oil Style) --
// We do NOT use iconStyle here. We want the icon to inherit the
// selection text color so the background block is unbroken.
// Content: Icon + Space + Name
content := fmt.Sprintf("%s %s", iconStr, displayName)
// Apply the solid block style to the whole content
renderedContent := SelectedBlockStyle.Render(content)
// Combine: Indent (unhighlighted) + Block (highlighted)
row = fmt.Sprintf("%s%s", indent, renderedContent)
title := i.Title()
// If this item is selected
if index == m.Index() {
if d.Focused {
// Render the whole line (including indent) with the selection background
fmt.Fprint(w, SelectedBlockStyle.Render(title))
} else {
// -- NORMAL / INACTIVE STATE --
// Render icon with its specific color
renderedIcon := iconStyle.Render(iconStr)
// Combine
row = fmt.Sprintf("%s%s %s", indent, renderedIcon, displayName)
// Apply generic padding/style
row = ItemStyle.Render(row)
// Dimmed selection if focus is on the other panel
fmt.Fprint(w, SelectedBlockStyle.Copy().Foreground(ColorSubtle).Render(title))
}
} else {
// Normal Item (No icons added, just the text)
fmt.Fprint(w, ItemStyle.Render(title))
}
fmt.Fprint(w, row)
}
// Helper: Returns raw icon string and its preferred style
func getIconInfo(name string, isDir bool) (string, lipgloss.Style) {
if isDir {
return "", FolderIconStyle
}
ext := filepath.Ext(name)
icon := ""
switch strings.ToLower(ext) {
case ".go":
icon = ""
case ".js", ".ts", ".tsx", ".jsx":
icon = ""
case ".md":
icon = ""
case ".json", ".yml", ".yaml", ".toml":
icon = ""
case ".css", ".scss":
icon = ""
case ".html":
icon = ""
case ".git":
icon = ""
case ".dockerfile":
icon = ""
case ".svelte":
icon = ""
}
return icon, FileIconStyle
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@ -17,8 +17,6 @@ import (
"github.com/oug-t/difi/internal/tree"
)
// REMOVED: const TargetBranch = "main" (Now dynamic)
type Focus int
const (
@ -33,7 +31,7 @@ type Model struct {
selectedPath string
currentBranch string
targetBranch string // Added field for dynamic target
targetBranch string
repoName string
diffContent string
@ -48,11 +46,9 @@ type Model struct {
width, height int
}
// Updated Signature: Accepts targetBranch string
func NewModel(cfg config.Config, targetBranch string) Model {
InitStyles(cfg)
// Use the dynamic targetBranch variable
files, _ := git.ListChangedFiles(targetBranch)
items := tree.Build(files)
@ -72,7 +68,7 @@ func NewModel(cfg config.Config, targetBranch string) Model {
diffViewport: viewport.New(0, 0),
focus: FocusTree,
currentBranch: git.GetCurrentBranch(),
targetBranch: targetBranch, // Store it
targetBranch: targetBranch,
repoName: git.GetRepoName(),
showHelp: false,
inputBuffer: "",
@ -88,7 +84,6 @@ func NewModel(cfg config.Config, targetBranch string) Model {
func (m Model) Init() tea.Cmd {
if m.selectedPath != "" {
// Use m.targetBranch instead of constant
return git.DiffCmd(m.targetBranch, m.selectedPath)
}
return nil
@ -119,6 +114,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateSizes()
case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
// If list is empty, ignore other keys
if len(m.fileTree.Items()) == 0 {
return m, nil
}
if len(msg.String()) == 1 && strings.ContainsAny(msg.String(), "0123456789") {
m.inputBuffer += msg.String()
return m, nil
@ -130,10 +134,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit
}
switch msg.String() {
case "tab":
if m.focus == FocusTree {
@ -205,7 +205,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
if m.focus == FocusTree {
if len(m.fileTree.Items()) > 0 && m.focus == FocusTree {
if !keyHandled {
m.fileTree, cmd = m.fileTree.Update(msg)
cmds = append(cmds, cmd)
@ -216,7 +216,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectedPath = item.FullPath
m.diffCursor = 0
m.diffViewport.GotoTop()
// Use m.targetBranch
cmds = append(cmds, git.DiffCmd(m.targetBranch, m.selectedPath))
}
}
@ -229,7 +228,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.diffViewport.SetContent(msg.Content)
case git.EditorFinishedMsg:
// Use m.targetBranch
return m, git.DiffCmd(m.targetBranch, m.selectedPath)
}
@ -267,6 +265,12 @@ func (m Model) View() string {
return "Loading..."
}
// EMPTY STATE CHECK
if len(m.fileTree.Items()) == 0 {
return m.viewEmptyState()
}
// 1. PANES
treeStyle := PaneStyle
if m.focus == FocusTree {
treeStyle = FocusedPaneStyle
@ -331,10 +335,10 @@ func (m Model) View() string {
mainPanes := lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView)
// 2. BOTTOM AREA
repoSection := StatusKeyStyle.Render(" " + m.repoName)
divider := StatusDividerStyle.Render("│")
// Use m.targetBranch in status bar
statusText := fmt.Sprintf(" %s ↔ %s", m.currentBranch, m.targetBranch)
if m.inputBuffer != "" {
statusText += fmt.Sprintf(" [Cmd: %s]", m.inputBuffer)
@ -390,6 +394,82 @@ func (m Model) View() string {
return finalView
}
// viewEmptyState renders a "Landing Page" when there are no changes
func (m Model) viewEmptyState() string {
// 1. Logo & Tagline
logo := EmptyLogoStyle.Render("difi")
desc := EmptyDescStyle.Render("A calm, focused way to review Git diffs.")
// 2. Status Message
statusMsg := fmt.Sprintf("✓ No changes found against '%s'", m.targetBranch)
status := EmptyStatusStyle.Render(statusMsg)
// 3. Usage Guide
usageHeader := EmptyHeaderStyle.Render("Usage Patterns")
cmd1 := lipgloss.NewStyle().Foreground(ColorText).Render("difi")
desc1 := EmptyCodeStyle.Render("Diff against main")
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi develop")
desc2 := EmptyCodeStyle.Render("Diff against target branch")
cmd3 := lipgloss.NewStyle().Foreground(ColorText).Render("difi HEAD~1")
desc3 := EmptyCodeStyle.Render("Diff against previous commit")
usageBlock := lipgloss.JoinVertical(lipgloss.Left,
usageHeader,
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, desc1),
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, desc2),
lipgloss.JoinHorizontal(lipgloss.Left, cmd3, desc3),
)
// 4. Navigation Guide
navHeader := EmptyHeaderStyle.Render("Navigation")
key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab")
keyDesc1 := EmptyCodeStyle.Render("Switch panels")
key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j / k")
keyDesc2 := EmptyCodeStyle.Render("Move cursor")
key3 := lipgloss.NewStyle().Foreground(ColorText).Render("?")
keyDesc3 := EmptyCodeStyle.Render("Toggle help")
navBlock := lipgloss.JoinVertical(lipgloss.Left,
navHeader,
lipgloss.JoinHorizontal(lipgloss.Left, key1, keyDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, key2, keyDesc2),
lipgloss.JoinHorizontal(lipgloss.Left, key3, keyDesc3),
)
// Combine blocks
guides := lipgloss.JoinHorizontal(lipgloss.Top,
usageBlock,
lipgloss.NewStyle().Width(8).Render(""), // Spacer
navBlock,
)
content := lipgloss.JoinVertical(lipgloss.Center,
logo,
desc,
status,
lipgloss.NewStyle().Height(1).Render(""),
guides,
)
// Center vertically
var verticalPad string
if m.height > lipgloss.Height(content) {
lines := (m.height - lipgloss.Height(content)) / 2
verticalPad = strings.Repeat("\n", lines)
}
return lipgloss.JoinVertical(lipgloss.Top,
verticalPad,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, content),
)
}
func stripAnsi(str string) string {
re := regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
return re.ReplaceAllString(str, "")

View File

@ -6,105 +6,112 @@ import (
)
var (
// -- Colors --
ColorText = lipgloss.AdaptiveColor{Light: "#24292f", Dark: "#c9d1d9"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#6e7781", Dark: "#8b949e"}
// UNIFIED SELECTION COLOR (The "Neutral Light Transparent Blue")
// This is used for BOTH the file tree and the diff panel background.
// Dark: Deep subtle slate blue | Light: Pale selection blue
ColorVisualBg = lipgloss.AdaptiveColor{Light: "#daeaff", Dark: "#3a4b5c"}
// Tree Text Color (High Contrast for the block cursor)
ColorVisualFg = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"}
ColorFolder = lipgloss.AdaptiveColor{Light: "#0969da", Dark: "#83a598"}
ColorFile = lipgloss.AdaptiveColor{Light: "#24292f", Dark: "#ebdbb2"}
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
// -- Styles --
PaneStyle lipgloss.Style
FocusedPaneStyle lipgloss.Style
DiffStyle lipgloss.Style
ItemStyle lipgloss.Style
SelectedBlockStyle lipgloss.Style // Tree (Opaque)
DiffSelectionStyle lipgloss.Style // Diff (Transparent/BG only)
FolderIconStyle lipgloss.Style
FileIconStyle lipgloss.Style
LineNumberStyle lipgloss.Style
StatusBarStyle lipgloss.Style
StatusKeyStyle lipgloss.Style
StatusDividerStyle lipgloss.Style
HelpTextStyle lipgloss.Style
HelpDrawerStyle lipgloss.Style
// Global Config
CurrentConfig config.Config
)
func InitStyles(cfg config.Config) {
CurrentConfig = cfg
ColorBorder := lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: cfg.Colors.Border}
ColorFocus := lipgloss.AdaptiveColor{Light: "#6e7781", Dark: cfg.Colors.Focus}
// Allow user override for the selection background
var selectionBg lipgloss.TerminalColor
if cfg.Colors.DiffSelectionBg != "" {
selectionBg = lipgloss.Color(cfg.Colors.DiffSelectionBg)
} else {
selectionBg = ColorVisualBg
}
// -- THEME COLORS --
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#E5E5E5"}
ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"}
ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"}
ColorAccent = lipgloss.AdaptiveColor{Light: "#00ADD8", Dark: "#00ADD8"} // Go Blue
// -- PANE STYLES --
PaneStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, cfg.UI.ShowGuide, false, false).
Border(lipgloss.NormalBorder(), false, true, false, false).
BorderForeground(ColorBorder)
FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus)
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
// Base Row
ItemStyle = lipgloss.NewStyle().
// -- LIST DELEGATE STYLES --
SelectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
PaddingRight(1).
Foreground(ColorText)
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
// 1. LEFT PANE STYLE (Tree)
// Uses the shared background + forces a foreground color for readability
SelectedBlockStyle = lipgloss.NewStyle().
Background(selectionBg).
Foreground(ColorVisualFg).
PaddingLeft(1).
PaddingRight(1).
Bold(true)
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
PaddingLeft(1)
// 2. RIGHT PANE STYLE (Diff)
// Uses the SAME shared background, but NO foreground.
// This makes it "transparent" so Green(+)/Red(-) text colors show through.
DiffSelectionStyle = lipgloss.NewStyle().
Background(selectionBg)
FolderIconStyle = lipgloss.NewStyle().Foreground(ColorFolder)
FileIconStyle = lipgloss.NewStyle().Foreground(ColorFile)
// -- ICON STYLES --
FolderIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F7B96E", Dark: "#E5C07B"})
FileIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#969696", Dark: "#ABB2BF"})
// -- DIFF VIEW STYLES --
LineNumberStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(cfg.Colors.LineNumber)).
Foreground(ColorSubtle).
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)
DiffSelectionStyle = lipgloss.NewStyle().
Background(ColorCursorBg).
Width(1000)
// -- STATUS BAR STYLES --
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
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)
// -- HELP STYLES --
HelpTextStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Padding(0, 1)
HelpTextStyle = lipgloss.NewStyle().Foreground(ColorSubtle).Padding(0, 1)
HelpDrawerStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(ColorBorder).
Padding(1, 2)
// -- EMPTY STATE / LANDING PAGE STYLES --
EmptyLogoStyle = lipgloss.NewStyle().
Foreground(ColorAccent).
Bold(true).
PaddingBottom(1)
EmptyDescStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
PaddingBottom(2)
EmptyStatusStyle = lipgloss.NewStyle().
Foreground(ColorText).
Background(ColorCursorBg).
Padding(0, 2).
MarginBottom(2)
EmptyCodeStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
MarginLeft(2)
EmptyHeaderStyle = lipgloss.NewStyle().
Foreground(ColorText).
Bold(true).
MarginBottom(1)
)
func InitStyles(cfg config.Config) {
CurrentConfig = cfg
}