Merge pull request #2 from oug-t/feat/vim-motions
feat: vim motions support
This commit is contained in:
commit
d8dd109c3f
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -96,8 +144,10 @@ func getIcon(name string, isDir bool) string {
|
|||
switch strings.ToLower(ext) {
|
||||
case ".go":
|
||||
return " "
|
||||
case ".js", ".ts":
|
||||
case ".js", ".ts", ".tsx":
|
||||
return " "
|
||||
case ".svelte":
|
||||
return " "
|
||||
case ".md":
|
||||
return " "
|
||||
case ".json":
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user