Merge pull request #2 from oug-t/feat/vim-motions

feat: vim motions support
This commit is contained in:
Tommy Guo 2026-01-30 12:38:38 -05:00 committed by GitHub
commit d8dd109c3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 151 additions and 66 deletions

View File

@ -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 " "
}
}

View File

@ -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,11 +151,15 @@ 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":
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++
@ -138,8 +167,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.diffViewport.LineDown(1)
}
}
} else {
m.fileTree.CursorDown()
}
}
m.inputBuffer = ""
case "k", "up":
keyHandled = true // Mark as handled
count := m.getRepeatCount()
for i := 0; i < count; i++ {
if m.focus == FocusDiff {
if m.diffCursor > 0 {
m.diffCursor--
@ -147,14 +184,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.diffViewport.LineUp(1)
}
}
} else {
m.fileTree.CursorUp()
}
}
m.inputBuffer = ""
default:
m.inputBuffer = ""
}
}
// Update Components
if m.focus == FocusTree {
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"),
)

View File

@ -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)
)