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:
@@ -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.
|
`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
|
## Safety model
|
||||||
|
|
||||||
@@ -32,6 +32,18 @@ install -Dm755 stack-sync ~/.local/bin/stack-sync
|
|||||||
|
|
||||||
## Usage
|
## 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:
|
Scan the current workspace. This is read-only and is the default command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,3 +1,28 @@
|
|||||||
module jezz.wtf/stack-sync
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.1.1"
|
const version = "0.2.0"
|
||||||
|
|
||||||
var defaultSkippedDirs = map[string]bool{
|
var defaultSkippedDirs = map[string]bool{
|
||||||
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
|
".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)
|
fmt.Fprintf(stdout, "stack-sync %s\n", version)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if command != "scan" && command != "sync" {
|
if command != "scan" && command != "sync" && command != "tui" {
|
||||||
fmt.Fprintf(stderr, "unknown command %q\n\n", command)
|
fmt.Fprintf(stderr, "unknown command %q\n\n", command)
|
||||||
usage(stderr)
|
usage(stderr)
|
||||||
return 2
|
return 2
|
||||||
@@ -113,6 +113,13 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
repos := inspectAll(root, paths, opts.jobs)
|
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 command == "scan" {
|
||||||
if opts.json {
|
if opts.json {
|
||||||
@@ -184,6 +191,8 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
|
|||||||
if command == "sync" {
|
if command == "sync" {
|
||||||
fs.BoolVar(&opts.yes, "yes", false, "apply without an interactive confirmation")
|
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")
|
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.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each hub sync")
|
||||||
}
|
}
|
||||||
fs.Usage = func() { usage(stderr) }
|
fs.Usage = func() { usage(stderr) }
|
||||||
@@ -214,11 +223,13 @@ func usage(w io.Writer) {
|
|||||||
Usage:
|
Usage:
|
||||||
stack-sync [scan] [options]
|
stack-sync [scan] [options]
|
||||||
stack-sync sync [options]
|
stack-sync sync [options]
|
||||||
|
stack-sync tui [options]
|
||||||
stack-sync version
|
stack-sync version
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
scan Discover repositories and report whether each is safe to sync (default)
|
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
|
sync Scan, confirm, re-check safety, then run hub sync in eligible repositories
|
||||||
|
tui Interactively review, select, and sync repositories
|
||||||
|
|
||||||
Safety:
|
Safety:
|
||||||
Repositories with edits, untracked files, detached HEADs, no remotes, or an
|
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 {
|
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 {
|
type item struct {
|
||||||
index int
|
index int
|
||||||
repo repo
|
repo repo
|
||||||
@@ -420,8 +435,8 @@ func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout
|
|||||||
results := make([]syncResult, len(repos))
|
results := make([]syncResult, len(repos))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var outputMu sync.Mutex
|
var outputMu sync.Mutex
|
||||||
allPaths := make([]string, 0, len(repos))
|
allPaths := make([]string, 0, len(workspaceRepos))
|
||||||
for _, r := range repos {
|
for _, r := range workspaceRepos {
|
||||||
allPaths = append(allPaths, r.Path)
|
allPaths = append(allPaths, r.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
}
|
||||||
+73
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user