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

View File

@ -25,10 +25,10 @@ func (i TreeItem) Title() string {
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 {
// FIX 1: Initialize root as a directory so logic works,
// but we won't compact the root itself.
// Initialize root
root := &node{
children: make(map[string]*node),
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).
// Instead, compact each top-level child individually.
for _, child := range root.children {
compact(child)
}
// 3. Flatten to list items
// 2. Flatten to list items (Sorting happens here)
var items []list.Item
flatten(root, 0, &items)
return items
@ -74,41 +68,14 @@ type node struct {
isDir bool
}
// compact recursively merges directories that contain only a single directory child.
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
}
}
}
// flatten recursively converts the tree into a linear list, sorting children by type and name.
func flatten(n *node, depth int, items *[]list.Item) {
keys := make([]string, 0, len(n.children))
for k := range n.children {
keys = append(keys, k)
}
// Sort: Directories first, then alphabetical
sort.Slice(keys, func(i, j int) bool {
a, b := n.children[keys[i]], n.children[keys[j]]
// Folders first
@ -123,6 +90,7 @@ func flatten(n *node, depth int, items *[]list.Item) {
for _, k := range keys {
child := n.children[k]
// Add current node
*items = append(*items, TreeItem{
Path: child.name,
FullPath: child.fullPath,
@ -130,6 +98,7 @@ func flatten(n *node, depth int, items *[]list.Item) {
Depth: depth,
})
// Recurse if directory
if child.isDir {
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 (
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
@ -28,6 +28,7 @@ const (
type Model struct {
fileTree list.Model
treeDelegate TreeDelegate
diffViewport viewport.Model
selectedPath string
@ -47,21 +48,24 @@ type Model struct {
}
func NewModel(cfg config.Config) Model {
// Initialize styles with the loaded config
InitStyles(cfg)
files, _ := git.ListChangedFiles(TargetBranch)
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.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowPagination(false)
l.DisableQuitKeybindings()
m := Model{
fileTree: l,
treeDelegate: delegate,
diffViewport: viewport.New(0, 0),
focus: FocusTree,
currentBranch: git.GetCurrentBranch(),
@ -132,14 +136,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.focus = FocusTree
}
m.updateTreeFocus()
m.inputBuffer = ""
case "l", "]", "ctrl+l", "right":
m.focus = FocusDiff
m.updateTreeFocus()
m.inputBuffer = ""
case "h", "[", "ctrl+h", "left":
m.focus = FocusTree
m.updateTreeFocus()
m.inputBuffer = ""
case "e", "enter":
@ -243,6 +250,11 @@ func (m *Model) updateSizes() {
m.diffViewport.Height = contentHeight
}
func (m *Model) updateTreeFocus() {
m.treeDelegate.Focused = (m.focus == FocusTree)
m.fileTree.SetDelegate(m.treeDelegate)
}
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
@ -272,29 +284,22 @@ func (m Model) View() string {
for i := start; i < end; i++ {
line := m.diffLines[i]
// --- LINE NUMBER LOGIC ---
// --- LINE NUMBERS ---
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)
}
@ -304,10 +309,11 @@ func (m Model) View() string {
if numStr != "" {
lineNumRendered = LineNumberStyle.Render(numStr)
}
// -------------------------
// --- DIFF VIEW HIGHLIGHT ---
if m.focus == FocusDiff && i == m.diffCursor {
line = SelectedItemStyle.Render(line)
cleanLine := stripAnsi(line)
line = DiffSelectionStyle.Render(" " + cleanLine)
} else {
line = " " + line
}
@ -381,21 +387,7 @@ func (m Model) View() string {
return finalView
}
// -- Delegates (unchanged) --
type listDelegate struct{}
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))
}
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

@ -5,24 +5,38 @@ import (
"github.com/oug-t/difi/internal/config"
)
// Global UI colors and styles.
// Initialized once at startup via InitStyles.
var (
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"}
// -- 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"}
PaneStyle lipgloss.Style
FocusedPaneStyle lipgloss.Style
DiffStyle lipgloss.Style
// -- Styles --
PaneStyle lipgloss.Style
FocusedPaneStyle lipgloss.Style
DiffStyle lipgloss.Style
ItemStyle lipgloss.Style
SelectedItemStyle lipgloss.Style
LineNumberStyle 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
@ -32,16 +46,20 @@ var (
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}
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
}
// Pane styles
PaneStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, cfg.UI.ShowGuide, false, false).
BorderForeground(ColorBorder)
@ -49,44 +67,42 @@ func InitStyles(cfg config.Config) {
FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus)
// Diff and list item styles
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
SelectedItemStyle = lipgloss.NewStyle().
// Base Row
ItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
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)
// Line numbers
LineNumberStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(cfg.Colors.LineNumber)).
PaddingRight(1).
Width(4)
// 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)
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).