diff --git a/internal/git/client.go b/internal/git/client.go index 6367c71..e16b43c 100644 --- a/internal/git/client.go +++ b/internal/git/client.go @@ -74,8 +74,6 @@ func OpenEditorCmd(path string, lineNumber int, targetBranch string) tea.Cmd { c := exec.Command(editor, args...) 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)) 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 { lines := strings.Split(diffContent, "\n") if visualLineIndex >= len(lines) { diff --git a/internal/tree/builder.go b/internal/tree/builder.go deleted file mode 100644 index a22f294..0000000 --- a/internal/tree/builder.go +++ /dev/null @@ -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 " " - } -} diff --git a/internal/tree/tree.go b/internal/tree/tree.go new file mode 100644 index 0000000..3174123 --- /dev/null +++ b/internal/tree/tree.go @@ -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 "" + } +} diff --git a/internal/ui/delegate.go b/internal/ui/delegate.go index ec52b5f..de7b117 100644 --- a/internal/ui/delegate.go +++ b/internal/ui/delegate.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/oug-t/difi/internal/tree" ) @@ -16,6 +17,7 @@ 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 { @@ -24,17 +26,19 @@ func (d TreeDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite 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)) + style := lipgloss.NewStyle(). + Background(lipgloss.Color("237")). // Dark gray background + Foreground(lipgloss.Color("255")). // White text + Bold(true) + + if !d.Focused { + style = style.Foreground(lipgloss.Color("245")) } + + fmt.Fprint(w, style.Render(title)) } else { - // Normal Item (No icons added, just the text) - fmt.Fprint(w, ItemStyle.Render(title)) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + fmt.Fprint(w, style.Render(title)) } } diff --git a/internal/ui/model.go b/internal/ui/model.go index aa6b595..e658b17 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -24,8 +24,14 @@ const ( FocusDiff ) +type StatsMsg struct { + Added int + Deleted int +} + type Model struct { - fileTree list.Model + fileList list.Model + treeState *tree.FileTree treeDelegate TreeDelegate diffViewport viewport.Model @@ -34,6 +40,9 @@ type Model struct { targetBranch string repoName string + statsAdded int + statsDeleted int + diffContent string diffLines []string diffCursor int @@ -51,7 +60,9 @@ func NewModel(cfg config.Config, targetBranch string) Model { InitStyles(cfg) files, _ := git.ListChangedFiles(targetBranch) - items := tree.Build(files) + + t := tree.New(files) + items := t.Items() delegate := TreeDelegate{Focused: true} l := list.New(items, delegate, 0, 0) @@ -64,7 +75,8 @@ func NewModel(cfg config.Config, targetBranch string) Model { l.DisableQuitKeybindings() m := Model{ - fileTree: l, + fileList: l, + treeState: t, treeDelegate: delegate, diffViewport: viewport.New(0, 0), focus: FocusTree, @@ -78,17 +90,34 @@ func NewModel(cfg config.Config, targetBranch string) Model { if len(items) > 0 { if first, ok := items[0].(tree.TreeItem); ok { - m.selectedPath = first.FullPath + if !first.IsDir { + m.selectedPath = first.FullPath + } } } return m } func (m Model) Init() tea.Cmd { + var cmds []tea.Cmd + 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 StatsMsg{Added: added, Deleted: deleted} } - return nil } func (m *Model) getRepeatCount() int { @@ -115,16 +144,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.updateSizes() + case StatsMsg: + m.statsAdded = msg.Added + m.statsDeleted = msg.Deleted + case tea.KeyMsg: if msg.String() == "q" || msg.String() == "ctrl+c" { return m, tea.Quit } - if len(m.fileTree.Items()) == 0 { + if len(m.fileList.Items()) == 0 { return m, nil } - // Handle z-prefix commands (zz, zt, zb) if m.pendingZ { m.pendingZ = false if m.focus == FocusDiff { @@ -158,6 +190,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "tab": if m.focus == FocusTree { + if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok && item.IsDir { + return m, nil + } m.focus = FocusDiff } else { m.focus = FocusTree @@ -166,6 +201,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.inputBuffer = "" 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.updateTreeFocus() m.inputBuffer = "" @@ -175,8 +215,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateTreeFocus() 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 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 if m.focus == FocusDiff { 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) } m.inputBuffer = "" - // Integration Point: Pass targetBranch to the editor command return m, git.OpenEditorCmd(m.selectedPath, line, m.targetBranch) } - // Viewport trigger case "z": if m.focus == FocusDiff { m.pendingZ = true return m, nil } - // Cursor screen positioning (H, M, L) case "H": if m.focus == FocusDiff { 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": if m.focus == FocusDiff { 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.diffCursor < len(m.diffLines)-1 { m.diffCursor++ - // Scroll if hitting bottom edge if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height { m.diffViewport.LineDown(1) } } } else { - m.fileTree.CursorDown() + m.fileList.CursorDown() } } m.inputBuffer = "" @@ -269,13 +325,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.focus == FocusDiff { if m.diffCursor > 0 { m.diffCursor-- - // Scroll if hitting top edge if m.diffCursor < m.diffViewport.YOffset { m.diffViewport.LineUp(1) } } } else { - m.fileTree.CursorUp() + m.fileList.CursorUp() } } 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 { - m.fileTree, cmd = m.fileTree.Update(msg) + m.fileList, cmd = m.fileList.Update(msg) cmds = append(cmds, cmd) } - if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir { - if item.FullPath != m.selectedPath { + if item, ok := m.fileList.SelectedItem().(tree.TreeItem); ok { + if !item.IsDir && item.FullPath != m.selectedPath { m.selectedPath = item.FullPath m.diffCursor = 0 m.diffViewport.GotoTop() @@ -324,29 +379,41 @@ func (m *Model) centerDiffCursor() { } func (m *Model) updateSizes() { - reservedHeight := 1 + // 1 line Top Bar + 1 line Bottom Bar = 2 reserved + reservedHeight := 2 if m.showHelp { reservedHeight += 6 } + // Calculate main area height contentHeight := m.height - reservedHeight if contentHeight < 1 { contentHeight = 1 } + // Calculate widths treeWidth := int(float64(m.width) * 0.20) if 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.Height = contentHeight + m.diffViewport.Height = listHeight } func (m *Model) updateTreeFocus() { m.treeDelegate.Focused = (m.focus == FocusTree) - m.fileTree.SetDelegate(m.treeDelegate) + m.fileList.SetDelegate(m.treeDelegate) } func (m Model) View() string { @@ -354,183 +421,238 @@ func (m Model) View() string { return "Loading..." } - if len(m.fileTree.Items()) == 0 { - return m.viewEmptyState() - } + topBar := m.renderTopBar() - // Panes - treeStyle := PaneStyle - if m.focus == FocusTree { - treeStyle = FocusedPaneStyle - } else { - treeStyle = PaneStyle - } - - treeView := treeStyle.Copy(). - Width(m.fileTree.Width()). - Height(m.fileTree.Height()). - Render(m.fileTree.View()) - - var renderedDiff strings.Builder - start := m.diffViewport.YOffset - end := start + m.diffViewport.Height - if end > len(m.diffLines) { - end = len(m.diffLines) - } - - for i := start; i < end; i++ { - line := m.diffLines[i] - - var numStr string - mode := CurrentConfig.UI.LineNumbers - - if mode == "hidden" { - numStr = "" - } else { - isCursor := (i == m.diffCursor) - if isCursor && mode == "hybrid" { - realLine := git.CalculateFileLine(m.diffContent, m.diffCursor) - numStr = fmt.Sprintf("%d", realLine) - } else if isCursor && mode == "relative" { - numStr = "0" - } else if mode == "absolute" { - numStr = fmt.Sprintf("%d", i+1) - } else { - dist := int(math.Abs(float64(i - m.diffCursor))) - numStr = fmt.Sprintf("%d", dist) - } - } - - lineNumRendered := "" - if numStr != "" { - lineNumRendered = LineNumberStyle.Render(numStr) - } - - if m.focus == FocusDiff && i == m.diffCursor { - cleanLine := stripAnsi(line) - line = DiffSelectionStyle.Render(" " + cleanLine) - } else { - line = " " + line - } - - renderedDiff.WriteString(lineNumRendered + line + "\n") - } - - diffView := DiffStyle.Copy(). - Width(m.diffViewport.Width). - Height(m.diffViewport.Height). - Render(renderedDiff.String()) - - 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) - rightStatus := StatusBarStyle.Render("? Help") - - statusBar := StatusBarStyle.Copy(). - 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 + var mainContent string + contentHeight := m.height - 2 if m.showHelp { - col1 := lipgloss.JoinVertical(lipgloss.Left, - HelpTextStyle.Render("↑/k Move Up"), - HelpTextStyle.Render("↓/j Move Down"), - ) - col2 := lipgloss.JoinVertical(lipgloss.Left, - HelpTextStyle.Render("←/h Left Panel"), - HelpTextStyle.Render("→/l Right Panel"), - ) - col3 := lipgloss.JoinVertical(lipgloss.Left, - HelpTextStyle.Render("C-d/u Page Dn/Up"), - HelpTextStyle.Render("zz/zt Scroll View"), - ) - col4 := lipgloss.JoinVertical(lipgloss.Left, - HelpTextStyle.Render("H/M/L Move Cursor"), - HelpTextStyle.Render("e Edit File"), - ) - - helpDrawer := HelpDrawerStyle.Copy(). - Width(m.width). - Render(lipgloss.JoinHorizontal(lipgloss.Top, - col1, - lipgloss.NewStyle().Width(4).Render(""), - col2, - lipgloss.NewStyle().Width(4).Render(""), - col3, - lipgloss.NewStyle().Width(4).Render(""), - col4, - )) - - finalView = lipgloss.JoinVertical(lipgloss.Top, mainPanes, helpDrawer, statusBar) - } else { - finalView = lipgloss.JoinVertical(lipgloss.Top, mainPanes, statusBar) + contentHeight -= 6 + } + if contentHeight < 0 { + contentHeight = 0 } - return finalView + if len(m.fileList.Items()) == 0 { + mainContent = m.renderEmptyState(m.width, contentHeight, "No changes found against "+m.targetBranch) + } else { + treeStyle := PaneStyle + if m.focus == FocusTree { + treeStyle = FocusedPaneStyle + } else { + treeStyle = PaneStyle + } + + treeView := treeStyle.Copy(). + Width(m.fileList.Width()). + Height(m.fileList.Height()). + 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 + start := m.diffViewport.YOffset + end := start + m.diffViewport.Height + if end > len(m.diffLines) { + end = len(m.diffLines) + } + + for i := start; i < end; i++ { + line := m.diffLines[i] + + var numStr string + mode := "relative" + + if mode == "hidden" { + numStr = "" + } else { + isCursor := (i == m.diffCursor) + if isCursor && mode == "hybrid" { + realLine := git.CalculateFileLine(m.diffContent, m.diffCursor) + numStr = fmt.Sprintf("%d", realLine) + } else if isCursor && mode == "relative" { + numStr = "0" + } else if mode == "absolute" { + numStr = fmt.Sprintf("%d", i+1) + } else { + dist := int(math.Abs(float64(i - m.diffCursor))) + numStr = fmt.Sprintf("%d", dist) + } + } + + lineNumRendered := "" + if numStr != "" { + lineNumRendered = LineNumberStyle.Render(numStr) + } + + if m.focus == FocusDiff && i == m.diffCursor { + cleanLine := stripAnsi(line) + line = DiffSelectionStyle.Render(" " + cleanLine) + } else { + line = " " + line + } + + renderedDiff.WriteString(lineNumRendered + line + "\n") + } + + // 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). + Height(m.diffViewport.Height). + Render(diffContentStr) + } + + mainContent = lipgloss.JoinHorizontal(lipgloss.Top, treeView, rightPaneView) + } + + var bottomBar string + if m.showHelp { + bottomBar = m.renderHelpDrawer() + } else { + bottomBar = m.viewStatusBar() + } + + return lipgloss.JoinVertical(lipgloss.Top, topBar, mainContent, bottomBar) } -func (m Model) viewEmptyState() string { +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, + HelpTextStyle.Render("↑/k Move Up"), + HelpTextStyle.Render("↓/j Move Down"), + ) + col2 := lipgloss.JoinVertical(lipgloss.Left, + HelpTextStyle.Render("←/h Left Panel"), + HelpTextStyle.Render("→/l Right Panel"), + ) + col3 := lipgloss.JoinVertical(lipgloss.Left, + HelpTextStyle.Render("C-d/u Page Dn/Up"), + HelpTextStyle.Render("zz/zt Scroll View"), + ) + col4 := lipgloss.JoinVertical(lipgloss.Left, + HelpTextStyle.Render("H/M/L Move Cursor"), + HelpTextStyle.Render("e Edit File"), + ) + + return HelpDrawerStyle.Copy(). + Width(m.width). + Render(lipgloss.JoinHorizontal(lipgloss.Top, + col1, + lipgloss.NewStyle().Width(4).Render(""), + col2, + lipgloss.NewStyle().Width(4).Render(""), + col3, + lipgloss.NewStyle().Width(4).Render(""), + col4, + )) +} + +func (m Model) renderEmptyState(w, h int, statusMsg string) string { logo := EmptyLogoStyle.Render("difi") 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) 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") + cmd2 := lipgloss.NewStyle().Foreground(ColorText).Render("difi dev") + desc2 := EmptyCodeStyle.Render("Diff against branch") usageBlock := lipgloss.JoinVertical(lipgloss.Left, usageHeader, - lipgloss.JoinHorizontal(lipgloss.Left, cmd1, desc1), - lipgloss.JoinHorizontal(lipgloss.Left, cmd2, desc2), - lipgloss.JoinHorizontal(lipgloss.Left, cmd3, desc3), + lipgloss.JoinHorizontal(lipgloss.Left, cmd1, " ", desc1), + lipgloss.JoinHorizontal(lipgloss.Left, cmd2, " ", desc2), ) navHeader := EmptyHeaderStyle.Render("Navigation") - key1 := lipgloss.NewStyle().Foreground(ColorText).Render("Tab") + key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j/k") keyDesc1 := EmptyCodeStyle.Render("Switch panels") - - key2 := lipgloss.NewStyle().Foreground(ColorText).Render("j / k") keyDesc2 := EmptyCodeStyle.Render("Move cursor") - key3 := lipgloss.NewStyle().Foreground(ColorText).Render("zz/zt") - keyDesc3 := EmptyCodeStyle.Render("Center/Top") - navBlock := lipgloss.JoinVertical(lipgloss.Left, navHeader, - lipgloss.JoinHorizontal(lipgloss.Left, key1, keyDesc1), - lipgloss.JoinHorizontal(lipgloss.Left, key2, keyDesc2), - lipgloss.JoinHorizontal(lipgloss.Left, key3, keyDesc3), + lipgloss.JoinHorizontal(lipgloss.Left, key1, " ", keyDesc1), + lipgloss.JoinHorizontal(lipgloss.Left, key2, " ", keyDesc2), ) - guides := lipgloss.JoinHorizontal(lipgloss.Top, - usageBlock, - lipgloss.NewStyle().Width(8).Render(""), - navBlock, + nvimHeader := EmptyHeaderStyle.Render("Neovim Integration") + nvim1 := lipgloss.NewStyle().Foreground(ColorText).Render("oug-t/difi.nvim") + nvimDesc1 := EmptyCodeStyle.Render("Install plugin") + 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, logo, desc, @@ -540,14 +662,14 @@ func (m Model) viewEmptyState() string { ) var verticalPad string - if m.height > lipgloss.Height(content) { - lines := (m.height - lipgloss.Height(content)) / 2 + if h > lipgloss.Height(content) { + lines := (h - lipgloss.Height(content)) / 2 verticalPad = strings.Repeat("\n", lines) } return lipgloss.JoinVertical(lipgloss.Top, verticalPad, - lipgloss.PlaceHorizontal(m.width, lipgloss.Center, content), + lipgloss.PlaceHorizontal(w, lipgloss.Center, content), ) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 88b5b0d..2400174 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -1,118 +1,74 @@ package ui -import ( - "github.com/charmbracelet/lipgloss" - "github.com/oug-t/difi/internal/config" -) +import "github.com/charmbracelet/lipgloss" var ( - // Config - CurrentConfig config.Config + // -- NORD PALETTE -- + 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 - 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 + // -- PANE STYLES -- PaneStyle = lipgloss.NewStyle(). - Border(lipgloss.NormalBorder(), false, true, false, false). - BorderForeground(ColorBorder) + Border(lipgloss.RoundedBorder()). + BorderForeground(nord3). // Goal 1: Nord3 Separator + Padding(0, 1) FocusedPaneStyle = PaneStyle.Copy(). - BorderForeground(ColorFocus) + BorderForeground(nord9) - DiffStyle = lipgloss.NewStyle().Padding(0, 0) - ItemStyle = lipgloss.NewStyle().PaddingLeft(2) + // -- TOP BAR STYLES (Goal 2) -- + TopBarStyle = lipgloss.NewStyle(). + Background(nord0). + Foreground(nord4). + Height(1) - // List styles - SelectedItemStyle = lipgloss.NewStyle(). - PaddingLeft(1). - Background(ColorCursorBg). - Foreground(ColorText). - Bold(true). - Width(1000) + TopInfoStyle = lipgloss.NewStyle(). + Bold(true). + Padding(0, 1) - SelectedBlockStyle = lipgloss.NewStyle(). - Background(ColorCursorBg). - Foreground(ColorText). - Bold(true). + TopStatsAddedStyle = lipgloss.NewStyle(). + Foreground(nord14). PaddingLeft(1) - // Icon styles - FolderIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F7B96E", Dark: "#E5C07B"}) - FileIconStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#969696", Dark: "#ABB2BF"}) + TopStatsDeletedStyle = lipgloss.NewStyle(). + Foreground(nord11). + PaddingLeft(1). + PaddingRight(1) - // Diff view styles - LineNumberStyle = lipgloss.NewStyle(). - Foreground(ColorSubtle). - PaddingRight(1). - Width(4) + // -- TREE STYLES -- + DirectoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")) + FileStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) - DiffSelectionStyle = lipgloss.NewStyle(). - Background(ColorCursorBg). - Width(1000) + // -- DIFF VIEW STYLES -- + DiffStyle = lipgloss.NewStyle().Padding(0, 0) + 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 - ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"} - ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"} + // -- EMPTY STATE STYLES -- + EmptyLogoStyle = lipgloss.NewStyle().Foreground(nord9).Bold(true).MarginBottom(1) + 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 - StatusBarStyle = lipgloss.NewStyle(). - Foreground(ColorBarFg). - Background(ColorBarBg). - Padding(0, 1) + // -- HELPER STYLES -- + HelpDrawerStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false, false, false).BorderForeground(nord3).Padding(1, 2) + HelpTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).MarginRight(2) - StatusKeyStyle = lipgloss.NewStyle(). - Foreground(ColorText). - Background(ColorBarBg). - Bold(true). - Padding(0, 1) + // -- BOTTOM STATUS BAR STYLES -- + StatusBarStyle = lipgloss.NewStyle().Background(nord0).Foreground(nord4).Height(1) + StatusKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Padding(0, 1) + StatusRepoStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7aa2f7")).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(). - 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) + ColorText = lipgloss.Color("252") ) -func InitStyles(cfg config.Config) { - CurrentConfig = cfg -} +func InitStyles(cfg interface{}) {}