diff --git a/README.md b/README.md index 56bee0f..c016032 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- The pixel-perfect terminal diff viewer.
+ A calm, focused way to review Git diffs.
Review code with clarity. Polish before you push.

@@ -14,14 +14,13 @@ difi demo

- ## 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 @@ -53,14 +54,14 @@ 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) | -| `e` / `Enter` | Edit file (opens editor at selected line) | -| `?` | Toggle help drawer | -| `q` | Quit | +| 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) | +| `e` / `Enter` | Edit file (opens editor at selected line) | +| `?` | Toggle help drawer | +| `q` | Quit | ## Contributing @@ -72,5 +73,7 @@ git clone https://github.com/oug-t/difi cd difi go run cmd/difi/main.go ``` + --- -

Made with ❤️ by oug-t

+ +

Made with ❤️ by oug-t

diff --git a/internal/config/config.go b/internal/config/config.go index aa2c54e..d087fd5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go index ab0205e..ec52b5f 100644 --- a/internal/ui/delegate.go +++ b/internal/ui/delegate.go @@ -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 { + // Dimmed selection if focus is on the other panel + fmt.Fprint(w, SelectedBlockStyle.Copy().Foreground(ColorSubtle).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) + // 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 } diff --git a/internal/ui/model.go b/internal/ui/model.go index e18b1d2..58e64eb 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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, "") diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 43d6378..9a4e562 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -6,105 +6,112 @@ import ( ) var ( - // -- Colors -- - ColorText = lipgloss.AdaptiveColor{Light: "#24292f", Dark: "#c9d1d9"} - ColorSubtle = lipgloss.AdaptiveColor{Light: "#6e7781", Dark: "#8b949e"} + // Global Config + CurrentConfig config.Config - // 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"} + // -- 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 - // Tree Text Color (High Contrast for the block cursor) - ColorVisualFg = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"} + // -- PANE STYLES -- + PaneStyle = lipgloss.NewStyle(). + Border(lipgloss.NormalBorder(), false, true, false, false). + BorderForeground(ColorBorder) - ColorFolder = lipgloss.AdaptiveColor{Light: "#0969da", Dark: "#83a598"} - ColorFile = lipgloss.AdaptiveColor{Light: "#24292f", Dark: "#ebdbb2"} + FocusedPaneStyle = PaneStyle.Copy(). + BorderForeground(ColorFocus) + DiffStyle = lipgloss.NewStyle().Padding(0, 0) + ItemStyle = lipgloss.NewStyle().PaddingLeft(2) + + // -- LIST DELEGATE STYLES -- + SelectedItemStyle = lipgloss.NewStyle(). + PaddingLeft(1). + Background(ColorCursorBg). + Foreground(ColorText). + Bold(true). + Width(1000) + + SelectedBlockStyle = lipgloss.NewStyle(). + Background(ColorCursorBg). + Foreground(ColorText). + Bold(true). + PaddingLeft(1) + + // -- 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(ColorSubtle). + PaddingRight(1). + Width(4) + + DiffSelectionStyle = lipgloss.NewStyle(). + Background(ColorCursorBg). + Width(1000) + + // -- STATUS BAR STYLES -- ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"} ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"} - // -- Styles -- - PaneStyle lipgloss.Style - FocusedPaneStyle lipgloss.Style - DiffStyle lipgloss.Style + StatusBarStyle = lipgloss.NewStyle(). + Foreground(ColorBarFg). + Background(ColorBarBg). + Padding(0, 1) - ItemStyle lipgloss.Style - SelectedBlockStyle lipgloss.Style // Tree (Opaque) - DiffSelectionStyle lipgloss.Style // Diff (Transparent/BG only) + StatusKeyStyle = lipgloss.NewStyle(). + Foreground(ColorText). + Background(ColorBarBg). + Bold(true). + Padding(0, 1) - FolderIconStyle lipgloss.Style - FileIconStyle lipgloss.Style - LineNumberStyle lipgloss.Style + StatusDividerStyle = lipgloss.NewStyle(). + Foreground(ColorSubtle). + Background(ColorBarBg). + Padding(0, 0) - StatusBarStyle lipgloss.Style - StatusKeyStyle lipgloss.Style - StatusDividerStyle lipgloss.Style - HelpTextStyle lipgloss.Style - HelpDrawerStyle lipgloss.Style + // -- HELP STYLES -- + HelpTextStyle = lipgloss.NewStyle(). + Foreground(ColorSubtle). + Padding(0, 1) - CurrentConfig config.Config + 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 - - 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 - } - - PaneStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, cfg.UI.ShowGuide, false, false). - BorderForeground(ColorBorder) - - FocusedPaneStyle = PaneStyle.Copy(). - BorderForeground(ColorFocus) - - DiffStyle = lipgloss.NewStyle().Padding(0, 0) - - // Base Row - ItemStyle = lipgloss.NewStyle(). - PaddingLeft(1). - PaddingRight(1). - Foreground(ColorText) - - // 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) - - // 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) - - LineNumberStyle = lipgloss.NewStyle(). - 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) }