From e0373854784236e6db08b89ae918b043e9e47530 Mon Sep 17 00:00:00 2001 From: Tommy Guo Date: Fri, 30 Jan 2026 12:38:12 -0500 Subject: [PATCH] feat: vim motions support --- internal/tree/builder.go | 76 ++++++++++++++++++++++++----- internal/ui/model.go | 102 ++++++++++++++++++++++++++++++--------- internal/ui/styles.go | 39 ++++----------- 3 files changed, 151 insertions(+), 66 deletions(-) diff --git a/internal/tree/builder.go b/internal/tree/builder.go index 34cda02..9649b51 100644 --- a/internal/tree/builder.go +++ b/internal/tree/builder.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/list" ) +// TreeItem represents a file or folder in the UI list. type TreeItem struct { Path string FullPath string @@ -24,9 +25,16 @@ 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. func Build(paths []string) []list.Item { - root := &node{children: make(map[string]*node)} + // FIX 1: Initialize root as a directory so logic works, + // but we won't compact the root itself. + root := &node{ + children: make(map[string]*node), + isDir: true, + } + // 1. Build the raw tree structure for _, path := range paths { parts := strings.Split(path, "/") current := root @@ -45,11 +53,20 @@ 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 var items []list.Item flatten(root, 0, &items) return items } +// -- Helpers -- + type node struct { name string fullPath string @@ -57,6 +74,35 @@ 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 + } + } +} + func flatten(n *node, depth int, items *[]list.Item) { keys := make([]string, 0, len(n.children)) for k := range n.children { @@ -65,6 +111,7 @@ func flatten(n *node, depth int, items *[]list.Item) { sort.Slice(keys, func(i, j int) bool { a, b := n.children[keys[i]], n.children[keys[j]] + // Folders first if a.isDir && !b.isDir { return true } @@ -82,6 +129,7 @@ func flatten(n *node, depth int, items *[]list.Item) { IsDir: child.isDir, Depth: depth, }) + if child.isDir { flatten(child, depth+1, items) } @@ -90,29 +138,31 @@ func flatten(n *node, depth int, items *[]list.Item) { func getIcon(name string, isDir bool) string { if isDir { - return "" + return " " } ext := filepath.Ext(name) switch strings.ToLower(ext) { case ".go": - return "" - case ".js", ".ts": - return "" + return " " + case ".js", ".ts", ".tsx": + return " " + case ".svelte": + return " " case ".md": - return "" + return " " case ".json": - return "" + return " " case ".yml", ".yaml": - return "" + return " " case ".html": - return "" + return " " case ".css": - return "" + return " " case ".git": - return "" + return " " case ".dockerfile": - return "" + return " " default: - return "" + return " " } } diff --git a/internal/ui/model.go b/internal/ui/model.go index 1fc05e1..448b907 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -3,6 +3,8 @@ package ui import ( "fmt" "io" + "math" + "strconv" "strings" "github.com/charmbracelet/bubbles/list" @@ -37,6 +39,9 @@ type Model struct { diffLines []string diffCursor int + // Input State for Vim Motions + inputBuffer string + // UI State focus Focus showHelp bool @@ -62,6 +67,7 @@ func NewModel() Model { currentBranch: git.GetCurrentBranch(), repoName: git.GetRepoName(), showHelp: false, + inputBuffer: "", } if len(items) > 0 { @@ -79,10 +85,25 @@ func (m Model) Init() tea.Cmd { return nil } +func (m *Model) getRepeatCount() int { + if m.inputBuffer == "" { + return 1 + } + count, err := strconv.Atoi(m.inputBuffer) + if err != nil { + return 1 + } + m.inputBuffer = "" + return count +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + // Flag to track if we manually handled navigation + keyHandled := false + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width @@ -90,19 +111,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.updateSizes() case tea.KeyMsg: - // Toggle Help + if len(msg.String()) == 1 && strings.ContainsAny(msg.String(), "0123456789") { + m.inputBuffer += msg.String() + return m, nil + } + if msg.String() == "?" { m.showHelp = !m.showHelp m.updateSizes() return m, nil } - // Quit if msg.String() == "q" || msg.String() == "ctrl+c" { return m, tea.Quit } - // Navigation switch msg.String() { case "tab": if m.focus == FocusTree { @@ -110,14 +133,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.focus = FocusTree } + m.inputBuffer = "" case "l", "]", "ctrl+l", "right": m.focus = FocusDiff + m.inputBuffer = "" case "h", "[", "ctrl+h", "left": m.focus = FocusTree + m.inputBuffer = "" - // Editing case "e", "enter": if m.selectedPath != "" { line := 0 @@ -126,35 +151,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { line = git.CalculateFileLine(m.diffContent, 0) } + m.inputBuffer = "" return m, git.OpenEditorCmd(m.selectedPath, line) } - // Diff Cursor + // Vim Motions case "j", "down": - if m.focus == FocusDiff { - if m.diffCursor < len(m.diffLines)-1 { - m.diffCursor++ - if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height { - m.diffViewport.LineDown(1) + keyHandled = true // Mark as handled so we don't pass to list.Update() + count := m.getRepeatCount() + for i := 0; i < count; i++ { + if m.focus == FocusDiff { + if m.diffCursor < len(m.diffLines)-1 { + m.diffCursor++ + if m.diffCursor >= m.diffViewport.YOffset+m.diffViewport.Height { + m.diffViewport.LineDown(1) + } } + } else { + m.fileTree.CursorDown() } } + m.inputBuffer = "" + case "k", "up": - if m.focus == FocusDiff { - if m.diffCursor > 0 { - m.diffCursor-- - if m.diffCursor < m.diffViewport.YOffset { - m.diffViewport.LineUp(1) + keyHandled = true // Mark as handled + count := m.getRepeatCount() + for i := 0; i < count; i++ { + if m.focus == FocusDiff { + if m.diffCursor > 0 { + m.diffCursor-- + if m.diffCursor < m.diffViewport.YOffset { + m.diffViewport.LineUp(1) + } } + } else { + m.fileTree.CursorUp() } } + m.inputBuffer = "" + + default: + m.inputBuffer = "" } } // Update Components if m.focus == FocusTree { - m.fileTree, cmd = m.fileTree.Update(msg) - cmds = append(cmds, cmd) + if !keyHandled { + m.fileTree, cmd = m.fileTree.Update(msg) + cmds = append(cmds, cmd) + } if item, ok := m.fileTree.SelectedItem().(tree.TreeItem); ok && !item.IsDir { if item.FullPath != m.selectedPath { @@ -226,12 +272,19 @@ func (m Model) View() string { for i := start; i < end; i++ { line := m.diffLines[i] + + // Relative Numbers + distance := int(math.Abs(float64(i - m.diffCursor))) + relNum := fmt.Sprintf("%d", distance) + lineNumStr := LineNumberStyle.Render(relNum) + if m.focus == FocusDiff && i == m.diffCursor { line = SelectedItemStyle.Render(line) } else { line = " " + line } - renderedDiff.WriteString(line + "\n") + + renderedDiff.WriteString(lineNumStr + line + "\n") } diffView := DiffStyle.Copy(). @@ -241,10 +294,14 @@ func (m Model) View() string { mainPanes := lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView) - // Status Bar repoSection := StatusKeyStyle.Render(" " + m.repoName) divider := StatusDividerStyle.Render("│") - branchSection := StatusBarStyle.Render(fmt.Sprintf(" %s ↔ %s", m.currentBranch, TargetBranch)) + + statusText := fmt.Sprintf(" %s ↔ %s", m.currentBranch, 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") @@ -256,7 +313,6 @@ func (m Model) View() string { lipgloss.PlaceHorizontal(m.width-lipgloss.Width(leftStatus)-lipgloss.Width(rightStatus), lipgloss.Right, rightStatus), )) - // Help Drawer var finalView string if m.showHelp { col1 := lipgloss.JoinVertical(lipgloss.Left, @@ -269,10 +325,10 @@ func (m Model) View() string { ) col3 := lipgloss.JoinVertical(lipgloss.Left, HelpTextStyle.Render("Tab Switch Panel"), - HelpTextStyle.Render("Ent/e Edit File"), + HelpTextStyle.Render("Num Motion Count"), ) col4 := lipgloss.JoinVertical(lipgloss.Left, - HelpTextStyle.Render("q Quit"), + HelpTextStyle.Render("e Edit File"), HelpTextStyle.Render("? Close Help"), ) diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 8238505..9b5a017 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -3,18 +3,15 @@ package ui import "github.com/charmbracelet/lipgloss" var ( - // -- THEME: Neutral & Clean -- ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"} ColorFocus = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#E5E5E5"} ColorText = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"} ColorSubtle = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"} ColorCursorBg = lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#3E3E3E"} - // -- Status Bar Colors -- ColorBarBg = lipgloss.AdaptiveColor{Light: "#F2F2F2", Dark: "#1F1F1F"} ColorBarFg = lipgloss.AdaptiveColor{Light: "#6E6E6E", Dark: "#9E9E9E"} - // -- PANE STYLES -- PaneStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, true, false, false). BorderForeground(ColorBorder) @@ -32,32 +29,14 @@ var ( Bold(true). Width(1000) - // -- STATUS BAR STYLES -- - StatusBarStyle = lipgloss.NewStyle(). - Foreground(ColorBarFg). - Background(ColorBarBg). - Padding(0, 1) + LineNumberStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#707070")). // Solid gray, easy to read + PaddingRight(1). + Width(4) - StatusKeyStyle = lipgloss.NewStyle(). - Foreground(ColorText). - Background(ColorBarBg). - Bold(true). - Padding(0, 1) - - StatusDividerStyle = lipgloss.NewStyle(). - Foreground(ColorSubtle). - Background(ColorBarBg). - Padding(0, 0) - - // -- NEW HELP STYLES (Transparent & Subtle) -- - // No background, subtle color, no bold - HelpTextStyle = lipgloss.NewStyle(). - Foreground(ColorSubtle). - Padding(0, 1) - - HelpDrawerStyle = lipgloss.NewStyle(). - // No Background() definition means transparent - Border(lipgloss.NormalBorder(), true, false, false, false). // Top border only - BorderForeground(ColorBorder). - Padding(1, 2) + 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).Padding(1, 2) )