From e397da27334224b7791ed4110b2112881b120812 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 6 Sep 2026 18:34:05 +0100 Subject: [PATCH] feat(tui): add repository picker - add the tui command using Bubble Tea and Lip Gloss - preselect eligible repositories and disable selection for blocked repositories - add keyboard controls for navigation, selection, refresh, sync, and cancellation - require confirmation before sync and show the branch-deletion warning - show dirty paths, block reasons, and hub output for the focused repository - add TUI model tests and set the embedded version to 0.2.0 --- README.md | 14 +- go.mod | 27 +++- go.sum | 43 ++++++ main.go | 23 ++- tui.go | 395 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tui_test.go | 73 ++++++++++ 6 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 go.sum create mode 100644 tui.go create mode 100644 tui_test.go diff --git a/README.md b/README.md index 22e6162..90bc594 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `stack-sync` safely runs [`hub sync`](https://hub.github.com/hub-sync.1.html) across every Git repository in a workspace. It is designed for directory trees such as JezzWTF where the root may not be a repository and a parent repository may contain deliberately untracked, nested repositories. -The interface is deliberately a CLI rather than a full-screen TUI: every run produces a reviewable plan, it works over SSH and in automation, and its JSON output can drive a future TUI or GUI without putting safety logic in the presentation layer. +It provides both a full-screen [Bubble Tea](https://github.com/charmbracelet/bubbletea) control deck for interactive work and a conventional CLI/JSON interface for SSH sessions and automation. Both interfaces use the same discovery and safety engine. ## Safety model @@ -32,6 +32,18 @@ install -Dm755 stack-sync ~/.local/bin/stack-sync ## Usage +### Interactive TUI + +Launch the control deck for the workspace: + +```bash +stack-sync tui --root ~/Coding/jwtf +``` + +Every eligible repository starts selected. Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the destructive-branch warning, then `y` to begin. Blocked repositories cannot be selected and show their first dirty paths in the detail panel. After a run, focusing a repository shows its latest `hub` output. + +### CLI and automation + Scan the current workspace. This is read-only and is the default command: ```bash diff --git a/go.mod b/go.mod index 0a3cfbf..fb26a9f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,28 @@ module jezz.wtf/stack-sync -go 1.24 +go 1.24.0 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4789639 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go index d8f548c..ec7519f 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ import ( "time" ) -const version = "0.1.1" +const version = "0.2.0" var defaultSkippedDirs = map[string]bool{ ".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true, @@ -82,7 +82,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { fmt.Fprintf(stdout, "stack-sync %s\n", version) return 0 } - if command != "scan" && command != "sync" { + if command != "scan" && command != "sync" && command != "tui" { fmt.Fprintf(stderr, "unknown command %q\n\n", command) usage(stderr) return 2 @@ -113,6 +113,13 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return 1 } repos := inspectAll(root, paths, opts.jobs) + if command == "tui" { + if err := runTUI(root, repos, opts); err != nil { + fmt.Fprintf(stderr, "run TUI: %v\n", err) + return 1 + } + return 0 + } if command == "scan" { if opts.json { @@ -184,6 +191,8 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error if command == "sync" { fs.BoolVar(&opts.yes, "yes", false, "apply without an interactive confirmation") fs.BoolVar(&opts.strict, "strict", false, "abort the entire run if any repository is blocked") + } + if command == "sync" || command == "tui" { fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each hub sync") } fs.Usage = func() { usage(stderr) } @@ -214,11 +223,13 @@ func usage(w io.Writer) { Usage: stack-sync [scan] [options] stack-sync sync [options] + stack-sync tui [options] stack-sync version Commands: scan Discover repositories and report whether each is safe to sync (default) sync Scan, confirm, re-check safety, then run hub sync in eligible repositories + tui Interactively review, select, and sync repositories Safety: Repositories with edits, untracked files, detached HEADs, no remotes, or an @@ -412,6 +423,10 @@ func gitBytes(path string, args ...string) ([]byte, error) { } func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult { + return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet) +} + +func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult { type item struct { index int repo repo @@ -420,8 +435,8 @@ func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout results := make([]syncResult, len(repos)) var wg sync.WaitGroup var outputMu sync.Mutex - allPaths := make([]string, 0, len(repos)) - for _, r := range repos { + allPaths := make([]string, 0, len(workspaceRepos)) + for _, r := range workspaceRepos { allPaths = append(allPaths, r.Path) } diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..88b038b --- /dev/null +++ b/tui.go @@ -0,0 +1,395 @@ +package main + +import ( + "fmt" + "io" + "os/exec" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + amber = lipgloss.Color("214") + green = lipgloss.Color("42") + red = lipgloss.Color("196") + muted = lipgloss.Color("241") + panel = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("238")).Padding(0, 1) + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(amber) + readyStyle = lipgloss.NewStyle().Foreground(green) + blockStyle = lipgloss.NewStyle().Foreground(red) + mutedStyle = lipgloss.NewStyle().Foreground(muted) +) + +type tuiMode int + +const ( + modeReady tuiMode = iota + modeConfirm + modeSyncing +) + +type tickMsg time.Time +type scanDoneMsg struct { + repos []repo + err error +} +type syncDoneMsg struct { + results []syncResult + repos []repo + err error +} + +type tuiModel struct { + root string + repos []repo + selected map[string]bool + opts options + cursor int + offset int + width int + height int + mode tuiMode + frame int + status string + lastResult []syncResult +} + +func runTUI(root string, repos []repo, opts options) error { + m := newTUIModel(root, repos, opts) + program := tea.NewProgram(m, tea.WithAltScreen()) + _, err := program.Run() + return err +} + +func newTUIModel(root string, repos []repo, opts options) tuiModel { + selected := make(map[string]bool) + for _, r := range repos { + selected[r.Path] = r.Eligible + } + return tuiModel{root: root, repos: repos, selected: selected, opts: opts, mode: modeReady} +} + +func (m tuiModel) Init() tea.Cmd { return nil } + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.clampViewport() + return m, nil + case tickMsg: + if m.mode == modeSyncing { + m.frame++ + return m, tickCmd() + } + return m, nil + case scanDoneMsg: + if msg.err != nil { + m.status = "Refresh failed: " + msg.err.Error() + return m, nil + } + m.repos = msg.repos + m.reconcileSelection() + m.status = "Workspace refreshed" + m.clampViewport() + return m, nil + case syncDoneMsg: + m.mode = modeReady + m.lastResult = msg.results + if msg.err != nil { + m.status = "Sync failed: " + msg.err.Error() + return m, nil + } + m.repos = msg.repos + m.reconcileSelection() + var ok, failed, skipped int + for _, result := range msg.results { + switch { + case result.Success: + ok++ + case result.Skipped: + skipped++ + default: + failed++ + } + } + m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped) + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) { + value := key.String() + if m.mode == modeSyncing { + if value == "q" || value == "ctrl+c" { + m.status = "Sync is running; wait for it to finish before quitting" + } + return m, nil + } + if m.mode == modeConfirm { + switch value { + case "y", "Y": + selected := m.selectedRepos() + m.mode = modeSyncing + m.status = fmt.Sprintf("Syncing %d repositories", len(selected)) + return m, tea.Batch(m.syncCmd(selected), tickCmd()) + case "n", "N", "esc", "q": + m.mode = modeReady + m.status = "Sync cancelled; nothing changed" + } + return m, nil + } + + switch value { + case "q", "ctrl+c": + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor+1 < len(m.repos) { + m.cursor++ + } + case " ": + if len(m.repos) > 0 && m.repos[m.cursor].Eligible { + path := m.repos[m.cursor].Path + m.selected[path] = !m.selected[path] + } + case "a": + for _, r := range m.repos { + if r.Eligible { + m.selected[r.Path] = true + } + } + m.status = "Selected every eligible repository" + case "n": + for path := range m.selected { + m.selected[path] = false + } + m.status = "Selection cleared" + case "r": + m.status = "Refreshing workspace…" + return m, m.scanCmd() + case "s", "enter": + count := len(m.selectedRepos()) + if count == 0 { + m.status = "Select at least one eligible repository" + } else if _, err := exec.LookPath("hub"); err != nil { + m.status = "hub is required for sync but was not found on PATH" + } else { + m.mode = modeConfirm + } + } + m.clampViewport() + return m, nil +} + +func (m tuiModel) View() string { + width := m.width + if width <= 0 { + width = 100 + } + contentWidth := max(40, width-6) + ready, blocked, selected := eligibleCount(m.repos), blockedCount(m.repos), len(m.selectedRepos()) + + var b strings.Builder + b.WriteString(titleStyle.Render("STACK SYNC")) + b.WriteString(mutedStyle.Render(" workspace control deck")) + b.WriteString("\n") + b.WriteString(mutedStyle.Render(truncateMiddle(m.root, contentWidth))) + b.WriteString("\n\n") + b.WriteString(readyStyle.Render(fmt.Sprintf("● %d ready", ready))) + b.WriteString(" ") + b.WriteString(blockStyle.Render(fmt.Sprintf("● %d blocked", blocked))) + b.WriteString(" ") + b.WriteString(titleStyle.Render(fmt.Sprintf("◆ %d selected", selected))) + b.WriteString("\n\n") + + visible := m.visibleRows() + nameWidth := max(12, min(42, contentWidth-38)) + b.WriteString(mutedStyle.Render(fmt.Sprintf(" %-3s %-*s %-16s %s", "", nameWidth, "REPOSITORY", "BRANCH", "STATE"))) + b.WriteString("\n") + for i := visible.start; i < visible.end; i++ { + r := m.repos[i] + pointer := " " + if i == m.cursor { + pointer = "›" + } + check := "[ ]" + if !r.Eligible { + check = "[–]" + } else if m.selected[r.Path] { + check = "[×]" + } + state := readyStyle.Render("ready") + if !r.Eligible { + state = blockStyle.Render("blocked") + } + line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state) + if i == m.cursor { + line = lipgloss.NewStyle().Bold(true).Foreground(amber).Render(line) + } + b.WriteString(line) + b.WriteString("\n") + } + if visible.start > 0 || visible.end < len(m.repos) { + b.WriteString(mutedStyle.Render(fmt.Sprintf(" showing %d–%d of %d", visible.start+1, visible.end, len(m.repos)))) + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(m.detailView(contentWidth)) + b.WriteString("\n") + + switch m.mode { + case modeConfirm: + b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Run hub sync in %d selected repositories?", selected))) + b.WriteString("\n") + b.WriteString(mutedStyle.Render("hub may delete local branches it considers merged. y confirm • n/esc cancel")) + case modeSyncing: + spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4] + b.WriteString(titleStyle.Render(fmt.Sprintf("%s Syncing %d repositories…", spinner, selected))) + b.WriteString("\n") + b.WriteString(mutedStyle.Render("Safety is rechecked immediately before each repository. Please wait.")) + default: + if m.status != "" { + b.WriteString(m.status) + b.WriteString("\n") + } + b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • q quit")) + } + return panel.Width(contentWidth).Render(b.String()) +} + +type rowRange struct{ start, end int } + +func (m tuiModel) visibleRows() rowRange { + available := m.height - 15 + if available < 4 { + available = 4 + } + end := min(len(m.repos), m.offset+available) + return rowRange{start: min(m.offset, end), end: end} +} + +func (m *tuiModel) clampViewport() { + if len(m.repos) == 0 { + m.cursor, m.offset = 0, 0 + return + } + m.cursor = max(0, min(m.cursor, len(m.repos)-1)) + available := max(4, m.height-15) + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+available { + m.offset = m.cursor - available + 1 + } +} + +func (m tuiModel) detailView(width int) string { + if len(m.repos) == 0 { + return mutedStyle.Render("No repositories found") + } + r := m.repos[m.cursor] + var lines []string + if r.Eligible { + lines = append(lines, readyStyle.Render("READY")+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch)) + } else { + lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason) + } + for i, item := range r.Dirty { + if i == 3 { + lines = append(lines, mutedStyle.Render(fmt.Sprintf("… and %d more changes", len(r.Dirty)-3))) + break + } + lines = append(lines, " "+truncateMiddle(item, width-4)) + } + if len(r.NestedRepoEntries) > 0 { + lines = append(lines, mutedStyle.Render(fmt.Sprintf("%d nested repository path(s) safely excluded from parent dirtiness", len(r.NestedRepoEntries)))) + } + for _, result := range m.lastResult { + if result.Path != r.RelativePath || result.Message == "" { + continue + } + state := readyStyle.Render("LAST SYNC") + if !result.Success { + state = blockStyle.Render("LAST SYNC FAILED") + } + lines = append(lines, state+mutedStyle.Render(" "+result.Duration)) + output := strings.Split(strings.ReplaceAll(result.Message, "\r", "\n"), "\n") + shown := 0 + for _, outputLine := range output { + if strings.TrimSpace(outputLine) == "" { + continue + } + if shown == 3 { + lines = append(lines, mutedStyle.Render(" … more output available in the CLI/JSON interface")) + break + } + lines = append(lines, " "+truncateMiddle(strings.TrimSpace(outputLine), width-4)) + shown++ + } + break + } + return strings.Join(lines, "\n") +} + +func (m tuiModel) selectedRepos() []repo { + selected := make([]repo, 0) + for _, r := range m.repos { + if r.Eligible && m.selected[r.Path] { + selected = append(selected, r) + } + } + return selected +} + +func (m *tuiModel) reconcileSelection() { + next := make(map[string]bool) + for _, r := range m.repos { + next[r.Path] = r.Eligible && m.selected[r.Path] + } + m.selected = next +} + +func (m tuiModel) scanCmd() tea.Cmd { + return func() tea.Msg { + paths, err := discover(m.root, m.opts.exclusions) + if err != nil { + return scanDoneMsg{err: err} + } + return scanDoneMsg{repos: inspectAll(m.root, paths, m.opts.jobs)} + } +} + +func (m tuiModel) syncCmd(selected []repo) tea.Cmd { + return func() tea.Msg { + results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true) + paths, err := discover(m.root, m.opts.exclusions) + if err != nil { + return syncDoneMsg{results: results, err: err} + } + return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)} + } +} + +func tickCmd() tea.Cmd { + return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func truncateMiddle(value string, width int) string { + if width < 2 || len(value) <= width { + return value + } + left := (width - 1) / 2 + right := width - left - 1 + return value[:left] + "…" + value[len(value)-right:] +} diff --git a/tui_test.go b/tui_test.go new file mode 100644 index 0000000..fceb227 --- /dev/null +++ b/tui_test.go @@ -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) + } +}