Skip to content

Commit 73582f2

Browse files
committed
feat(ui): keep branch picker inside tui
1 parent 12c0a65 commit 73582f2

File tree

5 files changed

+341
-102
lines changed

5 files changed

+341
-102
lines changed

internal/ui/branch_picker.go

Lines changed: 214 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,258 @@
11
package ui
22

33
import (
4-
"bytes"
54
"errors"
6-
"fmt"
7-
"io"
8-
"os"
9-
"os/exec"
105
"strings"
116

7+
"github.com/charmbracelet/bubbles/textinput"
128
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/lipgloss"
1310

1411
"github.com/asheshgoplani/agent-deck/internal/git"
1512
"github.com/asheshgoplani/agent-deck/internal/session"
1613
)
1714

18-
var openBranchPicker = branchPickerCmd
15+
var loadBranchCandidates = branchCandidatesForPath
1916

17+
// branchPickerResultMsg is kept for compatibility with existing dialog message handling.
2018
type branchPickerResultMsg struct {
2119
branch string
2220
canceled bool
2321
err error
2422
}
2523

26-
func branchPickerCmd(projectPath string) tea.Cmd {
27-
selected := ""
28-
canceled := false
29-
30-
cmd := &branchPickerExecCmd{
31-
projectPath: projectPath,
32-
selected: &selected,
33-
canceled: &canceled,
34-
}
35-
36-
return tea.Exec(cmd, func(err error) tea.Msg {
37-
return branchPickerResultMsg{
38-
branch: selected,
39-
canceled: canceled,
40-
err: err,
41-
}
42-
})
24+
// BranchPickerDialog is an in-TUI branch picker with inline filtering.
25+
type BranchPickerDialog struct {
26+
visible bool
27+
width int
28+
height int
29+
input textinput.Model
30+
allBranches []string
31+
branches []string
32+
cursor int
33+
offset int
4334
}
4435

45-
type branchPickerExecCmd struct {
46-
projectPath string
47-
selected *string
48-
canceled *bool
49-
stdin io.Reader
50-
stdout io.Writer
51-
stderr io.Writer
52-
}
36+
func NewBranchPickerDialog() *BranchPickerDialog {
37+
input := textinput.New()
38+
input.Placeholder = "Search branches..."
39+
input.CharLimit = 200
40+
input.Width = 40
5341

54-
func (c *branchPickerExecCmd) Run() error {
55-
if _, err := exec.LookPath("fzf"); err != nil {
56-
return errors.New("fzf not found; install fzf or type branch manually")
42+
return &BranchPickerDialog{
43+
input: input,
5744
}
45+
}
5846

59-
projectPath := session.ExpandPath(strings.Trim(strings.TrimSpace(c.projectPath), "'\""))
47+
func branchCandidatesForPath(projectPath string) ([]string, error) {
48+
projectPath = session.ExpandPath(strings.Trim(strings.TrimSpace(projectPath), "'\""))
6049
if projectPath == "" {
61-
return errors.New("project path is empty")
50+
return nil, errors.New("project path is empty")
6251
}
6352

6453
repoRoot, err := git.GetWorktreeBaseRoot(projectPath)
6554
if err != nil {
66-
return errors.New("path is not a git repository")
55+
return nil, errors.New("path is not a git repository")
6756
}
6857

6958
branches, err := git.ListBranchCandidates(repoRoot)
7059
if err != nil {
71-
return err
60+
return nil, err
7261
}
7362
if len(branches) == 0 {
74-
return errors.New("no branches found in repository")
63+
return nil, errors.New("no branches found in repository")
7564
}
7665

77-
var output bytes.Buffer
78-
fzf := exec.Command("fzf", "--prompt", "Branch> ", "--height", "40%", "--reverse")
79-
fzf.Stdin = strings.NewReader(strings.Join(branches, "\n") + "\n")
80-
fzf.Stdout = &output
81-
if c.stderr != nil {
82-
fzf.Stderr = c.stderr
83-
} else {
84-
fzf.Stderr = os.Stderr
85-
}
86-
87-
if err := fzf.Run(); err != nil {
88-
var exitErr *exec.ExitError
89-
if errors.As(err, &exitErr) {
90-
switch exitErr.ExitCode() {
91-
case 1, 130:
92-
if c.canceled != nil {
93-
*c.canceled = true
94-
}
95-
return nil
96-
}
97-
}
98-
return fmt.Errorf("fzf failed: %w", err)
66+
return branches, nil
67+
}
68+
69+
func (d *BranchPickerDialog) SetSize(width, height int) {
70+
d.width = width
71+
d.height = height
72+
}
73+
74+
func (d *BranchPickerDialog) IsVisible() bool {
75+
return d.visible
76+
}
77+
78+
func (d *BranchPickerDialog) Hide() {
79+
d.visible = false
80+
d.input.Blur()
81+
d.input.SetValue("")
82+
d.allBranches = nil
83+
d.branches = nil
84+
d.cursor = 0
85+
d.offset = 0
86+
}
87+
88+
func (d *BranchPickerDialog) Show(projectPath string) error {
89+
branches, err := loadBranchCandidates(projectPath)
90+
if err != nil {
91+
return err
9992
}
10093

101-
selected := strings.TrimSpace(output.String())
102-
if selected == "" {
103-
if c.canceled != nil {
104-
*c.canceled = true
94+
d.visible = true
95+
d.allBranches = branches
96+
d.cursor = 0
97+
d.offset = 0
98+
d.input.SetValue("")
99+
d.input.Focus()
100+
d.filter()
101+
return nil
102+
}
103+
104+
func (d *BranchPickerDialog) filter() {
105+
query := strings.ToLower(strings.TrimSpace(d.input.Value()))
106+
d.branches = d.branches[:0]
107+
for _, branch := range d.allBranches {
108+
if query == "" || strings.Contains(strings.ToLower(branch), query) {
109+
d.branches = append(d.branches, branch)
105110
}
106-
return nil
107111
}
108-
if c.selected != nil {
109-
*c.selected = selected
112+
if len(d.branches) == 0 {
113+
d.cursor = 0
114+
d.offset = 0
115+
return
110116
}
111-
return nil
117+
if d.cursor >= len(d.branches) {
118+
d.cursor = len(d.branches) - 1
119+
}
120+
if d.cursor < 0 {
121+
d.cursor = 0
122+
}
123+
d.ensureCursorVisible()
112124
}
113125

114-
func (c *branchPickerExecCmd) SetStdin(r io.Reader) { c.stdin = r }
115-
func (c *branchPickerExecCmd) SetStdout(w io.Writer) { c.stdout = w }
116-
func (c *branchPickerExecCmd) SetStderr(w io.Writer) { c.stderr = w }
126+
func (d *BranchPickerDialog) maxVisibleRows() int {
127+
if d.height <= 0 {
128+
return 6
129+
}
130+
rows := d.height / 4
131+
if rows < 4 {
132+
rows = 4
133+
}
134+
if rows > 8 {
135+
rows = 8
136+
}
137+
return rows
138+
}
139+
140+
func (d *BranchPickerDialog) ensureCursorVisible() {
141+
rows := d.maxVisibleRows()
142+
if d.cursor < d.offset {
143+
d.offset = d.cursor
144+
}
145+
if d.cursor >= d.offset+rows {
146+
d.offset = d.cursor - rows + 1
147+
}
148+
if d.offset < 0 {
149+
d.offset = 0
150+
}
151+
maxOffset := len(d.branches) - rows
152+
if maxOffset < 0 {
153+
maxOffset = 0
154+
}
155+
if d.offset > maxOffset {
156+
d.offset = maxOffset
157+
}
158+
}
159+
160+
// Update returns the selected branch and whether the key was consumed.
161+
func (d *BranchPickerDialog) Update(msg tea.KeyMsg) (string, bool) {
162+
if !d.visible {
163+
return "", false
164+
}
165+
166+
switch msg.String() {
167+
case "esc":
168+
d.Hide()
169+
return "", true
170+
case "enter":
171+
if len(d.branches) == 0 {
172+
return "", true
173+
}
174+
selected := d.branches[d.cursor]
175+
d.Hide()
176+
return selected, true
177+
case "up", "ctrl+k", "ctrl+p":
178+
if len(d.branches) > 0 && d.cursor > 0 {
179+
d.cursor--
180+
d.ensureCursorVisible()
181+
}
182+
return "", true
183+
case "down", "ctrl+j", "ctrl+n":
184+
if len(d.branches) > 0 && d.cursor < len(d.branches)-1 {
185+
d.cursor++
186+
d.ensureCursorVisible()
187+
}
188+
return "", true
189+
}
190+
191+
var cmd tea.Cmd
192+
d.input, cmd = d.input.Update(msg)
193+
_ = cmd
194+
d.cursor = 0
195+
d.offset = 0
196+
d.filter()
197+
return "", true
198+
}
199+
200+
func (d *BranchPickerDialog) View() string {
201+
if !d.visible {
202+
return ""
203+
}
204+
205+
titleStyle := lipgloss.NewStyle().
206+
Foreground(ColorAccent).
207+
Bold(true)
208+
labelStyle := lipgloss.NewStyle().
209+
Foreground(ColorComment)
210+
selectedStyle := lipgloss.NewStyle().
211+
Foreground(ColorAccent).
212+
Bold(true)
213+
itemStyle := lipgloss.NewStyle().
214+
Foreground(ColorText)
215+
216+
var body strings.Builder
217+
body.WriteString(titleStyle.Render("Branch Search"))
218+
body.WriteString("\n")
219+
body.WriteString(" ")
220+
body.WriteString(d.input.View())
221+
body.WriteString("\n")
222+
223+
if len(d.branches) == 0 {
224+
body.WriteString("\n")
225+
body.WriteString(labelStyle.Render(" No matching branches"))
226+
} else {
227+
body.WriteString("\n")
228+
rows := d.maxVisibleRows()
229+
end := d.offset + rows
230+
if end > len(d.branches) {
231+
end = len(d.branches)
232+
}
233+
for i := d.offset; i < end; i++ {
234+
prefix := " "
235+
style := itemStyle
236+
if i == d.cursor {
237+
prefix = "▶ "
238+
style = selectedStyle
239+
}
240+
body.WriteString(style.Render(prefix + d.branches[i]))
241+
body.WriteString("\n")
242+
}
243+
if end < len(d.branches) {
244+
body.WriteString(labelStyle.Render(" …"))
245+
} else {
246+
body.WriteString(labelStyle.Render(" "))
247+
}
248+
}
249+
250+
help := labelStyle.Render("Enter select | Esc close | Up/Down navigate | Type filter")
251+
return lipgloss.NewStyle().
252+
Border(lipgloss.RoundedBorder()).
253+
BorderForeground(ColorAccent).
254+
Padding(0, 1).
255+
MarginTop(1).
256+
Width(48).
257+
Render(body.String() + "\n" + help)
258+
}

0 commit comments

Comments
 (0)