diff --git a/internal/git/client.go b/internal/git/client.go index cc00c8a..43eff48 100644 --- a/internal/git/client.go +++ b/internal/git/client.go @@ -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 } diff --git a/internal/ui/model.go b/internal/ui/model.go index 61fd093..1fc05e1 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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)) } diff --git a/internal/ui/styles.go b/internal/ui/styles.go index fb5faf6..8238505 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -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) )