feat: implement mvp

This commit is contained in:
Tommy Guo 2026-01-30 11:20:29 -05:00
parent 65d3ecbb14
commit 297e3e5095
3 changed files with 337 additions and 46 deletions

View File

@ -1,20 +1,43 @@
package git
import (
"fmt"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
func GetCurrentBranch() string {
out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output()
if err != nil {
return "HEAD"
}
return strings.TrimSpace(string(out))
}
func GetRepoName() string {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "Repo"
}
path := strings.TrimSpace(string(out))
parts := strings.Split(path, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return "Repo"
}
func ListChangedFiles(targetBranch string) ([]string, error) {
cmd := exec.Command("git", "diff", "--name-only", targetBranch)
out, err := cmd.Output()
if err != nil {
return nil, err
}
files := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(files) == 1 && files[0] == "" {
return []string{}, nil
@ -32,17 +55,66 @@ func DiffCmd(targetBranch, path string) tea.Cmd {
}
}
func OpenEditorCmd(path string) tea.Cmd {
func OpenEditorCmd(path string, lineNumber int) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vim"
if _, err := exec.LookPath("nvim"); err == nil {
editor = "nvim"
} else {
editor = "vim"
}
}
c := exec.Command(editor, path)
var args []string
if lineNumber > 0 {
args = append(args, fmt.Sprintf("+%d", lineNumber))
}
args = append(args, path)
c := exec.Command(editor, args...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
return EditorFinishedMsg{Err: err}
})
}
func CalculateFileLine(diffContent string, visualLineIndex int) int {
lines := strings.Split(diffContent, "\n")
if visualLineIndex >= len(lines) {
return 0
}
re := regexp.MustCompile(`^.*?@@ \-\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
currentLineNo := 0
for i := 0; i <= visualLineIndex; i++ {
line := lines[i]
matches := re.FindStringSubmatch(line)
if len(matches) > 1 {
startLine, _ := strconv.Atoi(matches[1])
currentLineNo = startLine
continue
}
cleanLine := stripAnsi(line)
if strings.HasPrefix(cleanLine, " ") || strings.HasPrefix(cleanLine, "+") {
currentLineNo++
}
}
if currentLineNo == 0 {
return 1
}
return currentLineNo - 1
}
func stripAnsi(str string) string {
const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
var re = regexp.MustCompile(ansi)
return re.ReplaceAllString(str, "")
}
type DiffMsg struct{ Content string }
type EditorFinishedMsg struct{ Err error }

View File

@ -3,6 +3,7 @@ package ui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/viewport"
@ -15,10 +16,31 @@ import (
const TargetBranch = "main"
type Focus int
const (
FocusTree Focus = iota
FocusDiff
)
type Model struct {
fileTree list.Model
diffViewport viewport.Model
fileTree list.Model
diffViewport viewport.Model
// Data
selectedPath string
currentBranch string
repoName string
// Diff State
diffContent string
diffLines []string
diffCursor int
// UI State
focus Focus
showHelp bool
width, height int
}
@ -31,10 +53,15 @@ func NewModel() Model {
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.DisableQuitKeybindings()
m := Model{
fileTree: l,
diffViewport: viewport.New(0, 0),
fileTree: l,
diffViewport: viewport.New(0, 0),
focus: FocusTree,
currentBranch: git.GetCurrentBranch(),
repoName: git.GetRepoName(),
showHelp: false,
}
if len(items) > 0 {
@ -60,58 +87,213 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
treeWidth := int(float64(m.width) * 0.25)
m.fileTree.SetSize(treeWidth, m.height)
m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = m.height
m.updateSizes()
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "up", "k", "down", "j":
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 {
m.selectedPath = item.FullPath
cmds = append(cmds, git.DiffCmd(TargetBranch, m.selectedPath))
}
}
case "e":
if m.selectedPath != "" {
return m, git.OpenEditorCmd(m.selectedPath)
}
// Toggle Help
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 {
m.focus = FocusDiff
} else {
m.focus = FocusTree
}
case "l", "]", "ctrl+l", "right":
m.focus = FocusDiff
case "h", "[", "ctrl+h", "left":
m.focus = FocusTree
// Editing
case "e", "enter":
if m.selectedPath != "" {
line := 0
if m.focus == FocusDiff {
line = git.CalculateFileLine(m.diffContent, m.diffCursor)
} else {
line = git.CalculateFileLine(m.diffContent, 0)
}
return m, git.OpenEditorCmd(m.selectedPath, line)
}
// Diff Cursor
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)
}
}
}
case "k", "up":
if m.focus == FocusDiff {
if m.diffCursor > 0 {
m.diffCursor--
if m.diffCursor < m.diffViewport.YOffset {
m.diffViewport.LineUp(1)
}
}
}
}
}
// Update Components
if m.focus == FocusTree {
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 {
m.selectedPath = item.FullPath
m.diffCursor = 0
m.diffViewport.GotoTop()
cmds = append(cmds, git.DiffCmd(TargetBranch, m.selectedPath))
}
}
}
switch msg := msg.(type) {
case git.DiffMsg:
m.diffContent = msg.Content
m.diffLines = strings.Split(msg.Content, "\n")
m.diffViewport.SetContent(msg.Content)
case git.EditorFinishedMsg:
if msg.Err != nil {
}
return m, git.DiffCmd(TargetBranch, m.selectedPath)
}
return m, tea.Batch(cmds...)
}
func (m *Model) updateSizes() {
reservedHeight := 1
if m.showHelp {
reservedHeight += 6
}
contentHeight := m.height - reservedHeight
if contentHeight < 1 {
contentHeight = 1
}
treeWidth := int(float64(m.width) * 0.20)
if treeWidth < 20 {
treeWidth = 20
}
m.fileTree.SetSize(treeWidth, contentHeight)
m.diffViewport.Width = m.width - treeWidth - 2
m.diffViewport.Height = contentHeight
}
func (m Model) View() string {
if m.width == 0 {
return "Loading..."
}
treeView := PaneStyle.Copy().
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]
if m.focus == FocusDiff && i == m.diffCursor {
line = SelectedItemStyle.Render(line)
} else {
line = " " + line
}
renderedDiff.WriteString(line + "\n")
}
diffView := DiffStyle.Copy().
Width(m.diffViewport.Width).
Height(m.diffViewport.Height).
Render(m.diffViewport.View())
Render(renderedDiff.String())
return lipgloss.JoinHorizontal(lipgloss.Top, treeView, diffView)
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))
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),
))
// Help Drawer
var finalView string
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("Tab Switch Panel"),
HelpTextStyle.Render("Ent/e Edit File"),
)
col4 := lipgloss.JoinVertical(lipgloss.Left,
HelpTextStyle.Render("q Quit"),
HelpTextStyle.Render("? Close Help"),
)
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)
}
return finalView
}
type listDelegate struct{}
@ -124,10 +306,9 @@ func (d listDelegate) Render(w io.Writer, m list.Model, index int, item list.Ite
if !ok {
return
}
str := i.Title()
if index == m.Index() {
fmt.Fprint(w, SelectedItemStyle.Render("│ "+str))
fmt.Fprint(w, SelectedItemStyle.Render(str))
} else {
fmt.Fprint(w, ItemStyle.Render(str))
}

View File

@ -3,23 +3,61 @@ package ui
import "github.com/charmbracelet/lipgloss"
var (
// -- THEME: Neutral & Clean --
ColorBorder = lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#383838"}
ColorSelected = lipgloss.AdaptiveColor{Light: "#1F1F1F", Dark: "#F8F8F2"}
ColorInactive = lipgloss.AdaptiveColor{Light: "#A8A8A8", Dark: "#626262"}
ColorAccent = lipgloss.AdaptiveColor{Light: "#00BCF0", Dark: "#00BCF0"} // Cyan
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).
MarginRight(1)
BorderForeground(ColorBorder)
DiffStyle = lipgloss.NewStyle().
Padding(0, 1)
FocusedPaneStyle = PaneStyle.Copy().
BorderForeground(ColorFocus)
ItemStyle = lipgloss.NewStyle().PaddingLeft(1)
DiffStyle = lipgloss.NewStyle().Padding(0, 0)
ItemStyle = lipgloss.NewStyle().PaddingLeft(2)
SelectedItemStyle = lipgloss.NewStyle().
PaddingLeft(0).
Foreground(ColorSelected).
Bold(true)
PaddingLeft(1).
Background(ColorCursorBg).
Foreground(ColorText).
Bold(true).
Width(1000)
// -- STATUS BAR STYLES --
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)
// -- 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)
)