feat(tui): add interactive repository selection

Introduce a Bubble Tea control deck with eligible repositories preselected,
keyboard navigation, responsive paging, and detailed explanations for blocked
worktrees.

Require confirmation before synchronization, allow refreshing the workspace,
and retain each repository's latest hub output without changing the existing
CLI and JSON workflows.
This commit is contained in:
2026-09-06 18:34:05 +01:00
parent 295d694d78
commit 9bcaab4dc0
6 changed files with 569 additions and 6 deletions
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func testTUIRepos() []repo {
return []repo{
{Path: "/workspace/clean", RelativePath: "clean", Branch: "main", Eligible: true},
{Path: "/workspace/dirty", RelativePath: "dirty", Branch: "develop", BlockReason: "dirty worktree (1 changes)", Dirty: []string{" M file.txt"}},
}
}
func TestTUIOnlySelectsEligibleRepositories(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
if got := len(m.selectedRepos()); got != 1 {
t.Fatalf("selected repositories = %d, want 1", got)
}
m.cursor = 1
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(tuiModel)
if got := len(m.selectedRepos()); got != 1 {
t.Fatalf("blocked repository became selectable; selected = %d", got)
}
}
func TestTUIRequiresExplicitConfirmation(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(tuiModel)
if m.mode != modeConfirm || cmd != nil {
t.Fatalf("sync should enter confirmation without starting command: mode=%v cmd=%v", m.mode, cmd)
}
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
m = updated.(tuiModel)
if m.mode != modeReady || cmd != nil || !strings.Contains(m.status, "cancelled") {
t.Fatalf("cancel should return to ready state: %+v", m)
}
}
func TestTUIViewExplainsBlockedRepository(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height, m.cursor = 100, 30, 1
view := m.View()
for _, want := range []string{"STACK SYNC", "dirty worktree", "file.txt", "1 blocked"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q:\n%s", want, view)
}
}
}
func TestTUIViewShowsLastHubOutputForFocusedRepository(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m.lastResult = []syncResult{{Path: "clean", Success: true, Message: "Updated branch main", Duration: "120ms"}}
view := m.View()
for _, want := range []string{"LAST SYNC", "120ms", "Updated branch main"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q:\n%s", want, view)
}
}
}
func TestTruncateMiddle(t *testing.T) {
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
t.Fatalf("truncateMiddle() = %q", got)
}
}