diff --git a/internal/config/config.go b/internal/config/config.go index 51023db..aa2c54e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/tree/builder.go b/internal/tree/builder.go index 9649b51..2bbd1bd 100644 --- a/internal/tree/builder.go +++ b/internal/tree/builder.go @@ -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) } diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go new file mode 100644 index 0000000..ab0205e --- /dev/null +++ b/internal/ui/delegate.go @@ -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 +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 324d3b7..04414c6 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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, "") } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 8b04fec..43d6378 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -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).