Merge pull request #4 from oug-t/fix/filetree

fix/filetree
This commit is contained in:
Tommy Guo 2026-01-30 15:02:51 -05:00 committed by GitHub
commit dde4074b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 218 additions and 123 deletions

View File

@ -9,21 +9,28 @@ import (
type Config struct { type Config struct {
Colors struct { Colors struct {
Border string `yaml:"border"` Border string `yaml:"border"`
Focus string `yaml:"focus"` Focus string `yaml:"focus"`
LineNumber string `yaml:"line_number"` LineNumber string `yaml:"line_number"`
DiffSelectionBg string `yaml:"diff_selection_bg"` // New config
} `yaml:"colors"` } `yaml:"colors"`
UI struct { UI struct {
LineNumbers string `yaml:"line_numbers"` // "absolute", "relative", "hybrid", "hidden" LineNumbers string `yaml:"line_numbers"`
ShowGuide bool `yaml:"show_guide"` // The vertical separation line ShowGuide bool `yaml:"show_guide"`
} `yaml:"ui"` } `yaml:"ui"`
} }
func DefaultConfig() Config { func DefaultConfig() Config {
var c Config var c Config
c.Colors.Border = "#D9DCCF" c.Colors.Border = "#D9DCCF"
c.Colors.Focus = "#000000" // Default neutral focus c.Colors.Focus = "#6e7781"
c.Colors.LineNumber = "#808080" 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.LineNumbers = "hybrid"
c.UI.ShowGuide = true c.UI.ShowGuide = true
return c return c
@ -31,7 +38,6 @@ func DefaultConfig() Config {
func Load() Config { func Load() Config {
cfg := DefaultConfig() cfg := DefaultConfig()
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return cfg return cfg
@ -43,7 +49,6 @@ func Load() Config {
return cfg return cfg
} }
// Parse YAML
_ = yaml.Unmarshal(data, &cfg) _ = yaml.Unmarshal(data, &cfg)
return cfg return cfg
} }

View File

@ -25,10 +25,10 @@ func (i TreeItem) Title() string {
return fmt.Sprintf("%s%s %s", indent, icon, i.Path) return fmt.Sprintf("%s%s %s", indent, icon, i.Path)
} }
// Build converts a list of file paths into a compacted, sorted tree list. // Build converts a list of file paths into a sorted tree list.
// Compaction is disabled to ensure tree stability.
func Build(paths []string) []list.Item { func Build(paths []string) []list.Item {
// FIX 1: Initialize root as a directory so logic works, // Initialize root
// but we won't compact the root itself.
root := &node{ root := &node{
children: make(map[string]*node), children: make(map[string]*node),
isDir: true, isDir: true,
@ -53,13 +53,7 @@ func Build(paths []string) []list.Item {
} }
} }
// FIX 2: Do NOT compact the root node itself (which would hide top-level folders). // 2. Flatten to list items (Sorting happens here)
// Instead, compact each top-level child individually.
for _, child := range root.children {
compact(child)
}
// 3. Flatten to list items
var items []list.Item var items []list.Item
flatten(root, 0, &items) flatten(root, 0, &items)
return items return items
@ -74,41 +68,14 @@ type node struct {
isDir bool isDir bool
} }
// compact recursively merges directories that contain only a single directory child. // flatten recursively converts the tree into a linear list, sorting children by type and name.
func compact(n *node) {
if !n.isDir {
return
}
// Compact children first (bottom-up traversal)
for _, child := range n.children {
compact(child)
}
// Logic: If I am a directory, and I have exactly 1 child, and that child is also a directory...
if len(n.children) == 1 {
var child *node
for _, c := range n.children {
child = c
break
}
if child.isDir {
// Merge child into parent
// e.g. "internal" + "ui" becomes "internal/ui"
n.name = filepath.Join(n.name, child.name)
n.fullPath = child.fullPath
n.children = child.children // Inherit grandchildren
}
}
}
func flatten(n *node, depth int, items *[]list.Item) { func flatten(n *node, depth int, items *[]list.Item) {
keys := make([]string, 0, len(n.children)) keys := make([]string, 0, len(n.children))
for k := range n.children { for k := range n.children {
keys = append(keys, k) keys = append(keys, k)
} }
// Sort: Directories first, then alphabetical
sort.Slice(keys, func(i, j int) bool { sort.Slice(keys, func(i, j int) bool {
a, b := n.children[keys[i]], n.children[keys[j]] a, b := n.children[keys[i]], n.children[keys[j]]
// Folders first // Folders first
@ -123,6 +90,7 @@ func flatten(n *node, depth int, items *[]list.Item) {
for _, k := range keys { for _, k := range keys {
child := n.children[k] child := n.children[k]
// Add current node
*items = append(*items, TreeItem{ *items = append(*items, TreeItem{
Path: child.name, Path: child.name,
FullPath: child.fullPath, FullPath: child.fullPath,
@ -130,6 +98,7 @@ func flatten(n *node, depth int, items *[]list.Item) {
Depth: depth, Depth: depth,
}) })
// Recurse if directory
if child.isDir { if child.isDir {
flatten(child, depth+1, items) flatten(child, depth+1, items)
} }

113
internal/ui/delegate.go Normal file
View File

@ -0,0 +1,113 @@
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"
)
type TreeDelegate struct {
Focused bool
}
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)
} 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)
}
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

@ -2,8 +2,8 @@ package ui
import ( import (
"fmt" "fmt"
"io"
"math" "math"
"regexp"
"strconv" "strconv"
"strings" "strings"
@ -28,6 +28,7 @@ const (
type Model struct { type Model struct {
fileTree list.Model fileTree list.Model
treeDelegate TreeDelegate
diffViewport viewport.Model diffViewport viewport.Model
selectedPath string selectedPath string
@ -47,21 +48,24 @@ type Model struct {
} }
func NewModel(cfg config.Config) Model { func NewModel(cfg config.Config) Model {
// Initialize styles with the loaded config
InitStyles(cfg) InitStyles(cfg)
files, _ := git.ListChangedFiles(TargetBranch) files, _ := git.ListChangedFiles(TargetBranch)
items := tree.Build(files) items := tree.Build(files)
l := list.New(items, listDelegate{}, 0, 0) delegate := TreeDelegate{Focused: true}
l := list.New(items, delegate, 0, 0)
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings() l.DisableQuitKeybindings()
m := Model{ m := Model{
fileTree: l, fileTree: l,
treeDelegate: delegate,
diffViewport: viewport.New(0, 0), diffViewport: viewport.New(0, 0),
focus: FocusTree, focus: FocusTree,
currentBranch: git.GetCurrentBranch(), currentBranch: git.GetCurrentBranch(),
@ -132,14 +136,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.focus = FocusTree m.focus = FocusTree
} }
m.updateTreeFocus()
m.inputBuffer = "" m.inputBuffer = ""
case "l", "]", "ctrl+l", "right": case "l", "]", "ctrl+l", "right":
m.focus = FocusDiff m.focus = FocusDiff
m.updateTreeFocus()
m.inputBuffer = "" m.inputBuffer = ""
case "h", "[", "ctrl+h", "left": case "h", "[", "ctrl+h", "left":
m.focus = FocusTree m.focus = FocusTree
m.updateTreeFocus()
m.inputBuffer = "" m.inputBuffer = ""
case "e", "enter": case "e", "enter":
@ -243,6 +250,11 @@ func (m *Model) updateSizes() {
m.diffViewport.Height = contentHeight m.diffViewport.Height = contentHeight
} }
func (m *Model) updateTreeFocus() {
m.treeDelegate.Focused = (m.focus == FocusTree)
m.fileTree.SetDelegate(m.treeDelegate)
}
func (m Model) View() string { func (m Model) View() string {
if m.width == 0 { if m.width == 0 {
return "Loading..." return "Loading..."
@ -272,29 +284,22 @@ func (m Model) View() string {
for i := start; i < end; i++ { for i := start; i < end; i++ {
line := m.diffLines[i] line := m.diffLines[i]
// --- LINE NUMBER LOGIC --- // --- LINE NUMBERS ---
var numStr string var numStr string
mode := CurrentConfig.UI.LineNumbers mode := CurrentConfig.UI.LineNumbers
if mode == "hidden" { if mode == "hidden" {
numStr = "" numStr = ""
} else { } else {
// Is this the cursor line?
isCursor := (i == m.diffCursor) isCursor := (i == m.diffCursor)
if isCursor && mode == "hybrid" { if isCursor && mode == "hybrid" {
// HYBRID: Show Real File Line Number
realLine := git.CalculateFileLine(m.diffContent, m.diffCursor) realLine := git.CalculateFileLine(m.diffContent, m.diffCursor)
numStr = fmt.Sprintf("%d", realLine) numStr = fmt.Sprintf("%d", realLine)
} else if isCursor && mode == "relative" { } else if isCursor && mode == "relative" {
numStr = "0" numStr = "0"
} else if mode == "absolute" { } 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) numStr = fmt.Sprintf("%d", i+1)
} else { } else {
// Default / Hybrid-non-cursor: Show Relative Distance
dist := int(math.Abs(float64(i - m.diffCursor))) dist := int(math.Abs(float64(i - m.diffCursor)))
numStr = fmt.Sprintf("%d", dist) numStr = fmt.Sprintf("%d", dist)
} }
@ -304,10 +309,11 @@ func (m Model) View() string {
if numStr != "" { if numStr != "" {
lineNumRendered = LineNumberStyle.Render(numStr) lineNumRendered = LineNumberStyle.Render(numStr)
} }
// -------------------------
// --- DIFF VIEW HIGHLIGHT ---
if m.focus == FocusDiff && i == m.diffCursor { if m.focus == FocusDiff && i == m.diffCursor {
line = SelectedItemStyle.Render(line) cleanLine := stripAnsi(line)
line = DiffSelectionStyle.Render(" " + cleanLine)
} else { } else {
line = " " + line line = " " + line
} }
@ -381,21 +387,7 @@ func (m Model) View() string {
return finalView return finalView
} }
// -- Delegates (unchanged) -- func stripAnsi(str string) string {
type listDelegate struct{} 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, "")
func (d listDelegate) Height() int { return 1 }
func (d listDelegate) Spacing() int { return 0 }
func (d listDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
func (d listDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
i, ok := item.(tree.TreeItem)
if !ok {
return
}
str := i.Title()
if index == m.Index() {
fmt.Fprint(w, SelectedItemStyle.Render(str))
} else {
fmt.Fprint(w, ItemStyle.Render(str))
}
} }

View File

@ -5,24 +5,38 @@ import (
"github.com/oug-t/difi/internal/config" "github.com/oug-t/difi/internal/config"
) )
// Global UI colors and styles.
// Initialized once at startup via InitStyles.
var ( var (
ColorBorder lipgloss.AdaptiveColor // -- Colors --
ColorFocus lipgloss.AdaptiveColor ColorText = lipgloss.AdaptiveColor{Light: "#24292f", Dark: "#c9d1d9"}
ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"} ColorSubtle = lipgloss.AdaptiveColor{Light: "#6e7781", Dark: "#8b949e"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"}
ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"} // 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"} ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"}
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"} ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"}
PaneStyle lipgloss.Style // -- Styles --
FocusedPaneStyle lipgloss.Style PaneStyle lipgloss.Style
DiffStyle lipgloss.Style FocusedPaneStyle lipgloss.Style
DiffStyle lipgloss.Style
ItemStyle lipgloss.Style ItemStyle lipgloss.Style
SelectedItemStyle lipgloss.Style SelectedBlockStyle lipgloss.Style // Tree (Opaque)
LineNumberStyle lipgloss.Style DiffSelectionStyle lipgloss.Style // Diff (Transparent/BG only)
FolderIconStyle lipgloss.Style
FileIconStyle lipgloss.Style
LineNumberStyle lipgloss.Style
StatusBarStyle lipgloss.Style StatusBarStyle lipgloss.Style
StatusKeyStyle lipgloss.Style StatusKeyStyle lipgloss.Style
StatusDividerStyle lipgloss.Style StatusDividerStyle lipgloss.Style
@ -32,16 +46,20 @@ var (
CurrentConfig config.Config 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) { func InitStyles(cfg config.Config) {
CurrentConfig = cfg CurrentConfig = cfg
// Colors derived from user config ColorBorder := lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: cfg.Colors.Border}
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: cfg.Colors.Border} ColorFocus := lipgloss.AdaptiveColor{Light: "#6e7781", Dark: cfg.Colors.Focus}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", 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
}
// Pane styles
PaneStyle = lipgloss.NewStyle(). PaneStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, cfg.UI.ShowGuide, false, false). Border(lipgloss.NormalBorder(), false, cfg.UI.ShowGuide, false, false).
BorderForeground(ColorBorder) BorderForeground(ColorBorder)
@ -49,44 +67,42 @@ func InitStyles(cfg config.Config) {
FocusedPaneStyle = PaneStyle.Copy(). FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus) BorderForeground(ColorFocus)
// Diff and list item styles
DiffStyle = lipgloss.NewStyle().Padding(0, 0) DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
SelectedItemStyle = lipgloss.NewStyle(). // Base Row
ItemStyle = lipgloss.NewStyle().
PaddingLeft(1). PaddingLeft(1).
Background(ColorCursorBg). PaddingRight(1).
Foreground(ColorText). 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)
// 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)
// Line numbers
LineNumberStyle = lipgloss.NewStyle(). LineNumberStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(cfg.Colors.LineNumber)). Foreground(lipgloss.Color(cfg.Colors.LineNumber)).
PaddingRight(1). PaddingRight(1).
Width(4) Width(4)
// Status bar StatusBarStyle = lipgloss.NewStyle().Foreground(ColorBarFg).Background(ColorBarBg).Padding(0, 1)
StatusBarStyle = lipgloss.NewStyle(). StatusKeyStyle = lipgloss.NewStyle().Foreground(ColorText).Background(ColorBarBg).Bold(true).Padding(0, 1)
Foreground(ColorBarFg). StatusDividerStyle = lipgloss.NewStyle().Foreground(ColorSubtle).Background(ColorBarBg).Padding(0, 0)
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)
HelpTextStyle = lipgloss.NewStyle().Foreground(ColorSubtle).Padding(0, 1)
HelpDrawerStyle = lipgloss.NewStyle(). HelpDrawerStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false). Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(ColorBorder). BorderForeground(ColorBorder).