Merge pull request #12 from oug-t/chore/file-tree

chore: refine file-tree
This commit is contained in:
Tommy Guo 2026-02-01 00:27:00 -05:00 committed by GitHub
commit 8cad917626
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 587 additions and 410 deletions

View File

@ -74,8 +74,6 @@ func OpenEditorCmd(path string, lineNumber int, targetBranch string) tea.Cmd {
c := exec.Command(editor, args...) c := exec.Command(editor, args...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
// Pass the diff target branch to the editor via environment variable
// This enables plugins like difi.nvim to auto-configure the view
c.Env = append(os.Environ(), fmt.Sprintf("DIFI_TARGET=%s", targetBranch)) c.Env = append(os.Environ(), fmt.Sprintf("DIFI_TARGET=%s", targetBranch))
return tea.ExecProcess(c, func(err error) tea.Msg { return tea.ExecProcess(c, func(err error) tea.Msg {
@ -83,6 +81,38 @@ func OpenEditorCmd(path string, lineNumber int, targetBranch string) tea.Cmd {
}) })
} }
func DiffStats(targetBranch string) (added int, deleted int, err error) {
cmd := exec.Command("git", "diff", "--numstat", targetBranch)
out, err := cmd.Output()
if err != nil {
return 0, 0, fmt.Errorf("git diff stats error: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
for _, line := range lines {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
if parts[0] != "-" {
if n, err := strconv.Atoi(parts[0]); err == nil {
added += n
}
}
if parts[1] != "-" {
if n, err := strconv.Atoi(parts[1]); err == nil {
deleted += n
}
}
}
return added, deleted, nil
}
func CalculateFileLine(diffContent string, visualLineIndex int) int { func CalculateFileLine(diffContent string, visualLineIndex int) int {
lines := strings.Split(diffContent, "\n") lines := strings.Split(diffContent, "\n")
if visualLineIndex >= len(lines) { if visualLineIndex >= len(lines) {

View File

@ -1,126 +0,0 @@
package tree
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/bubbles/list"
)
// TreeItem represents a file or folder
type TreeItem struct {
Path string
FullPath string
IsDir bool
Depth int
}
func (i TreeItem) FilterValue() string { return i.FullPath }
func (i TreeItem) Description() string { return "" }
func (i TreeItem) Title() string {
indent := strings.Repeat(" ", i.Depth)
icon := getIcon(i.Path, i.IsDir)
return fmt.Sprintf("%s%s %s", indent, icon, i.Path)
}
func Build(paths []string) []list.Item {
root := &node{
children: make(map[string]*node),
isDir: true,
}
for _, path := range paths {
parts := strings.Split(path, "/")
current := root
for i, part := range parts {
if _, exists := current.children[part]; !exists {
isDir := i < len(parts)-1
fullPath := strings.Join(parts[:i+1], "/")
current.children[part] = &node{
name: part,
fullPath: fullPath,
children: make(map[string]*node),
isDir: isDir,
}
}
current = current.children[part]
}
}
var items []list.Item
flatten(root, 0, &items)
return items
}
type node struct {
name string
fullPath string
children map[string]*node
isDir bool
}
// 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.Slice(keys, func(i, j int) bool {
a, b := n.children[keys[i]], n.children[keys[j]]
if a.isDir && !b.isDir {
return true
}
if !a.isDir && b.isDir {
return false
}
return a.name < b.name
})
for _, k := range keys {
child := n.children[k]
*items = append(*items, TreeItem{
Path: child.name,
FullPath: child.fullPath,
IsDir: child.isDir,
Depth: depth,
})
if child.isDir {
flatten(child, depth+1, items)
}
}
}
func getIcon(name string, isDir bool) string {
if isDir {
return " "
}
ext := filepath.Ext(name)
switch strings.ToLower(ext) {
case ".go":
return " "
case ".js", ".ts", ".tsx":
return " "
case ".svelte":
return " "
case ".md":
return " "
case ".json":
return " "
case ".yml", ".yaml":
return " "
case ".html":
return " "
case ".css":
return " "
case ".git":
return " "
case ".dockerfile":
return " "
default:
return " "
}
}

191
internal/tree/tree.go Normal file
View File

@ -0,0 +1,191 @@
package tree
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/bubbles/list"
)
// FileTree holds the state of the entire file graph.
type FileTree struct {
Root *Node
}
// Node represents a file or directory in the tree.
type Node struct {
Name string
FullPath string
IsDir bool
Children map[string]*Node
Expanded bool
Depth int
}
// TreeItem represents a file or folder for the Bubble Tea list.
type TreeItem struct {
Name string
FullPath string
IsDir bool
Depth int
Expanded bool
Icon string
}
// Implement list.Item interface
func (i TreeItem) FilterValue() string { return i.Name }
func (i TreeItem) Description() string { return "" }
func (i TreeItem) Title() string {
indent := strings.Repeat(" ", i.Depth)
disclosure := " "
if i.IsDir {
if i.Expanded {
disclosure = "▾"
} else {
disclosure = "▸"
}
}
// Icon spacing handled in formatting
return fmt.Sprintf("%s%s %s %s", indent, disclosure, i.Icon, i.Name)
}
// New creates a new FileTree from a list of changed file paths.
func New(paths []string) *FileTree {
root := &Node{
Name: "root",
IsDir: true,
Children: make(map[string]*Node),
Expanded: true, // Root always expanded
Depth: -1, // Root is hidden
}
for _, path := range paths {
addPath(root, path)
}
return &FileTree{Root: root}
}
// addPath inserts a path into the tree, creating directory nodes as needed.
func addPath(root *Node, path string) {
cleanPath := filepath.ToSlash(filepath.Clean(path))
parts := strings.Split(cleanPath, "/")
current := root
for i, name := range parts {
if _, exists := current.Children[name]; !exists {
isFile := i == len(parts)-1
nodePath := name
if current.FullPath != "" {
nodePath = current.FullPath + "/" + name
}
// Directories default to expanded for visibility, or collapsed if preferred
// GitHub usually auto-expands to show changed files. Here we auto-expand.
current.Children[name] = &Node{
Name: name,
FullPath: nodePath,
IsDir: !isFile,
Children: make(map[string]*Node),
Expanded: true,
Depth: current.Depth + 1,
}
}
current = current.Children[name]
}
}
// Items returns the flattened, visible list items based on expansion state.
func (t *FileTree) Items() []list.Item {
var items []list.Item
flatten(t.Root, &items)
return items
}
// flatten recursively builds the list, respecting expansion state.
func flatten(node *Node, items *[]list.Item) {
// Collect children to sort
children := make([]*Node, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, child)
}
// Sort: Directories first, then alphabetical
sort.Slice(children, func(i, j int) bool {
if children[i].IsDir != children[j].IsDir {
return children[i].IsDir
}
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
})
for _, child := range children {
*items = append(*items, TreeItem{
Name: child.Name,
FullPath: child.FullPath,
IsDir: child.IsDir,
Depth: child.Depth,
Expanded: child.Expanded,
Icon: getIcon(child.Name, child.IsDir),
})
// Only traverse children if expanded
if child.IsDir && child.Expanded {
flatten(child, items)
}
}
}
// ToggleExpand toggles the expansion state of a specific node.
func (t *FileTree) ToggleExpand(fullPath string) {
node := findNode(t.Root, fullPath)
if node != nil && node.IsDir {
node.Expanded = !node.Expanded
}
}
func findNode(node *Node, fullPath string) *Node {
if node.FullPath == fullPath {
return node
}
// Simple traversal. For very large trees, a map cache in FileTree might be faster.
for _, child := range node.Children {
if strings.HasPrefix(fullPath, child.FullPath) {
if child.FullPath == fullPath {
return child
}
if found := findNode(child, fullPath); found != nil {
return found
}
}
}
return nil
}
func getIcon(name string, isDir bool) string {
if isDir {
return ""
}
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".go":
return ""
case ".js", ".ts", ".tsx":
return ""
case ".css", ".scss":
return ""
case ".html":
return ""
case ".json", ".yaml", ".yml", ".toml":
return ""
case ".md":
return ""
case ".png", ".jpg", ".jpeg", ".svg":
return ""
case ".gitignore", ".gitmodules":
return ""
default:
return ""
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/tree" "github.com/oug-t/difi/internal/tree"
) )
@ -16,6 +17,7 @@ type TreeDelegate struct {
func (d TreeDelegate) Height() int { return 1 } func (d TreeDelegate) Height() int { return 1 }
func (d TreeDelegate) Spacing() int { return 0 } func (d TreeDelegate) Spacing() int { return 0 }
func (d TreeDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } 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) { func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) {
i, ok := item.(tree.TreeItem) i, ok := item.(tree.TreeItem)
if !ok { if !ok {
@ -24,17 +26,19 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite
title := i.Title() title := i.Title()
// If this item is selected
if index == m.Index() { if index == m.Index() {
if d.Focused { style := lipgloss.NewStyle().
// Render the whole line (including indent) with the selection background Background(lipgloss.Color("237")). // Dark gray background
fmt.Fprint(w, SelectedBlockStyle.Render(title)) Foreground(lipgloss.Color("255")). // White text
Bold(true)
if !d.Focused {
style = style.Foreground(lipgloss.Color("245"))
}
fmt.Fprint(w, style.Render(title))
} else { } else {
// Dimmed selection if focus is on the other panel style := lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
fmt.Fprint(w, SelectedBlockStyle.Copy().Foreground(ColorSubtle).Render(title)) fmt.Fprint(w, style.Render(title))
}
} else {
// Normal Item (No icons added, just the text)
fmt.Fprint(w, ItemStyle.Render(title))
} }
} }

View File

@ -24,8 +24,14 @@ const (
FocusDiff FocusDiff
) )
type StatsMsg struct {
Added int
Deleted int
}
type Model struct { type Model struct {
fileTree list.Model fileList list.Model
treeState *tree.FileTree
treeDelegate TreeDelegate treeDelegate TreeDelegate
diffViewport viewport.Model diffViewport viewport.Model
@ -34,6 +40,9 @@ type Model struct {
targetBranch string targetBranch string
repoName string repoName string
statsAdded int
statsDeleted int
diffContent string diffContent string
diffLines []string diffLines []string
diffCursor int diffCursor int
@ -51,7 +60,9 @@ func NewModel(cfg config.Config, targetBranch string) Model {
InitStyles(cfg) InitStyles(cfg)
files, _ := git.ListChangedFiles(targetBranch) files, _ := git.ListChangedFiles(targetBranch)
items := tree.Build(files)
t := tree.New(files)
items := t.Items()
delegate := TreeDelegate{Focused: true} delegate := TreeDelegate{Focused: true}
l := list.New(items, delegate, 0, 0) l := list.New(items, delegate, 0, 0)
@ -64,7 +75,8 @@ func NewModel(cfg config.Config, targetBranch string) Model {
l.DisableQuitKeybindings() l.DisableQuitKeybindings()
m := Model{ m := Model{
fileTree: l, fileList: l,
treeState: t,
treeDelegate: delegate, treeDelegate: delegate,
diffViewport: viewport.New(0, 0), diffViewport: viewport.New(0, 0),
focus: FocusTree, focus: FocusTree,
@ -78,18 +90,35 @@ func NewModel(cfg config.Config, targetBranch string) Model {
if len(items) > 0 { if len(items) > 0 {
if first, ok := items[0].(tree.TreeItem); ok { if first, ok := items[0].(tree.TreeItem); ok {
if !first.IsDir {
m.selectedPath = first.FullPath m.selectedPath = first.FullPath
} }
} }
}
return m return m
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
if m.selectedPath != "" { if m.selectedPath != "" {
return git.DiffCmd(m.targetBranch, m.selectedPath) cmds = append(cmds, git.DiffCmd(m.targetBranch, m.selectedPath))
} }
cmds = append(cmds, fetchStatsCmd(m.targetBranch))
return tea.Batch(cmds...)
}
func fetchStatsCmd(target string) tea.Cmd {
return func() tea.Msg {
added, deleted, err := git.DiffStats(target)
if err != nil {
return nil return nil
} }
return StatsMsg{Added: added, Deleted: deleted}
}
}
func (m *Model) getRepeatCount() int { func (m *Model) getRepeatCount() int {
if m.inputBuffer == "" { if m.inputBuffer == "" {
@ -115,16 +144,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height m.height = msg.Height
m.updateSizes() m.updateSizes()
case StatsMsg:
m.statsAdded = msg.Added
m.statsDeleted = msg.Deleted
case tea.KeyMsg: case tea.KeyMsg:
if msg.String() == "q" || msg.String() == "ctrl+c" { if msg.String() == "q" || msg.String() == "ctrl+c" {
return m, tea.Quit return m, tea.Quit
} }
if len(m.fileTree.Items()) == 0 { if len(m.fileList.Items()) == 0 {
return m, nil return m, nil
} }
// Handle z-prefix commands (zz, zt, zb)
if m.pendingZ { if m.pendingZ {
m.pendingZ = false m.pendingZ = false
if m.focus == FocusDiff { if m.focus == FocusDiff {
@ -158,6 +190,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.String() { switch msg.String() {
case "tab": case "tab":
if m.focus == FocusTree { if m.focus == FocusTree {
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir {
return m, nil
}
m.focus = FocusDiff m.focus = FocusDiff
} else { } else {
m.focus = FocusTree m.focus = FocusTree
@ -166,6 +201,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inputBuffer = "" m.inputBuffer = ""
case "l", "]", "ctrl+l", "right": case "l", "]", "ctrl+l", "right":
if m.focus == FocusTree {
if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir {
return m, nil
}
}
m.focus = FocusDiff m.focus = FocusDiff
m.updateTreeFocus() m.updateTreeFocus()
m.inputBuffer = "" m.inputBuffer = ""
@ -175,8 +215,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateTreeFocus() m.updateTreeFocus()
m.inputBuffer = "" m.inputBuffer = ""
case "e", "enter": case "enter":
if m.focus == FocusTree {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir {
m.treeState.ToggleExpand(i.FullPath)
m.fileList.SetItems(m.treeState.Items())
return m, nil
}
}
if m.selectedPath != "" { if m.selectedPath != "" {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && !i.IsDir {
// proceed
} else {
return m, nil
}
}
fallthrough
case "e":
if m.selectedPath != "" {
if i, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && i.IsDir {
return m, nil
}
line := 0 line := 0
if m.focus == FocusDiff { if m.focus == FocusDiff {
line = git.CalculateFileLine(m.diffContent, m.diffCursor) line = git.CalculateFileLine(m.diffContent, m.diffCursor)
@ -184,18 +245,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
line = git.CalculateFileLine(m.diffContent, 0) line = git.CalculateFileLine(m.diffContent, 0)
} }
m.inputBuffer = "" m.inputBuffer = ""
// Integration Point: Pass targetBranch to the editor command
return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch) return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch)
} }
// Viewport trigger
case "z": case "z":
if m.focus == FocusDiff { if m.focus == FocusDiff {
m.pendingZ = true m.pendingZ = true
return m, nil return m, nil
} }
// Cursor screen positioning (H, M, L)
case "H": case "H":
if m.focus == FocusDiff { if m.focus == FocusDiff {
m.diffCursor = m.diffViewport.YOffset m.diffCursor = m.diffViewport.YOffset
@ -221,7 +279,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
// Page navigation
case "ctrl+d": case "ctrl+d":
if m.focus == FocusDiff { if m.focus == FocusDiff {
halfScreen := m.diffViewport.Height / 2 halfScreen := m.diffViewport.Height / 2
@ -251,13 +308,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.focus == FocusDiff { if m.focus == FocusDiff {
if m.diffCursor < len(m.diffLines)-1 { if m.diffCursor < len(m.diffLines)-1 {
m.diffCursor++ m.diffCursor++
// Scroll if hitting bottom edge
if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height { if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height {
m.diffViewport.LineDown(1) m.diffViewport.LineDown(1)
} }
} }
} else { } else {
m.fileTree.CursorDown() m.fileList.CursorDown()
} }
} }
m.inputBuffer = "" m.inputBuffer = ""
@ -269,13 +325,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.focus == FocusDiff { if m.focus == FocusDiff {
if m.diffCursor > 0 { if m.diffCursor > 0 {
m.diffCursor-- m.diffCursor--
// Scroll if hitting top edge
if m.diffCursor < m.diffViewport.YOffset { if m.diffCursor < m.diffViewport.YOffset {
m.diffViewport.LineUp(1) m.diffViewport.LineUp(1)
} }
} }
} else { } else {
m.fileTree.CursorUp() m.fileList.CursorUp()
} }
} }
m.inputBuffer = "" m.inputBuffer = ""
@ -285,14 +340,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
} }
if len(m.fileTree.Items()) > 0 && m.focus == FocusTree { if len(m.fileList.Items()) > 0 && m.focus == FocusTree {
if !keyHandled { if !keyHandled {
m.fileTree, cmd = m.fileTree.Update(msg) m.fileList, cmd = m.fileList.Update(msg)
cmds = append(cmds, cmd) cmds = append(cmds, cmd)
} }
if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir { if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok {
if item.FullPath != m.selectedPath { if !item.IsDir && item.FullPath != m.selectedPath {
m.selectedPath = item.FullPath m.selectedPath = item.FullPath
m.diffCursor = 0 m.diffCursor = 0
m.diffViewport.GotoTop() m.diffViewport.GotoTop()
@ -324,29 +379,41 @@ func (m *Model) centerDiffCursor() {
} }
func (m *Model) updateSizes() { func (m *Model) updateSizes() {
reservedHeight := 1 // 1 line Top Bar + 1 line Bottom Bar = 2 reserved
reservedHeight := 2
if m.showHelp { if m.showHelp {
reservedHeight += 6 reservedHeight += 6
} }
// Calculate main area height
contentHeight := m.height - reservedHeight contentHeight := m.height - reservedHeight
if contentHeight < 1 { if contentHeight < 1 {
contentHeight = 1 contentHeight = 1
} }
// Calculate widths
treeWidth := int(float64(m.width) * 0.20) treeWidth := int(float64(m.width) * 0.20)
if treeWidth < 20 { if treeWidth < 20 {
treeWidth = 20 treeWidth = 20
} }
m.fileTree.SetSize(treeWidth, contentHeight) // The Tree PaneStyle has a border (1 top, 1 bottom = 2 lines).
// We must subtract this from the content height for the inner list.
listHeight := contentHeight - 2
if listHeight < 1 {
listHeight = 1
}
m.fileList.SetSize(treeWidth, listHeight)
// We align the Diff Viewport height with the List height to ensure
// the bottom edges match visually and to prevent overflow.
m.diffViewport.Width = m.width - treeWidth - 2 m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = contentHeight m.diffViewport.Height = listHeight
} }
func (m *Model) updateTreeFocus() { func (m *Model) updateTreeFocus() {
m.treeDelegate.Focused = (m.focus == FocusTree) m.treeDelegate.Focused = (m.focus == FocusTree)
m.fileTree.SetDelegate(m.treeDelegate) m.fileList.SetDelegate(m.treeDelegate)
} }
func (m Model) View() string { func (m Model) View() string {
@ -354,11 +421,20 @@ func (m Model) View() string {
return "Loading..." return "Loading..."
} }
if len(m.fileTree.Items()) == 0 { topBar := m.renderTopBar()
return m.viewEmptyState()
var mainContent string
contentHeight := m.height - 2
if m.showHelp {
contentHeight -= 6
}
if contentHeight < 0 {
contentHeight = 0
} }
// Panes if len(m.fileList.Items()) == 0 {
mainContent = m.renderEmptyState(m.width, contentHeight, "No changes found against "+m.targetBranch)
} else {
treeStyle := PaneStyle treeStyle := PaneStyle
if m.focus == FocusTree { if m.focus == FocusTree {
treeStyle = FocusedPaneStyle treeStyle = FocusedPaneStyle
@ -367,10 +443,16 @@ func (m Model) View() string {
} }
treeView := treeStyle.Copy(). treeView := treeStyle.Copy().
Width(m.fileTree.Width()). Width(m.fileList.Width()).
Height(m.fileTree.Height()). Height(m.fileList.Height()).
Render(m.fileTree.View()) Render(m.fileList.View())
var rightPaneView string
selectedItem, ok := m.fileList.SelectedItem().(tree.TreeItem)
if ok && selectedItem.IsDir {
rightPaneView = m.renderEmptyState(m.diffViewport.Width, m.diffViewport.Height, "Directory: "+selectedItem.Name)
} else {
var renderedDiff strings.Builder var renderedDiff strings.Builder
start := m.diffViewport.YOffset start := m.diffViewport.YOffset
end := start + m.diffViewport.Height end := start + m.diffViewport.Height
@ -382,7 +464,7 @@ func (m Model) View() string {
line := m.diffLines[i] line := m.diffLines[i]
var numStr string var numStr string
mode := CurrentConfig.UI.LineNumbers mode := "relative"
if mode == "hidden" { if mode == "hidden" {
numStr = "" numStr = ""
@ -416,35 +498,72 @@ func (m Model) View() string {
renderedDiff.WriteString(lineNumRendered + line + "\n") renderedDiff.WriteString(lineNumRendered + line + "\n")
} }
diffView := DiffStyle.Copy(). // Trim the trailing newline to prevent an extra empty line
// which pushes the layout height +1 and causes the top bar to scroll off.
diffContentStr := strings.TrimRight(renderedDiff.String(), "\n")
rightPaneView = DiffStyle.Copy().
Width(m.diffViewport.Width). Width(m.diffViewport.Width).
Height(m.diffViewport.Height). Height(m.diffViewport.Height).
Render(renderedDiff.String()) Render(diffContentStr)
mainPanes := lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView)
// Bottom area
repoSection := StatusKeyStyle.Render(" " + m.repoName)
divider := StatusDividerStyle.Render("│")
statusText := fmt.Sprintf(" %s ↔ %s", m.currentBranch, m.targetBranch)
if m.inputBuffer != "" {
statusText += fmt.Sprintf(" [Cmd: %s]", m.inputBuffer)
} }
branchSection := StatusBarStyle.Render(statusText)
leftStatus := lipgloss.JoinHorizontal(lipgloss.Center, repoSection, divider, branchSection) mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView)
rightStatus := StatusBarStyle.Render("? Help") }
statusBar := StatusBarStyle.Copy(). var bottomBar string
Width(m.width).
Render(lipgloss.JoinHorizontal(lipgloss.Top,
leftStatus,
lipgloss.PlaceHorizontal(m.width-lipgloss.Width(leftStatus)-lipgloss.Width(rightStatus), lipgloss.Right, rightStatus),
))
var finalView string
if m.showHelp { if m.showHelp {
bottomBar = m.renderHelpDrawer()
} else {
bottomBar = m.viewStatusBar()
}
return lipgloss.JoinVertical(lipgloss.Top, topBar, mainContent, bottomBar)
}
func (m Model) renderTopBar() string {
repo := fmt.Sprintf(" %s", m.repoName)
branches := fmt.Sprintf(" %s ➜ %s", m.currentBranch, m.targetBranch)
info := fmt.Sprintf("%s %s", repo, branches)
leftSide := TopInfoStyle.Render(info)
middle := ""
if m.selectedPath != "" {
middle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(m.selectedPath)
}
rightSide := ""
if m.statsAdded > 0 || m.statsDeleted > 0 {
added := TopStatsAddedStyle.Render(fmt.Sprintf("+%d", m.statsAdded))
deleted := TopStatsDeletedStyle.Render(fmt.Sprintf("-%d", m.statsDeleted))
rightSide = lipgloss.JoinHorizontal(lipgloss.Center, added, deleted)
}
availWidth := m.width - lipgloss.Width(leftSide) - lipgloss.Width(rightSide)
if availWidth < 0 {
availWidth = 0
}
midWidth := lipgloss.Width(middle)
var centerBlock string
if midWidth > availWidth {
centerBlock = strings.Repeat(" ", availWidth)
} else {
padL := (availWidth - midWidth) / 2
padR := availWidth - midWidth - padL
centerBlock = strings.Repeat(" ", padL) + middle + strings.Repeat(" ", padR)
}
finalBar := lipgloss.JoinHorizontal(lipgloss.Top, leftSide, centerBlock, rightSide)
return TopBarStyle.Width(m.width).Render(finalBar)
}
func (m Model) viewStatusBar() string {
shortcuts := StatusKeyStyle.Render("? Help q Quit Tab Switch")
return StatusBarStyle.Width(m.width).Render(shortcuts)
}
func (m Model) renderHelpDrawer() string {
col1 := lipgloss.JoinVertical(lipgloss.Left, col1 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("↑/k Move Up"), HelpTextStyle.Render("↑/k Move Up"),
HelpTextStyle.Render("↓/j Move Down"), HelpTextStyle.Render("↓/j Move Down"),
@ -462,7 +581,7 @@ func (m Model) View() string {
HelpTextStyle.Render("e Edit File"), HelpTextStyle.Render("e Edit File"),
) )
helpDrawer := HelpDrawerStyle.Copy(). return HelpDrawerStyle.Copy().
Width(m.width). Width(m.width).
Render(lipgloss.JoinHorizontal(lipgloss.Top, Render(lipgloss.JoinHorizontal(lipgloss.Top,
col1, col1,
@ -473,64 +592,67 @@ func (m Model) View() string {
lipgloss.NewStyle().Width(4).Render(""), lipgloss.NewStyle().Width(4).Render(""),
col4, col4,
)) ))
finalView = lipgloss.JoinVertical(lipgloss.Top, mainPanes, helpDrawer, statusBar)
} else {
finalView = lipgloss.JoinVertical(lipgloss.Top, mainPanes, statusBar)
} }
return finalView func (m Model) renderEmptyState(w, h int, statusMsg string) string {
}
func (m Model) viewEmptyState() string {
logo := EmptyLogoStyle.Render("difi") logo := EmptyLogoStyle.Render("difi")
desc := EmptyDescStyle.Render("A calm, focused way to review Git diffs.") desc := EmptyDescStyle.Render("A calm, focused way to review Git diffs.")
statusMsg := fmt.Sprintf("✓ No changes found against '%s'", m.targetBranch)
status := EmptyStatusStyle.Render(statusMsg) status := EmptyStatusStyle.Render(statusMsg)
usageHeader := EmptyHeaderStyle.Render("Usage Patterns") usageHeader := EmptyHeaderStyle.Render("Usage Patterns")
cmd1 := lipgloss.NewStyle().Foreground(ColorText).Render("difi") cmd1 := lipgloss.NewStyle().Foreground(ColorText).Render("difi")
desc1 := EmptyCodeStyle.Render("Diff against main") desc1 := EmptyCodeStyle.Render("Diff against main")
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi dev")
cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi develop") desc2 := EmptyCodeStyle.Render("Diff against branch")
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, usageBlock := lipgloss.JoinVertical(lipgloss.Left,
usageHeader, usageHeader,
lipgloss.JoinHorizontal(lipgloss.Left, cmd1, desc1), lipgloss.JoinHorizontal(lipgloss.Left, cmd1, " ", desc1),
lipgloss.JoinHorizontal(lipgloss.Left, cmd2, desc2), lipgloss.JoinHorizontal(lipgloss.Left, cmd2, " ", desc2),
lipgloss.JoinHorizontal(lipgloss.Left, cmd3, desc3),
) )
navHeader := EmptyHeaderStyle.Render("Navigation") navHeader := EmptyHeaderStyle.Render("Navigation")
key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab") key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab")
keyDesc1 := EmptyCodeStyle.Render("Switch panels")
key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j/k") key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j/k")
keyDesc1 := EmptyCodeStyle.Render("Switch panels")
keyDesc2 := EmptyCodeStyle.Render("Move cursor") keyDesc2 := EmptyCodeStyle.Render("Move cursor")
key3 := lipgloss.NewStyle().Foreground(ColorText).Render("zz/zt")
keyDesc3 := EmptyCodeStyle.Render("Center/Top")
navBlock := lipgloss.JoinVertical(lipgloss.Left, navBlock := lipgloss.JoinVertical(lipgloss.Left,
navHeader, navHeader,
lipgloss.JoinHorizontal(lipgloss.Left, key1, keyDesc1), lipgloss.JoinHorizontal(lipgloss.Left, key1, " ", keyDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, key2, keyDesc2), lipgloss.JoinHorizontal(lipgloss.Left, key2, " ", keyDesc2),
lipgloss.JoinHorizontal(lipgloss.Left, key3, keyDesc3),
) )
guides := lipgloss.JoinHorizontal(lipgloss.Top, nvimHeader := EmptyHeaderStyle.Render("Neovim Integration")
usageBlock, nvim1 := lipgloss.NewStyle().Foreground(ColorText).Render("oug-t/difi.nvim")
lipgloss.NewStyle().Width(8).Render(""), nvimDesc1 := EmptyCodeStyle.Render("Install plugin")
navBlock, nvim2 := lipgloss.NewStyle().Foreground(ColorText).Render("Press 'e'")
nvimDesc2 := EmptyCodeStyle.Render("Edit with context")
nvimBlock := lipgloss.JoinVertical(lipgloss.Left,
nvimHeader,
lipgloss.JoinHorizontal(lipgloss.Left, nvim1, " ", nvimDesc1),
lipgloss.JoinHorizontal(lipgloss.Left, nvim2, " ", nvimDesc2),
) )
var guides string
if w > 80 {
guides = lipgloss.JoinHorizontal(lipgloss.Top,
usageBlock,
lipgloss.NewStyle().Width(6).Render(""),
navBlock,
lipgloss.NewStyle().Width(6).Render(""),
nvimBlock,
)
} else {
topRow := lipgloss.JoinHorizontal(lipgloss.Top, usageBlock, lipgloss.NewStyle().Width(4).Render(""), navBlock)
guides = lipgloss.JoinVertical(lipgloss.Left,
topRow,
lipgloss.NewStyle().Height(1).Render(""),
nvimBlock,
)
}
content := lipgloss.JoinVertical(lipgloss.Center, content := lipgloss.JoinVertical(lipgloss.Center,
logo, logo,
desc, desc,
@ -540,14 +662,14 @@ func (m Model) viewEmptyState() string {
) )
var verticalPad string var verticalPad string
if m.height > lipgloss.Height(content) { if h > lipgloss.Height(content) {
lines := (m.height - lipgloss.Height(content)) / 2 lines := (h - lipgloss.Height(content)) / 2
verticalPad = strings.Repeat("\n", lines) verticalPad = strings.Repeat("\n", lines)
} }
return lipgloss.JoinVertical(lipgloss.Top, return lipgloss.JoinVertical(lipgloss.Top,
verticalPad, verticalPad,
lipgloss.PlaceHorizontal(m.width, lipgloss.Center, content), lipgloss.PlaceHorizontal(w, lipgloss.Center, content),
) )
} }

View File

@ -1,118 +1,74 @@
package ui package ui
import ( import "github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss"
"github.com/oug-t/difi/internal/config"
)
var ( var (
// Config // -- NORD PALETTE --
CurrentConfig config.Config nord0 = lipgloss.Color("#2E3440") // Dark background
nord3 = lipgloss.Color("#4C566A") // Separators / Dimmed
nord4 = lipgloss.Color("#D8DEE9") // Main Text
nord11 = lipgloss.Color("#BF616A") // Red (Deleted)
nord14 = lipgloss.Color("#A3BE8C") // Green (Added)
nord9 = lipgloss.Color("#81A1C1") // Blue (Focus)
// Theme colors // -- PANE STYLES --
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#E5E5E5"}
ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#FFFFFF"}
ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#D0D0D0"}
ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"}
ColorAccent = lipgloss.AdaptiveColor{Light: "#00ADD8", Dark: "#00ADD8"} // Go blue
// Pane styles
PaneStyle = lipgloss.NewStyle(). PaneStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, true, false, false). Border(lipgloss.RoundedBorder()).
BorderForeground(ColorBorder) BorderForeground(nord3). // Goal 1: Nord3 Separator
Padding(0, 1)
FocusedPaneStyle = PaneStyle.Copy(). FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus) BorderForeground(nord9)
DiffStyle = lipgloss.NewStyle().Padding(0, 0) // -- TOP BAR STYLES (Goal 2) --
ItemStyle = lipgloss.NewStyle().PaddingLeft(2) TopBarStyle = lipgloss.NewStyle().
Background(nord0).
Foreground(nord4).
Height(1)
// List styles TopInfoStyle = lipgloss.NewStyle().
SelectedItemStyle = lipgloss.NewStyle().
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true). Bold(true).
Width(1000) Padding(0, 1)
SelectedBlockStyle = lipgloss.NewStyle(). TopStatsAddedStyle = lipgloss.NewStyle().
Background(ColorCursorBg). Foreground(nord14).
Foreground(ColorText).
Bold(true).
PaddingLeft(1) PaddingLeft(1)
// Icon styles TopStatsDeletedStyle = lipgloss.NewStyle().
FolderIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F7B96E", Dark: "#E5C07B"}) Foreground(nord11).
FileIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#969696", Dark: "#ABB2BF"}) PaddingLeft(1).
PaddingRight(1)
// Diff view styles // -- TREE STYLES --
LineNumberStyle = lipgloss.NewStyle(). DirectoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
Foreground(ColorSubtle). FileStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
PaddingRight(1).
Width(4)
DiffSelectionStyle = lipgloss.NewStyle(). // -- DIFF VIEW STYLES --
Background(ColorCursorBg). DiffStyle = lipgloss.NewStyle().Padding(0, 0)
Width(1000) DiffSelectionStyle = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("255"))
LineNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Width(4).Align(lipgloss.Right).MarginRight(1)
// Status bar colors // -- EMPTY STATE STYLES --
ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"} EmptyLogoStyle = lipgloss.NewStyle().Foreground(nord9).Bold(true).MarginBottom(1)
ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"} EmptyDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).MarginBottom(1)
EmptyStatusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).MarginBottom(2)
EmptyHeaderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Bold(true).MarginBottom(1)
EmptyCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
// Status bar styles // -- HELPER STYLES --
StatusBarStyle = lipgloss.NewStyle(). HelpDrawerStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(nord3).Padding(1, 2)
Foreground(ColorBarFg). HelpTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).MarginRight(2)
Background(ColorBarBg).
Padding(0, 1)
StatusKeyStyle = lipgloss.NewStyle(). // -- BOTTOM STATUS BAR STYLES --
Foreground(ColorText). StatusBarStyle = lipgloss.NewStyle().Background(nord0).Foreground(nord4).Height(1)
Background(ColorBarBg). StatusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Padding(0, 1)
Bold(true). StatusRepoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7aa2f7")).Padding(0, 1)
Padding(0, 1) StatusBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7")).Padding(0, 1)
StatusAddedStyle = lipgloss.NewStyle().Foreground(nord14).Padding(0, 1)
StatusDeletedStyle = lipgloss.NewStyle().Foreground(nord11).Padding(0, 1)
StatusDividerStyle = lipgloss.NewStyle().Foreground(nord3).Padding(0, 1)
StatusDividerStyle = lipgloss.NewStyle(). ColorText = lipgloss.Color("252")
Foreground(ColorSubtle).
Background(ColorBarBg).
Padding(0, 0)
// Help styles
HelpTextStyle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Padding(0, 1)
HelpDrawerStyle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true, false, false, false).
BorderForeground(ColorBorder).
Padding(1, 2)
// Empty/landing 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) { func InitStyles(cfg interface{}) {}
CurrentConfig = cfg
}