Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ bonsai clean --stale 7
bonsai clean --force
```

Keys: `up/down` move, `space` toggle, `a` select all, `n` select none, `enter` confirm, `q` quit
Picker keys: `up/down` or `j/k` move, `space` toggles the highlighted row,
`a` selects all safe rows, `n` clears the selection, and `enter` opens a final
review screen. Confirm deletion there with `y` or return with `n`/`esc`.

### `bonsai prune`

Expand Down
6 changes: 5 additions & 1 deletion cmd/clean.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ func runClean(cmd *cobra.Command, args []string) error {
return nil
}

result, err := tui.Run("bonsai clean — select worktrees to delete", pickerItems)
result, err := tui.RunWithOptions(
"bonsai clean — select worktrees to delete",
pickerItems,
tui.PickerOptions{AllowProtected: force},
)
if err != nil {
return err
}
Expand Down
223 changes: 199 additions & 24 deletions internal/tui/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package tui

import (
"fmt"
"os"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"golang.org/x/term"
)

// Item represents a worktree entry in the picker.
Expand All @@ -24,25 +26,61 @@ type Result struct {
Items []Item
}

// PickerOptions controls which entries the picker allows users to select.
type PickerOptions struct {
AllowProtected bool
}

// Model is the bubbletea model for the interactive picker.
type Model struct {
title string
items []Item
cursor int
done bool
quit bool
title string
items []Item
cursor int
height int
reviewing bool
allowProtected bool
notice string
done bool
quit bool
}

// NewPicker creates a new picker model.
func NewPicker(title string, items []Item) Model {
return Model{title: title, items: items}
return NewPickerWithOptions(title, items, PickerOptions{})
}

// NewPickerWithOptions creates a picker with explicit selection behavior.
func NewPickerWithOptions(title string, items []Item, options PickerOptions) Model {
return Model{
title: title,
items: items,
height: 24,
allowProtected: options.AllowProtected,
}
}

func (m Model) Init() tea.Cmd { return nil }

func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.height = msg.Height
case tea.KeyMsg:
if m.reviewing {
switch msg.String() {
case "ctrl+c", "q":
m.quit = true
return m, tea.Quit
case "y", "enter":
m.done = true
return m, tea.Quit
case "n", "esc", "backspace":
m.reviewing = false
m.notice = ""
}
return m, nil
}

switch msg.String() {
case "ctrl+c", "q", "esc":
m.quit = true
Expand All @@ -51,23 +89,64 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.cursor > 0 {
m.cursor--
}
m.notice = ""
case "down", "j":
if m.cursor < len(m.items)-1 {
m.cursor++
}
m.notice = ""
case "home", "g":
m.cursor = 0
m.notice = ""
case "end", "G":
if len(m.items) > 0 {
m.cursor = len(m.items) - 1
}
m.notice = ""
case "pgup":
m.cursor = max(0, m.cursor-m.visibleItemCount())
m.notice = ""
case "pgdown":
if len(m.items) > 0 {
m.cursor = min(len(m.items)-1, m.cursor+m.visibleItemCount())
}
m.notice = ""
case " ", "x":
if len(m.items) == 0 {
break
}
if m.items[m.cursor].Protected && !m.allowProtected {
m.notice = "Protected rows are view-only. Rerun with --force to select one explicitly."
break
}
m.items[m.cursor].Selected = !m.items[m.cursor].Selected
m.notice = ""
case "a":
skipped := 0
for i := range m.items {
if m.items[i].Protected {
skipped++
continue
}
m.items[i].Selected = true
}
if skipped > 0 {
m.notice = fmt.Sprintf("Selected all safe rows; skipped %d protected row(s).", skipped)
} else {
m.notice = "Selected all rows."
}
case "n":
for i := range m.items {
m.items[i].Selected = false
}
m.notice = "Selection cleared."
case "enter":
m.done = true
return m, tea.Quit
if m.selectedCount() == 0 {
m.notice = "Nothing selected. Move with ↑/↓ and press Space to select a row."
break
}
m.reviewing = true
m.notice = ""
}
}
return m, nil
Expand All @@ -92,21 +171,33 @@ func (m Model) View() string {
if m.quit || m.done {
return ""
}
if m.reviewing {
return m.reviewView()
}

var b strings.Builder
b.WriteString(titleStyle.Render(m.title) + "\n")
b.WriteString(titleStyle.Render(m.title) + "\n\n")
hk := func(k string) string { return helpKeyStyle.Render(k) }
b.WriteString(helpStyle.Render(fmt.Sprintf(
"%s select/unselect · %s move · %s all · %s none · %s confirm · %s quit",
hk("space"), hk("↑/↓"), hk("a"), hk("n"), hk("enter"), hk("q"),
"%s move %s select/unselect %s review selection",
hk("↑/↓ or j/k"), hk("Space"), hk("Enter"),
)) + "\n")
b.WriteString(helpStyle.Render(fmt.Sprintf(
"%s all safe %s clear %s page %s quit",
hk("a"), hk("n"), hk("PgUp/PgDn"), hk("q"),
)) + "\n\n")

if len(m.items) == 0 {
b.WriteString(dimStyle.Render(" No candidates found.\n"))
return b.String()
}

for i, item := range m.items {
start, end := m.visibleRange()
if start > 0 {
fmt.Fprintf(&b, " %s\n", dimStyle.Render(fmt.Sprintf("↑ %d more", start)))
}
for i := start; i < end; i++ {
item := m.items[i]
focused := i == m.cursor

var box string
Expand All @@ -126,7 +217,7 @@ func (m Model) View() string {
bar := " "
rowStyle := normalStyle
if focused {
bar = focusBarStyle.Render(" ")
bar = focusBarStyle.Render(" ")
rowStyle = focusedStyle
} else if item.Selected {
rowStyle = selectedStyle
Expand All @@ -143,29 +234,113 @@ func (m Model) View() string {
fmt.Fprintf(&b, " %s\n", ds.Render(item.Desc))
}
}

// Summary line
count := 0
for _, it := range m.items {
if it.Selected {
count++
}
if end < len(m.items) {
fmt.Fprintf(&b, " %s\n", dimStyle.Render(fmt.Sprintf("↓ %d more", len(m.items)-end)))
}

b.WriteString("\n")
count := m.selectedCount()
if count == 0 {
b.WriteString(helpStyle.Render(" nothing selected"))
b.WriteString(helpStyle.Render(fmt.Sprintf(
" Row %d/%d · nothing selected — press Space to select the highlighted row",
m.cursor+1, len(m.items),
)))
} else {
b.WriteString(warnStyle.Render(fmt.Sprintf(" %d worktree(s) will be deleted", count)))
b.WriteString(selectedStyle.Render(fmt.Sprintf(
" Row %d/%d · %d selected — press Enter to review before deletion",
m.cursor+1, len(m.items), count,
)))
}
b.WriteString("\n")
if m.notice != "" {
b.WriteString(warnStyle.Render(" "+m.notice) + "\n")
}

return b.String()
}

func (m Model) reviewView() string {
var b strings.Builder
b.WriteString(titleStyle.Render("Review selected worktrees") + "\n\n")

selected := m.selectedItems()
const maxReviewRows = 10
for i, item := range selected {
if i == maxReviewRows {
fmt.Fprintf(&b, " %s\n", dimStyle.Render(fmt.Sprintf("… and %d more", len(selected)-maxReviewRows)))
break
}
warning := ""
if item.Protected {
warning = " " + warnStyle.Render("⚠ protected")
}
fmt.Fprintf(&b, " %s %s%s\n", checkStyle.Render("✓"), item.Label, warning)
}

b.WriteString("\n")
b.WriteString(warnStyle.Render(fmt.Sprintf(" Delete %d selected worktree(s)? This cannot be undone.", len(selected))) + "\n\n")
hk := func(k string) string { return helpKeyStyle.Render(k) }
b.WriteString(helpStyle.Render(fmt.Sprintf(
" %s delete %s back to selection %s cancel",
hk("y/Enter"), hk("n/Esc"), hk("q"),
)) + "\n")
return b.String()
}

func (m Model) selectedItems() []Item {
selected := make([]Item, 0, len(m.items))
for _, item := range m.items {
if item.Selected {
selected = append(selected, item)
}
}
return selected
}

func (m Model) selectedCount() int {
count := 0
for _, item := range m.items {
if item.Selected {
count++
}
}
return count
}

func (m Model) visibleItemCount() int {
// Reserve space for the title, two help rows, scroll indicators, summary,
// and notices. Each worktree normally consumes a label and description row.
count := (m.height - 10) / 2
if count < 1 {
return 1
}
return count
}

func (m Model) visibleRange() (int, int) {
count := min(m.visibleItemCount(), len(m.items))
start := m.cursor - count/2
if start < 0 {
start = 0
}
if start+count > len(m.items) {
start = len(m.items) - count
}
return start, start + count
}

// Run launches the TUI and returns the result.
func Run(title string, items []Item) (Result, error) {
m := NewPicker(title, items)
p := tea.NewProgram(m)
return RunWithOptions(title, items, PickerOptions{})
}

// RunWithOptions launches the TUI with explicit protected-row behavior.
func RunWithOptions(title string, items []Item, options PickerOptions) (Result, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return Result{}, fmt.Errorf("interactive picker requires a terminal; run this command directly in a terminal or use bonsai prune --dry-run")
}
m := NewPickerWithOptions(title, items, options)
p := tea.NewProgram(m, tea.WithAltScreen())
final, err := p.Run()
if err != nil {
return Result{}, err
Expand Down
Loading
Loading