From fda3c95211eb8b8d2f8f232a2eb8fec47836548a Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 16:06:30 -0400 Subject: [PATCH 1/3] Add an interactive stack picker for checkout Running `gh stack checkout` with no argument previously showed a plain text prompt of locally tracked stacks only. It could not surface stacks that exist only on the remote, gave no sense of a stack's state, and listed fully merged stacks that can no longer be added to. Replace it with an interactive picker (new package internal/tui/checkoutview) that lists every stack available to you, reconciling the local stack file with the Stacks REST API: - Merges local and remote stacks, matched by stack id/number, and labels each Local (present locally, even if also tracked on the remote) or Remote (only on GitHub). Fully merged stacks are filtered out. - Shows compact columns: stack number, first...last branch, base branch, a muted status bar summarizing merged/open/closed/unpushed PRs, type, and relative created time. - Offers All / Local / Remote tabs and `/` type-to-filter search. The picker renders inline rather than taking over the screen: it shows up to ten rows with a scroll indicator, shrinks to fit short terminals, and clears itself on exit. Selecting a locally available stack checks out its top unmerged branch; selecting a remote-only stack clones it down through the existing checkout-by-number import flow. When the Stacks API is unavailable (stacks not enabled, no auth, or a network error), the picker degrades gracefully to a local-only list. Also update the README, overview, and CLI reference for the new behavior. --- README.md | 15 +- cmd/checkout.go | 132 +++- cmd/checkout_picker_test.go | 219 +++++++ .../src/content/docs/introduction/overview.md | 2 +- docs/src/content/docs/reference/cli.md | 4 +- internal/tui/checkoutview/data.go | 356 +++++++++++ internal/tui/checkoutview/data_test.go | 306 +++++++++ internal/tui/checkoutview/model.go | 598 ++++++++++++++++++ internal/tui/checkoutview/model_test.go | 292 +++++++++ internal/tui/checkoutview/styles.go | 187 ++++++ 10 files changed, 2071 insertions(+), 40 deletions(-) create mode 100644 cmd/checkout_picker_test.go create mode 100644 internal/tui/checkoutview/data.go create mode 100644 internal/tui/checkoutview/data_test.go create mode 100644 internal/tui/checkoutview/model.go create mode 100644 internal/tui/checkoutview/model_test.go create mode 100644 internal/tui/checkoutview/styles.go diff --git a/README.md b/README.md index 6c634eb..ad1ef62 100644 --- a/README.md +++ b/README.md @@ -165,21 +165,26 @@ gh stack add -m "Refactor utils" cleanup-layer ### `gh stack checkout` -Check out a stack from a pull request number, URL, or branch name. +Check out a stack by its stack number, a pull request number, a PR URL, or a branch name. ``` -gh stack checkout [ | | ] +gh stack checkout [ | | | ] ``` -When a PR number or URL is provided (e.g. `123` or `https://github.com/owner/repo/pull/123`), the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. +A bare number is interpreted first as a stack or PR number (repo-scoped identifiers shown in the GitHub UI). If nothing matches the number, it is tried as a branch name. + +When a remote stack is referenced, the command fetches the stack on GitHub, pulls the branches, and sets up the stack locally. If the stack already exists locally and matches, it switches to the branch. If the local and remote stacks have different compositions, you'll be prompted to resolve the conflict. When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. +When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. **Examples:** ```sh +# Check out a stack by its stack number +gh stack checkout 7 + # Check out a stack by PR number gh stack checkout 42 @@ -189,7 +194,7 @@ gh stack checkout https://github.com/owner/repo/pull/42 # Check out a stack by branch name (local only) gh stack checkout feature-auth -# Interactive — select from locally tracked stacks +# Interactive — pick from all available stacks (local and remote) gh stack checkout ``` diff --git a/cmd/checkout.go b/cmd/checkout.go index 5fb20c0..e1067e1 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -6,12 +6,14 @@ import ( "strconv" "strings" + tea "github.com/charmbracelet/bubbletea" "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/prompter" "github.com/github/gh-stack/internal/config" "github.com/github/gh-stack/internal/git" "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/checkoutview" "github.com/spf13/cobra" ) @@ -42,8 +44,10 @@ it simply switches to the branch. When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments, shows a menu of all locally available -stacks to choose from.`, +When run without arguments, opens an interactive picker listing every +stack available to you — both the stacks tracked locally and the stacks +that exist only on GitHub — so you can search, filter, and check one out. +Fully merged stacks are omitted.`, Example: ` # Check out a stack by its stack number $ gh stack checkout 7 @@ -56,7 +60,7 @@ stacks to choose from.`, # Check out a stack by branch name $ gh stack checkout feat/api-routes - # Show a menu of all locally tracked stacks + # Open the interactive picker of all available stacks (local and remote) $ gh stack checkout`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -91,18 +95,21 @@ func runCheckout(cfg *config.Config, opts *checkoutOptions) error { var targetBranch string if opts.target == "" { - // Interactive picker mode - s, err = interactiveStackPicker(cfg, sf) + // Interactive picker mode (local + remote stacks). + s, targetBranch, err = interactiveCheckout(cfg, sf, gitDir) if err != nil { - if !errors.Is(err, errInterrupt) { - cfg.Errorf("%s", err) + var exitErr *ExitError + if errors.As(err, &exitErr) { + // The callee already printed a message and chose an exit code. + return err } + cfg.Errorf("%s", err) return ErrSilent } if s == nil { + // No stacks available, or the user cancelled. return nil } - targetBranch = s.Branches[len(s.Branches)-1].Branch } else if prNumber, ok := parsePRURL(opts.target); ok { // Target is a PR URL — extract number and resolve like a numeric target s, targetBranch, err = resolveNumericTarget(cfg, sf, gitDir, prNumber, opts.target) @@ -625,40 +632,101 @@ func syncRemotePRState(s *stack.Stack, prs []*github.PullRequest) { } } -// interactiveStackPicker shows a menu of all locally tracked stacks and returns -// the one the user selects. Returns nil, nil if the user has no stacks. -func interactiveStackPicker(cfg *config.Config, sf *stack.StackFile) (*stack.Stack, error) { +// interactiveCheckout opens the interactive stack picker, which lists every +// stack available to the user (locally tracked and remote-only, reconciled and +// with fully-merged stacks filtered out), and resolves the user's choice to a +// stack and the branch to check out. It returns (nil, "", nil) when the user +// cancels or has no stacks. Remote-only selections are cloned down through the +// same path as `gh stack checkout `. +func interactiveCheckout(cfg *config.Config, sf *stack.StackFile, gitDir string) (*stack.Stack, string, error) { if !cfg.IsInteractive() { - return nil, fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack") + return nil, "", fmt.Errorf("no target specified; provide a branch name or PR number, or run interactively to select a stack") } - if len(sf.Stacks) == 0 { - cfg.Infof("No locally tracked stacks found") - cfg.Printf("Create a stack with `%s` or check out a remote stack with `%s`", + rows := gatherCheckoutRows(cfg, sf) + if len(rows) == 0 { + cfg.Infof("No stacks available to check out") + cfg.Printf("Create a stack with `%s` or check out a stack by number with `%s`", cfg.ColorCyan("gh stack init"), - cfg.ColorCyan("gh stack checkout 123")) - return nil, nil + cfg.ColorCyan("gh stack checkout ")) + return nil, "", nil } - options := make([]string, len(sf.Stacks)) - for i := range sf.Stacks { - options[i] = sf.Stacks[i].DisplayChain() + selected, ok, err := launchCheckoutPicker(rows) + if err != nil { + return nil, "", err + } + if !ok { + // The user dismissed the picker without selecting. + return nil, "", nil } - p := prompter.New(cfg.In, cfg.Out, cfg.Err) - selected, err := p.Select( - "Select a stack to check out (showing locally tracked stacks only)", - "", - options, - ) + return resolveCheckoutSelection(cfg, sf, gitDir, selected) +} + +// gatherCheckoutRows fetches the remote stacks (best-effort) and reconciles them +// with the local stacks into the picker's rows. Any GitHub failure (stacks not +// enabled for the repo, no auth, network error) gracefully degrades to a +// local-only list. +func gatherCheckoutRows(cfg *config.Config, sf *stack.StackFile) []checkoutview.StackRow { + var remote []github.RemoteStack + if client, err := cfg.GitHubClient(); err == nil { + if stacks, err := client.ListStacks(); err == nil { + remote = stacks + } + } + return checkoutview.BuildRows(sf.Stacks, remote) +} + +// resolveCheckoutSelection resolves a picker selection to a local stack and the +// branch to check out. Locally available stacks are used directly; remote-only +// stacks are cloned down by stack number through the same import/reconcile flow +// as `gh stack checkout `. +func resolveCheckoutSelection(cfg *config.Config, sf *stack.StackFile, gitDir string, selected checkoutview.StackRow) (*stack.Stack, string, error) { + if selected.Type == checkoutview.TypeLocal && selected.LocalStack != nil { + return selected.LocalStack, topUnmergedBranch(selected.LocalStack), nil + } + + s, targetBranch, err := checkoutStackByNumber(cfg, sf, gitDir, selected.Number) if err != nil { - if isInterruptError(err) { - clearSelectPrompt(cfg, len(options)) - printInterrupt(cfg) - return nil, errInterrupt + if errors.Is(err, errStackNumberNotFound) { + cfg.Errorf("stack #%d could not be loaded from GitHub", selected.Number) + return nil, "", ErrAPIFailure } - return nil, fmt.Errorf("stack selection: %w", err) + return nil, "", err } + return s, targetBranch, nil +} - return &sf.Stacks[selected], nil +// launchCheckoutPicker runs the Bubble Tea stack picker and returns the selected +// row (and whether one was chosen). It renders inline (no alt-screen) so the +// picker occupies only a few lines and leaves the surrounding terminal output +// intact. Mouse motion is intentionally not enabled so the search field never +// receives stray mouse bytes. +func launchCheckoutPicker(rows []checkoutview.StackRow) (checkoutview.StackRow, bool, error) { + p := tea.NewProgram(checkoutview.New(rows)) + finalModel, err := p.Run() + if err != nil { + return checkoutview.StackRow{}, false, fmt.Errorf("running stack picker: %w", err) + } + m, ok := finalModel.(checkoutview.Model) + if !ok { + return checkoutview.StackRow{}, false, nil + } + row, selected := m.Result() + return row, selected, nil +} + +// topUnmergedBranch returns the top-most branch of a local stack that has not +// been merged, falling back to the very top branch when every branch is merged. +func topUnmergedBranch(s *stack.Stack) string { + if len(s.Branches) == 0 { + return "" + } + for i := len(s.Branches) - 1; i >= 0; i-- { + if !s.Branches[i].IsMerged() { + return s.Branches[i].Branch + } + } + return s.Branches[len(s.Branches)-1].Branch } diff --git a/cmd/checkout_picker_test.go b/cmd/checkout_picker_test.go new file mode 100644 index 0000000..a97e0b5 --- /dev/null +++ b/cmd/checkout_picker_test.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-stack/internal/config" + "github.com/github/gh-stack/internal/git" + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/checkoutview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInteractiveCheckout_NonInteractive(t *testing.T) { + cfg, _, _ := config.NewTestConfig() // ForceInteractive defaults to false + sf := &stack.StackFile{SchemaVersion: 1} + + _, _, err := interactiveCheckout(cfg, sf, t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "no target specified") +} + +func TestGatherCheckoutRows_FallbackToLocalOnListError(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return nil, &api.HTTPError{StatusCode: 404, Message: "stacks not enabled"} + }, + } + + sf := &stack.StackFile{Stacks: []stack.Stack{{ + Number: 5, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-a", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "feat-b"}, + }, + }}} + + rows := gatherCheckoutRows(cfg, sf) + require.Len(t, rows, 1, "falls back to a local-only list when ListStacks fails") + assert.Equal(t, checkoutview.TypeLocal, rows[0].Type) + assert.Equal(t, 5, rows[0].Number) +} + +func TestGatherCheckoutRows_IncludesRemoteOnlyStacks(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + ListStacksFn: func() ([]github.RemoteStack, error) { + return []github.RemoteStack{{ + ID: 200, + Number: 55, + Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 7, State: "open", Head: github.RemoteStackPRHead{Ref: "r1"}}, + {Number: 8, State: "open", Head: github.RemoteStackPRHead{Ref: "r2"}}, + }, + }}, nil + }, + } + + sf := &stack.StackFile{Stacks: []stack.Stack{{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "local-a"}}, + }}} + + rows := gatherCheckoutRows(cfg, sf) + require.Len(t, rows, 2, "local and remote-only stacks are both listed") + + var haveLocal, haveRemote bool + for _, r := range rows { + switch r.Type { + case checkoutview.TypeLocal: + haveLocal = true + case checkoutview.TypeRemote: + haveRemote = true + assert.Equal(t, 55, r.Number) + } + } + assert.True(t, haveLocal, "local stack present") + assert.True(t, haveRemote, "remote-only stack present") +} + +func TestResolveCheckoutSelection_Local(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + localStack := &stack.Stack{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2}}, + }, + } + sel := checkoutview.StackRow{Type: checkoutview.TypeLocal, Number: 3, LocalStack: localStack} + + s, branch, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel) + require.NoError(t, err) + assert.Same(t, localStack, s) + assert.Equal(t, "b", branch, "checks out the top unmerged branch") +} + +func TestResolveCheckoutSelection_RemoteRoutesToClone(t *testing.T) { + gitDir := t.TempDir() + var createdBranches []string + + restore := git.SetOps(&git.MockOps{ + GitDirFn: func() (string, error) { return gitDir, nil }, + CurrentBranchFn: func() (string, error) { return "main", nil }, + BranchExistsFn: func(name string) bool { return name == "main" }, + FetchFn: func(remote string) error { return nil }, + CreateBranchFn: func(name, base string) error { + createdBranches = append(createdBranches, name) + return nil + }, + SetUpstreamTrackingFn: func(branch, remote string) error { return nil }, + ResolveRemoteFn: func(branch string) (string, error) { return "origin", nil }, + CheckoutBranchFn: func(name string) error { return nil }, + RevParseFn: func(ref string) (string, error) { return "abc123", nil }, + RevParseMultiFn: func(refs []string) ([]string, error) { + shas := make([]string, len(refs)) + for i := range refs { + shas[i] = "abc123" + } + return shas, nil + }, + }) + defer restore() + + require.NoError(t, stack.Save(gitDir, &stack.StackFile{SchemaVersion: 1, Stacks: []stack.Stack{}})) + sf, err := stack.Load(gitDir) + require.NoError(t, err) + + var gotStackNumber int + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + gotStackNumber = n + return &github.RemoteStack{ID: 42, Number: 7, PullRequests: []int{10, 11, 12}}, nil + }, + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + prs := map[int]*github.PullRequest{ + 10: {ID: "PR_10", Number: 10, HeadRefName: "feat-1", BaseRefName: "main"}, + 11: {ID: "PR_11", Number: 11, HeadRefName: "feat-2", BaseRefName: "feat-1"}, + 12: {ID: "PR_12", Number: 12, HeadRefName: "feat-3", BaseRefName: "feat-2"}, + } + return prs[number], nil + }, + } + + sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7} + s, branch, err := resolveCheckoutSelection(cfg, sf, gitDir, sel) + require.NoError(t, err) + assert.Equal(t, 7, gotStackNumber, "remote selection is cloned by stack number") + assert.Equal(t, "feat-3", branch, "targets the top-most branch") + require.NotNil(t, s) + assert.Equal(t, "42", s.ID) + assert.Equal(t, 7, s.Number) +} + +func TestResolveCheckoutSelection_RemoteLoadFailure(t *testing.T) { + cfg, _, _ := config.NewTestConfig() + cfg.GitHubClientOverride = &github.MockClient{ + GetStackFn: func(n int) (*github.RemoteStack, error) { + return nil, errors.New("boom") + }, + } + + sel := checkoutview.StackRow{Type: checkoutview.TypeRemote, Number: 7} + _, _, err := resolveCheckoutSelection(cfg, &stack.StackFile{}, t.TempDir(), sel) + + var exitErr *ExitError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, ErrAPIFailure, err) +} + +func TestTopUnmergedBranch(t *testing.T) { + tests := []struct { + name string + branches []stack.BranchRef + expect string + }{ + {"empty", nil, ""}, + { + name: "some unmerged", + branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2}}, + {Branch: "c"}, + }, + expect: "c", + }, + { + name: "all merged falls back to top", + branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + expect: "b", + }, + { + name: "merged on top of unmerged", + branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + expect: "a", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &stack.Stack{Branches: tt.branches} + assert.Equal(t, tt.expect, topUnmergedBranch(s)) + }) + } +} diff --git a/docs/src/content/docs/introduction/overview.md b/docs/src/content/docs/introduction/overview.md index 11e5f07..eeec98a 100644 --- a/docs/src/content/docs/introduction/overview.md +++ b/docs/src/content/docs/introduction/overview.md @@ -91,7 +91,7 @@ While the PR UI provides the review and merge experience, the `gh stack` CLI han - **Syncing everything** — `gh stack sync` fetches, rebases, pushes, updates PR state, and links open PRs into a Stack on GitHub in one command. It also syncs the stack's remote state, pulling down branches for any PRs added to the stack on GitHub. - **Restructuring stacks** — `gh stack modify` opens an interactive terminal UI to drop, fold, insert, rename, and reorder branches in a stack. - **Tearing down stacks** — `gh stack unstack` removes a stack from GitHub and local tracking. -- **Checking out a stack** — `gh stack checkout ` pulls down a stack, with all its branches, from GitHub to your local machine. +- **Checking out a stack** — `gh stack checkout` pulls a stack, with all its branches, down from GitHub to your local machine. Give it a stack number or run it with no arguments to pick from an interactive list of every stack available to you (local and remote). The CLI is not required to use Stacked PRs — the underlying git operations are standard. But it makes the workflow simpler, and you can create Stacked PRs from the CLI instead of the UI. diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 7f6b10a..d233c94 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -147,7 +147,7 @@ When a remote stack is referenced, the command fetches the stack on GitHub, pull When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments in an interactive terminal, shows a menu of all locally available stacks to choose from. +When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. **Examples:** @@ -164,7 +164,7 @@ gh stack checkout https://github.com/owner/repo/pull/42 # Check out a stack by branch name (local only) gh stack checkout feature-auth -# Interactive — select from locally tracked stacks +# Interactive — pick from all available stacks (local and remote) gh stack checkout ``` diff --git a/internal/tui/checkoutview/data.go b/internal/tui/checkoutview/data.go new file mode 100644 index 0000000..3c4f3f5 --- /dev/null +++ b/internal/tui/checkoutview/data.go @@ -0,0 +1,356 @@ +// Package checkoutview renders the interactive stack picker shown by +// `gh stack checkout` when no target is given. It reconciles the locally +// tracked stacks with the stacks returned by the Stacks REST API into a single +// table, labels each as Local or Remote, and lets the user filter, search, and +// select one to check out. +package checkoutview + +import ( + "fmt" + "sort" + "strconv" + "time" + + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +// StackType classifies where a stack lives. +type StackType int + +const ( + // TypeLocal means the stack is present in the local stack file. It may also + // be tracked on the remote; per the picker's simplification, anything + // available locally is "Local". + TypeLocal StackType = iota + // TypeRemote means the stack exists only on the remote. + TypeRemote +) + +// String returns the human-readable label for the type column. +func (t StackType) String() string { + if t == TypeRemote { + return "Remote" + } + return "Local" +} + +// StatusCounts summarizes a stack's composition for the status bar. Each field +// is a count of branches/PRs in that state. +type StatusCounts struct { + Merged int // merged PRs (purple) + Open int // open, non-merged PRs (green) + Closed int // closed, non-merged PRs (red) + Unpushed int // branches with no PR yet (gray) +} + +// Total returns the number of branches/PRs represented. +func (c StatusCounts) Total() int { + return c.Merged + c.Open + c.Closed + c.Unpushed +} + +// fullyMerged reports whether every entry is a merged PR (nothing open, closed, +// or unpushed). Such stacks can no longer be added to and are filtered out. +func (c StatusCounts) fullyMerged() bool { + return c.Merged > 0 && c.Open == 0 && c.Closed == 0 && c.Unpushed == 0 +} + +// StackRow is a single reconciled entry shown in the checkout picker. +type StackRow struct { + Number int // stack number; 0 when unknown (local-only, not pushed) + Type StackType + BottomBranch string + TopBranch string + Base string + Status StatusCounts + Created time.Time + HasCreated bool + + // LocalStack points at the local stack when Type == TypeLocal, so the caller + // can check out its branches directly without cloning. It is nil for + // remote-only rows, which are cloned by stack number instead. + LocalStack *stack.Stack +} + +// branchSep joins the bottom and top branch names in the Branches column, +// mirroring Git's A...B compare syntax. +const branchSep = "..." + +// Summary returns "bottom...top" (or a single branch when the stack has one +// branch, or the ends coincide). +func (r StackRow) Summary() string { + switch { + case r.BottomBranch == "" && r.TopBranch == "": + return "" + case r.TopBranch == "" || r.BottomBranch == r.TopBranch: + return r.BottomBranch + case r.BottomBranch == "": + return r.TopBranch + default: + return r.BottomBranch + branchSep + r.TopBranch + } +} + +// NumberDisplay renders the stack number, or "—" when it is unknown. +func (r StackRow) NumberDisplay() string { + if r.Number == 0 { + return "—" + } + return strconv.Itoa(r.Number) +} + +// CreatedDisplay renders the creation time as a compact age, or "—" when +// unknown. +func (r StackRow) CreatedDisplay() string { + if !r.HasCreated { + return "—" + } + return relativeTime(r.Created) +} + +// BuildRows reconciles the local stacks with the remote stacks into the ordered +// list shown in the picker. Local stacks own the "Local" type even when they +// are tracked on the remote; remote stacks with no local match are "Remote". +// Fully-merged stacks (every PR merged) are omitted. The local slice must be the +// caller's live stack slice, since rows retain pointers into it. +func BuildRows(local []stack.Stack, remote []github.RemoteStack) []StackRow { + remoteByNumber := make(map[int]*github.RemoteStack, len(remote)) + remoteByID := make(map[int]*github.RemoteStack, len(remote)) + for i := range remote { + rs := &remote[i] + if rs.Number != 0 { + remoteByNumber[rs.Number] = rs + } + remoteByID[rs.ID] = rs + } + + matched := make(map[*github.RemoteStack]bool, len(remote)) + rows := make([]StackRow, 0, len(local)+len(remote)) + + // Local stacks first — they own the "Local" type even when tracked. + for i := range local { + ls := &local[i] + rs := matchRemote(ls, remoteByNumber, remoteByID) + if rs != nil { + matched[rs] = true + } + if row, ok := localRow(ls, rs); ok { + rows = append(rows, row) + } + } + + // Remote-only stacks. + for i := range remote { + rs := &remote[i] + if matched[rs] { + continue + } + if row, ok := remoteRow(rs); ok { + rows = append(rows, row) + } + } + + sortRows(rows) + return rows +} + +// matchRemote finds the remote stack that corresponds to a local stack, by +// stack number first and then by the remote id stored (as a string) locally. +func matchRemote(ls *stack.Stack, byNumber, byID map[int]*github.RemoteStack) *github.RemoteStack { + if ls.Number != 0 { + if rs := byNumber[ls.Number]; rs != nil { + return rs + } + } + if ls.ID != "" { + if id, err := strconv.Atoi(ls.ID); err == nil { + if rs := byID[id]; rs != nil { + return rs + } + } + } + return nil +} + +// localRow builds the row for a local stack, enriching it with live remote data +// (number, base, created, PR states) when a matching remote stack is provided. +func localRow(ls *stack.Stack, rs *github.RemoteStack) (StackRow, bool) { + row := StackRow{ + Type: TypeLocal, + Number: ls.Number, + Base: ls.Trunk.Branch, + LocalStack: ls, + } + if len(ls.Branches) > 0 { + row.BottomBranch = ls.Branches[0].Branch + row.TopBranch = ls.Branches[len(ls.Branches)-1].Branch + } + + if rs != nil { + if rs.Number != 0 { + row.Number = rs.Number + } + if rs.Base.Ref != "" { + row.Base = rs.Base.Ref + } + if t, ok := parseTime(rs.CreatedAt); ok { + row.Created, row.HasCreated = t, true + } + row.Status = statusFromLocalTracked(ls, rs) + } else { + row.Status = statusFromLocal(ls) + } + + if row.Status.fullyMerged() { + return StackRow{}, false + } + return row, true +} + +// remoteRow builds the row for a remote-only stack from its API details. +func remoteRow(rs *github.RemoteStack) (StackRow, bool) { + if len(rs.PRDetails) == 0 { + return StackRow{}, false + } + row := StackRow{ + Number: rs.Number, + Type: TypeRemote, + Base: rs.Base.Ref, + BottomBranch: rs.PRDetails[0].Head.Ref, + TopBranch: rs.PRDetails[len(rs.PRDetails)-1].Head.Ref, + } + if t, ok := parseTime(rs.CreatedAt); ok { + row.Created, row.HasCreated = t, true + } + + var c StatusCounts + for _, p := range rs.PRDetails { + classifyRemotePR(p, &c) + } + row.Status = c + + if c.fullyMerged() { + return StackRow{}, false + } + return row, true +} + +// statusFromLocalTracked derives status counts for a tracked stack, preferring +// the remote's live PR states and counting local branches with no PR as +// unpushed. It produces one entry per local branch. +func statusFromLocalTracked(ls *stack.Stack, rs *github.RemoteStack) StatusCounts { + remoteByNum := make(map[int]github.RemoteStackPR, len(rs.PRDetails)) + for _, p := range rs.PRDetails { + remoteByNum[p.Number] = p + } + + var c StatusCounts + for i := range ls.Branches { + b := &ls.Branches[i] + if b.PullRequest != nil && b.PullRequest.Number != 0 { + if p, ok := remoteByNum[b.PullRequest.Number]; ok { + classifyRemotePR(p, &c) + continue + } + // Tracked locally but absent from the remote stack — fall back to the + // local merged flag. + if b.IsMerged() { + c.Merged++ + } else { + c.Open++ + } + continue + } + c.Unpushed++ + } + return c +} + +// statusFromLocal derives status counts for an untracked local stack from its +// local PR references alone. +func statusFromLocal(ls *stack.Stack) StatusCounts { + var c StatusCounts + for i := range ls.Branches { + b := &ls.Branches[i] + if b.PullRequest != nil && b.PullRequest.Number != 0 { + if b.IsMerged() { + c.Merged++ + } else { + c.Open++ + } + continue + } + c.Unpushed++ + } + return c +} + +// classifyRemotePR increments the count matching a remote PR's state. +func classifyRemotePR(p github.RemoteStackPR, c *StatusCounts) { + switch { + case p.IsMerged(): + c.Merged++ + case p.State == "closed": + c.Closed++ + default: + c.Open++ + } +} + +// sortRows orders rows newest-first: local-only stacks with no number surface +// first (active in-progress work), then higher stack numbers (newer) before +// lower ones. Ties break by creation time then summary for determinism. +func sortRows(rows []StackRow) { + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + if (a.Number == 0) != (b.Number == 0) { + return a.Number == 0 + } + if a.Number != b.Number { + return a.Number > b.Number + } + if a.HasCreated != b.HasCreated { + return a.HasCreated + } + if a.HasCreated && b.HasCreated && !a.Created.Equal(b.Created) { + return a.Created.After(b.Created) + } + return a.Summary() < b.Summary() + }) +} + +// parseTime parses an RFC3339 timestamp, reporting whether it succeeded. +func parseTime(s string) (time.Time, bool) { + if s == "" { + return time.Time{}, false + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, true + } + return time.Time{}, false +} + +// relativeTime formats t as a compact age like "15m ago", "3d ago", or +// "1mo ago". +func relativeTime(t time.Time) string { + d := time.Since(t) + if d < 0 { + d = 0 + } + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + case d < 30*24*time.Hour: + return fmt.Sprintf("%dw ago", int(d.Hours()/24/7)) + case d < 365*24*time.Hour: + return fmt.Sprintf("%dmo ago", int(d.Hours()/24/30)) + default: + return fmt.Sprintf("%dy ago", int(d.Hours()/24/365)) + } +} diff --git a/internal/tui/checkoutview/data_test.go b/internal/tui/checkoutview/data_test.go new file mode 100644 index 0000000..66144ec --- /dev/null +++ b/internal/tui/checkoutview/data_test.go @@ -0,0 +1,306 @@ +package checkoutview + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" +) + +func mergedAt(t time.Time) *string { + s := t.UTC().Format(time.RFC3339) + return &s +} + +func rfc3339(t time.Time) string { return t.UTC().Format(time.RFC3339) } + +// findRow returns the row with the given number (or a zero-value row) for +// assertions. +func findRow(rows []StackRow, number int) (StackRow, bool) { + for _, r := range rows { + if r.Number == number { + return r, true + } + } + return StackRow{}, false +} + +func TestBuildRows_LocalOnly_Unpushed(t *testing.T) { + local := []stack.Stack{{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "feat-a"}, + {Branch: "feat-b"}, + }, + }} + + rows := BuildRows(local, nil) + require.Len(t, rows, 1) + + r := rows[0] + assert.Equal(t, TypeLocal, r.Type) + assert.Equal(t, 0, r.Number) + assert.Equal(t, "—", r.NumberDisplay()) + assert.Equal(t, "feat-a...feat-b", r.Summary()) + assert.Equal(t, "main", r.Base) + assert.Equal(t, StatusCounts{Unpushed: 2}, r.Status) + assert.False(t, r.HasCreated) + assert.Equal(t, "—", r.CreatedDisplay()) + require.NotNil(t, r.LocalStack) + assert.Same(t, &local[0], r.LocalStack) +} + +func TestBuildRows_RemoteOnly(t *testing.T) { + now := time.Now() + remote := []github.RemoteStack{{ + ID: 200, + Number: 55, + Base: github.RemoteStackBase{Ref: "main"}, + CreatedAt: rfc3339(now.Add(-3 * time.Hour)), + PRDetails: []github.RemoteStackPR{ + {Number: 7, State: "open", Head: github.RemoteStackPRHead{Ref: "refactor-1"}}, + {Number: 8, State: "closed", Head: github.RemoteStackPRHead{Ref: "refactor-2"}}, + {Number: 9, State: "open", Head: github.RemoteStackPRHead{Ref: "refactor-3"}}, + }, + }} + + rows := BuildRows(nil, remote) + require.Len(t, rows, 1) + + r := rows[0] + assert.Equal(t, TypeRemote, r.Type) + assert.Equal(t, 55, r.Number) + assert.Equal(t, "refactor-1...refactor-3", r.Summary()) + assert.Equal(t, "main", r.Base) + assert.Equal(t, StatusCounts{Open: 2, Closed: 1}, r.Status) + assert.True(t, r.HasCreated) + assert.Nil(t, r.LocalStack) +} + +func TestBuildRows_TrackedStack_IsLocalWithLiveStatus(t *testing.T) { + now := time.Now() + // Local stack tracked on remote (matched by number and id). It has three + // branches: one merged PR, one open PR, and one unpushed branch. + local := []stack.Stack{{ + Number: 42, + ID: "100", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "api-1", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "api-2", PullRequest: &stack.PullRequestRef{Number: 2}}, + {Branch: "api-3"}, + }, + }} + remote := []github.RemoteStack{{ + ID: 100, + Number: 42, + Base: github.RemoteStackBase{Ref: "trunk"}, + CreatedAt: rfc3339(now.Add(-72 * time.Hour)), + PRDetails: []github.RemoteStackPR{ + {Number: 1, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "api-1"}}, + {Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "api-2"}}, + }, + }} + + rows := BuildRows(local, remote) + require.Len(t, rows, 1, "tracked stack must appear once, not duplicated") + + r := rows[0] + assert.Equal(t, TypeLocal, r.Type, "available locally means Local even when tracked") + assert.Equal(t, 42, r.Number) + // Base and created come from the live remote data. + assert.Equal(t, "trunk", r.Base) + assert.True(t, r.HasCreated) + // One box per local branch: merged (remote), open (remote), unpushed (no PR). + assert.Equal(t, StatusCounts{Merged: 1, Open: 1, Unpushed: 1}, r.Status) +} + +func TestBuildRows_MatchByID_WhenNumberMissing(t *testing.T) { + // Local stack has no number yet (0) but stores the remote id as a string. + local := []stack.Stack{{ + Number: 0, + ID: "500", + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "b1", PullRequest: &stack.PullRequestRef{Number: 1}}, + {Branch: "b2", PullRequest: &stack.PullRequestRef{Number: 2}}, + }, + }} + remote := []github.RemoteStack{{ + ID: 500, + Number: 88, + Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 1, State: "open", Head: github.RemoteStackPRHead{Ref: "b1"}}, + {Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "b2"}}, + }, + }} + + rows := BuildRows(local, remote) + require.Len(t, rows, 1, "id match must dedupe local and remote") + assert.Equal(t, TypeLocal, rows[0].Type) + assert.Equal(t, 88, rows[0].Number, "number is backfilled from the matched remote") +} + +func TestBuildRows_FiltersFullyMergedStacks(t *testing.T) { + now := time.Now() + remote := []github.RemoteStack{ + { + ID: 1, Number: 1, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{ + {Number: 10, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "m1"}}, + {Number: 11, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "m2"}}, + }, + }, + { + ID: 2, Number: 2, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{ + {Number: 20, State: "closed", MergedAt: mergedAt(now), Head: github.RemoteStackPRHead{Ref: "x"}}, + {Number: 21, State: "open", Head: github.RemoteStackPRHead{Ref: "y"}}, + }, + }, + } + + rows := BuildRows(nil, remote) + require.Len(t, rows, 1, "fully-merged stack #1 must be filtered out") + assert.Equal(t, 2, rows[0].Number) +} + +func TestBuildRows_LocalFullyMergedIsFiltered(t *testing.T) { + local := []stack.Stack{{ + Number: 9, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2, Merged: true}}, + }, + }} + + rows := BuildRows(local, nil) + assert.Empty(t, rows, "a local stack with every branch merged is filtered") +} + +func TestBuildRows_SortOrder(t *testing.T) { + now := time.Now() + local := []stack.Stack{{ + Number: 0, // local-only, unpushed -> sorts first + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "wip"}}, + }} + remote := []github.RemoteStack{ + {ID: 1, Number: 10, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{{Number: 1, State: "open", Head: github.RemoteStackPRHead{Ref: "a"}}}}, + {ID: 2, Number: 30, Base: github.RemoteStackBase{Ref: "main"}, CreatedAt: rfc3339(now), + PRDetails: []github.RemoteStackPR{{Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "b"}}}}, + } + + rows := BuildRows(local, remote) + require.Len(t, rows, 3) + // Local-only (number 0) first, then higher numbers before lower. + assert.Equal(t, 0, rows[0].Number) + assert.Equal(t, 30, rows[1].Number) + assert.Equal(t, 10, rows[2].Number) +} + +func TestBuildRows_ClosedNonMergedStackIsKept(t *testing.T) { + remote := []github.RemoteStack{{ + ID: 1, Number: 1, Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 10, State: "closed", Head: github.RemoteStackPRHead{Ref: "x"}}, + {Number: 11, State: "closed", Head: github.RemoteStackPRHead{Ref: "y"}}, + }, + }} + rows := BuildRows(nil, remote) + require.Len(t, rows, 1, "closed-but-not-merged stacks stay visible") + assert.Equal(t, StatusCounts{Closed: 2}, rows[0].Status) +} + +func TestBuildRows_UntrackedLocalWithMergedPRFlag(t *testing.T) { + local := []stack.Stack{{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a", PullRequest: &stack.PullRequestRef{Number: 1, Merged: true}}, + {Branch: "b", PullRequest: &stack.PullRequestRef{Number: 2}}, + }, + }} + rows := BuildRows(local, nil) + require.Len(t, rows, 1) + assert.Equal(t, StatusCounts{Merged: 1, Open: 1}, rows[0].Status) +} + +func TestStackRow_Summary(t *testing.T) { + tests := []struct { + name string + row StackRow + expect string + }{ + {"two branches", StackRow{BottomBranch: "a", TopBranch: "b"}, "a...b"}, + {"single branch", StackRow{BottomBranch: "a", TopBranch: "a"}, "a"}, + {"only bottom", StackRow{BottomBranch: "a"}, "a"}, + {"empty", StackRow{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expect, tt.row.Summary()) + }) + } +} + +func TestStatusCounts_FullyMerged(t *testing.T) { + assert.True(t, StatusCounts{Merged: 3}.fullyMerged()) + assert.False(t, StatusCounts{}.fullyMerged()) + assert.False(t, StatusCounts{Merged: 1, Open: 1}.fullyMerged()) + assert.False(t, StatusCounts{Unpushed: 2}.fullyMerged()) + assert.False(t, StatusCounts{Merged: 1, Closed: 1}.fullyMerged()) +} + +func TestDistribute(t *testing.T) { + // Fits within max: unchanged (one square per branch). + assert.Equal(t, []int{1, 2, 0, 1}, distribute([]int{1, 2, 0, 1}, 10)) + + // Exceeds max: downsample to sum exactly max, preserving proportions. + got := distribute([]int{10, 10, 0, 0}, 10) + sum := 0 + for _, n := range got { + sum += n + } + assert.Equal(t, 10, sum) + assert.Equal(t, []int{5, 5, 0, 0}, got) +} + +func TestParseTime(t *testing.T) { + _, ok := parseTime("") + assert.False(t, ok) + _, ok = parseTime("not-a-time") + assert.False(t, ok) + tm, ok := parseTime("2024-01-02T03:04:05Z") + require.True(t, ok) + assert.Equal(t, 2024, tm.Year()) +} + +func TestRelativeTime(t *testing.T) { + now := time.Now() + tests := []struct { + name string + at time.Time + expect string + }{ + {"just now", now.Add(-10 * time.Second), "just now"}, + {"minutes", now.Add(-15 * time.Minute), "15m ago"}, + {"hours", now.Add(-3 * time.Hour), "3h ago"}, + {"days", now.Add(-3 * 24 * time.Hour), "3d ago"}, + {"weeks", now.Add(-14 * 24 * time.Hour), "2w ago"}, + {"months", now.Add(-60 * 24 * time.Hour), "2mo ago"}, + {"future clamps", now.Add(1 * time.Hour), "just now"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expect, relativeTime(tt.at)) + }) + } +} diff --git a/internal/tui/checkoutview/model.go b/internal/tui/checkoutview/model.go new file mode 100644 index 0000000..004192c --- /dev/null +++ b/internal/tui/checkoutview/model.go @@ -0,0 +1,598 @@ +package checkoutview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" +) + +// tab identifies the active filter tab. +type tab int + +const ( + tabAll tab = iota + tabLocal + tabRemote +) + +func (t tab) String() string { + switch t { + case tabLocal: + return "Local" + case tabRemote: + return "Remote" + default: + return "All" + } +} + +// keyMap holds the picker's key bindings. +type keyMap struct { + Up key.Binding + Down key.Binding + Select key.Binding + NextTab key.Binding + PrevTab key.Binding + Search key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "ctrl+p")), + Down: key.NewBinding(key.WithKeys("down", "ctrl+n")), + Select: key.NewBinding(key.WithKeys("enter")), + NextTab: key.NewBinding(key.WithKeys("tab", "right")), + PrevTab: key.NewBinding(key.WithKeys("shift+tab", "left")), + Search: key.NewBinding(key.WithKeys("/")), + Quit: key.NewBinding(key.WithKeys("esc", "q")), +} + +// Model is the Bubble Tea model for the interactive checkout stack picker. +type Model struct { + rows []StackRow // all reconciled rows (stable order) + filtered []StackRow // rows matching the active tab + search query + + tab tab + query string + searching bool + + cursor int + scrollOffset int + width int + height int + + result StackRow + hasResult bool + cancelled bool +} + +// New creates a picker model over the given reconciled rows. +func New(rows []StackRow) Model { + m := Model{rows: rows} + m.applyFilter() + return m +} + +// Result returns the selected row and whether one was chosen (Enter). It is +// false when the user cancelled or nothing was selectable. +func (m Model) Result() (StackRow, bool) { + return m.result, m.hasResult +} + +// Cancelled reports whether the user dismissed the picker without selecting. +func (m Model) Cancelled() bool { + return m.cancelled +} + +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.width = msg.Width + m.height = msg.Height + m.ensureVisible() + return m, nil + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case msg.Type == tea.KeyCtrlC: + m.cancelled = true + return m, tea.Quit + + case key.Matches(msg, keys.Up): + m.moveCursor(-1) + return m, nil + + case key.Matches(msg, keys.Down): + m.moveCursor(1) + return m, nil + + case key.Matches(msg, keys.Select): + if row, ok := m.current(); ok { + m.result = row + m.hasResult = true + return m, tea.Quit + } + return m, nil + + case key.Matches(msg, keys.NextTab): + m.cycleTab(1) + return m, nil + + case key.Matches(msg, keys.PrevTab): + m.cycleTab(-1) + return m, nil + + case key.Matches(msg, keys.Search) && !m.searching: + m.searching = true + return m, nil + + case !m.searching && key.Matches(msg, keys.Quit): + m.cancelled = true + return m, tea.Quit + } + + if m.searching { + return m.updateSearch(msg) + } + return m, nil +} + +// updateSearch handles text entry while the search field is focused. +func (m Model) updateSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.searching = false + m.query = "" + m.applyFilter() + case tea.KeyBackspace: + if r := []rune(m.query); len(r) > 0 { + m.query = string(r[:len(r)-1]) + m.applyFilter() + } + case tea.KeySpace: + m.query += " " + m.applyFilter() + case tea.KeyRunes: + m.query += string(msg.Runes) + m.applyFilter() + } + return m, nil +} + +// cycleTab moves the active tab by delta and resets the cursor. +func (m *Model) cycleTab(delta int) { + m.tab = tab((int(m.tab) + delta + 3) % 3) + m.cursor = 0 + m.scrollOffset = 0 + m.applyFilter() +} + +// applyFilter recomputes the visible rows from the active tab and query, then +// clamps the cursor and scroll. +func (m *Model) applyFilter() { + m.filtered = m.filtered[:0] + for _, r := range m.rows { + if matchesTab(r, m.tab) && matchesQuery(r, m.query) { + m.filtered = append(m.filtered, r) + } + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + m.ensureVisible() +} + +func matchesTab(r StackRow, t tab) bool { + switch t { + case tabLocal: + return r.Type == TypeLocal + case tabRemote: + return r.Type == TypeRemote + default: + return true + } +} + +func matchesQuery(r StackRow, q string) bool { + q = strings.ToLower(strings.TrimSpace(q)) + if q == "" { + return true + } + hay := strings.ToLower(strings.Join([]string{ + r.NumberDisplay(), r.Summary(), r.Base, r.Type.String(), + }, " ")) + return strings.Contains(hay, q) +} + +// current returns the row under the cursor, if any. +func (m Model) current() (StackRow, bool) { + if m.cursor >= 0 && m.cursor < len(m.filtered) { + return m.filtered[m.cursor], true + } + return StackRow{}, false +} + +// moveCursor moves the selection by delta within the filtered rows. +func (m *Model) moveCursor(delta int) { + if len(m.filtered) == 0 { + return + } + m.cursor += delta + if m.cursor < 0 { + m.cursor = 0 + } + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + m.ensureVisible() +} + +// maxVisibleRows caps how many stack rows the inline picker shows at once. The +// rest are reached by scrolling, so the picker never takes over the screen. +const maxVisibleRows = 10 + +// bodyHeight returns the number of table rows shown at once: the row count +// capped at maxVisibleRows, and further shrunk to fit a short terminal. It never +// returns less than 1 (a line is reserved for the empty-state message). +func (m Model) bodyHeight() int { + rows := len(m.filtered) + if rows < 1 { + rows = 1 + } + limit := maxVisibleRows + if m.height > 0 { + if avail := m.height - m.chromeHeight(); avail < limit { + limit = avail + } + } + if limit < 1 { + limit = 1 + } + if rows > limit { + rows = limit + } + return rows +} + +// chromeHeight is the number of lines reserved around the table body when +// fitting the picker to a short terminal: title, tabs, blank, header, footer +// (5), plus one line of breathing room so the inline picker never exactly fills +// the terminal (which would make it scroll). The search line adds one more. +func (m Model) chromeHeight() int { + chrome := 6 + if m.searching { + chrome++ + } + return chrome +} + +// ensureVisible adjusts the scroll offset so the cursor stays on screen. +func (m *Model) ensureVisible() { + m.scrollOffset = shared.EnsureVisible(m.cursor, m.cursor+1, m.scrollOffset, m.bodyHeight()) +} + +// --- layout --- + +type layout struct { + num, summary, base, status, typ, created int +} + +const colSep = " " + +// computeLayout derives column widths from all rows (stable across filtering) +// and the terminal width. +func (m Model) computeLayout() layout { + l := layout{ + num: lipgloss.Width("#"), + base: lipgloss.Width("Base"), + status: lipgloss.Width("Status"), + typ: lipgloss.Width("Remote"), + created: lipgloss.Width("Created"), + } + for i := range m.rows { + r := &m.rows[i] + l.num = maxInt(l.num, lipgloss.Width(r.NumberDisplay())) + l.base = maxInt(l.base, lipgloss.Width(r.Base)) + l.status = maxInt(l.status, statusWidth(r.Status)) + l.created = maxInt(l.created, lipgloss.Width(r.CreatedDisplay())) + } + if l.base > 24 { + l.base = 24 + } + + sep := lipgloss.Width(colSep) + // selector(2) + num + base + status + typ + created + 5 separators between + // the six data columns. + fixed := 2 + l.num + l.base + l.status + l.typ + l.created + sep*5 + l.summary = m.width - fixed + if l.summary < 10 { + l.summary = 10 + } + return l +} + +// statusWidth returns the visible width of a status bar for the given counts. +func statusWidth(c StatusCounts) int { + t := c.Total() + if t == 0 { + return 1 + } + if t > maxStatusBoxes { + return maxStatusBoxes + } + return t +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +// --- view --- + +func (m Model) View() string { + if m.width == 0 { + return "" + } + // On exit, render nothing so the inline picker clears itself; the caller + // prints the outcome (the switched-to branch, or nothing on cancel). + if m.hasResult || m.cancelled { + return "" + } + + lay := m.computeLayout() + var b strings.Builder + + b.WriteString(m.renderTitle()) + b.WriteString("\n") + b.WriteString(m.renderTabs()) + b.WriteString("\n\n") + b.WriteString(m.renderHeaderRow(lay)) + b.WriteString("\n") + b.WriteString(m.renderBody(lay)) + b.WriteString("\n") + if m.searching { + b.WriteString(m.renderSearch()) + b.WriteString("\n") + } + b.WriteString(m.renderFooter()) + + return b.String() +} + +func (m Model) renderTitle() string { + left := titleStyle.Render("Checkout a stack") + right := m.positionIndicator() + if right == "" { + return left + } + gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + return left + } + return left + strings.Repeat(" ", gap) + right +} + +// positionIndicator returns a right-aligned "start–end of total" hint when the +// list is scrolled (more rows than fit at once), or "" when everything fits. +func (m Model) positionIndicator() string { + total := len(m.filtered) + vis := m.bodyHeight() + if total == 0 || total <= vis { + return "" + } + start := m.scrollOffset + 1 + end := m.scrollOffset + vis + if end > total { + end = total + } + return dimStyle.Render(fmt.Sprintf("%d–%d of %d", start, end, total)) +} + +func (m Model) renderTabs() string { + labels := []tab{tabAll, tabLocal, tabRemote} + parts := make([]string, 0, len(labels)) + for _, t := range labels { + if t == m.tab { + parts = append(parts, tabActiveStyle.Render(t.String())) + } else { + parts = append(parts, tabInactiveStyle.Render(t.String())) + } + } + return strings.Join(parts, " ") +} + +func (m Model) renderHeaderRow(lay layout) string { + var b strings.Builder + b.WriteString(" ") // selector column + b.WriteString(headerStyle.Render(padRight("#", lay.num))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Branches", lay.summary))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Base", lay.base))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Status", lay.status))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Type", lay.typ))) + b.WriteString(colSep) + b.WriteString(headerStyle.Render(padRight("Created", lay.created))) + return b.String() +} + +func (m Model) renderBody(lay layout) string { + bodyH := m.bodyHeight() + if len(m.filtered) == 0 { + return m.padBody(emptyStyle.Render(" No stacks match."), bodyH) + } + + var lines []string + end := m.scrollOffset + bodyH + if end > len(m.filtered) { + end = len(m.filtered) + } + for i := m.scrollOffset; i < end; i++ { + lines = append(lines, m.renderRow(m.filtered[i], i == m.cursor, lay)) + } + return m.padBody(strings.Join(lines, "\n"), bodyH) +} + +// padBody pads content with blank lines so the body always occupies bodyH rows, +// keeping the footer anchored. +func (m Model) padBody(content string, bodyH int) string { + lines := strings.Count(content, "\n") + 1 + if content == "" { + lines = 0 + } + if lines >= bodyH { + return content + } + pad := strings.Repeat("\n", bodyH-lines) + if content == "" { + return strings.TrimPrefix(pad, "\n") + } + return content + pad +} + +func (m Model) renderRow(r StackRow, selected bool, lay layout) string { + var b strings.Builder + + // Selector. + selector := " " + if selected { + selector = styleBg(selectorStyle, true).Render("›") + styleBg(lipgloss.NewStyle(), true).Render(" ") + } + b.WriteString(selector) + + // #, Branches, Base, Status, Type, Created. A missing stack number is + // rendered faint so it stays unobtrusive. + numStyle := numberStyle + if r.Number == 0 { + numStyle = dimStyle + } + b.WriteString(renderCell(r.NumberDisplay(), numStyle, lay.num, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderSummaryCell(r, lay.summary, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderCell(r.Base, baseStyle, lay.base, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderStatusCell(r.Status, lay.status, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderCell(r.Type.String(), typeStyle(r.Type), lay.typ, selected)) + b.WriteString(sepCell(selected)) + b.WriteString(renderCell(r.CreatedDisplay(), createdStyle, lay.created, selected)) + + line := b.String() + // Extend the selected-row background to the right edge. + if selected { + gap := m.width - lipgloss.Width(line) + if gap > 0 { + line += styleBg(lipgloss.NewStyle(), true).Render(strings.Repeat(" ", gap)) + } + } + return line +} + +// renderSummaryCell renders "bottom...top" with a dimmed separator, each part +// carrying the selected-row background so styling stays continuous. When the +// text is too wide it falls back to a single truncated segment. +func renderSummaryCell(r StackRow, width int, selected bool) string { + if lipgloss.Width(r.Summary()) > width { + return renderCell(r.Summary(), summaryStyle, width, selected) + } + var seg string + if r.BottomBranch != "" && r.TopBranch != "" && r.BottomBranch != r.TopBranch { + seg = styleBg(summaryStyle, selected).Render(r.BottomBranch) + + styleBg(dotsStyle, selected).Render(branchSep) + + styleBg(summaryStyle, selected).Render(r.TopBranch) + } else { + seg = styleBg(summaryStyle, selected).Render(r.Summary()) + } + if pad := width - lipgloss.Width(seg); pad > 0 { + seg += styleBg(lipgloss.NewStyle(), selected).Render(strings.Repeat(" ", pad)) + } + return seg +} + +func (m Model) renderSearch() string { + return searchLabelStyle.Render("/ ") + searchTextStyle.Render(m.query) + dimStyle.Render("▏") +} + +func (m Model) renderFooter() string { + var pairs [][2]string + if m.searching { + pairs = [][2]string{ + {"↑↓", "navigate"}, + {"enter", "select"}, + {"esc", "clear search"}, + } + } else { + pairs = [][2]string{ + {"↑↓", "navigate"}, + {"←→", "tabs"}, + {"/", "search"}, + {"enter", "select"}, + {"esc", "quit"}, + } + } + parts := make([]string, 0, len(pairs)) + for _, p := range pairs { + parts = append(parts, footerKeyStyle.Render(p[0])+" "+footerDescStyle.Render(p[1])) + } + return strings.Join(parts, footerSepStyle.Render(" · ")) +} + +// --- cell rendering helpers --- + +// styleBg adds the selected-row background to a style when selected. +func styleBg(st lipgloss.Style, selected bool) lipgloss.Style { + if selected { + return st.Background(shared.ColorRowShade) + } + return st +} + +// renderCell renders text in st, truncated/padded to width, applying the +// selected-row background to text and padding when selected. +func renderCell(text string, st lipgloss.Style, width int, selected bool) string { + text = truncate(text, width) + rendered := styleBg(st, selected).Render(text) + if pad := width - lipgloss.Width(rendered); pad > 0 { + rendered += styleBg(lipgloss.NewStyle(), selected).Render(strings.Repeat(" ", pad)) + } + return rendered +} + +// renderStatusCell renders the status bar padded to width, shaded when selected. +func renderStatusCell(c StatusCounts, width int, selected bool) string { + bar := statusBar(c, selected) + if pad := width - lipgloss.Width(bar); pad > 0 { + bar += styleBg(lipgloss.NewStyle(), selected).Render(strings.Repeat(" ", pad)) + } + return bar +} + +// sepCell renders the inter-column separator, shaded when selected. +func sepCell(selected bool) string { + if selected { + return styleBg(lipgloss.NewStyle(), true).Render(colSep) + } + return colSep +} diff --git a/internal/tui/checkoutview/model_test.go b/internal/tui/checkoutview/model_test.go new file mode 100644 index 0000000..8f6c71e --- /dev/null +++ b/internal/tui/checkoutview/model_test.go @@ -0,0 +1,292 @@ +package checkoutview + +import ( + "fmt" + "regexp" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/gh-stack/internal/stack" +) + +var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func stripANSI(s string) string { return ansiRe.ReplaceAllString(s, "") } + +func runeKey(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } + +// drive applies a sequence of messages to the model and returns the result. +func drive(m Model, msgs ...tea.Msg) Model { + var tm tea.Model = m + for _, msg := range msgs { + tm, _ = tm.Update(msg) + } + return tm.(Model) +} + +func sampleRows() []StackRow { + now := time.Now() + return []StackRow{ + {Number: 0, Type: TypeLocal, BottomBranch: "wip-a", TopBranch: "wip-b", Base: "main", + Status: StatusCounts{Unpushed: 2}, LocalStack: &stack.Stack{Number: 0}}, + {Number: 55, Type: TypeRemote, BottomBranch: "ref-1", TopBranch: "ref-3", Base: "main", + Status: StatusCounts{Open: 3}, HasCreated: true, Created: now.Add(-time.Hour)}, + {Number: 42, Type: TypeLocal, BottomBranch: "api-1", TopBranch: "api-3", Base: "trunk", + Status: StatusCounts{Merged: 1, Open: 1, Unpushed: 1}, HasCreated: true, Created: now.Add(-72 * time.Hour), + LocalStack: &stack.Stack{Number: 42}}, + } +} + +func sized(m Model) Model { + return drive(m, tea.WindowSizeMsg{Width: 100, Height: 20}) +} + +func TestNew_InitialState(t *testing.T) { + m := New(sampleRows()) + assert.Equal(t, tabAll, m.tab) + assert.Len(t, m.filtered, 3) + assert.Equal(t, 0, m.cursor) + assert.False(t, m.searching) +} + +func TestTabFiltering(t *testing.T) { + m := sized(New(sampleRows())) + + // All -> Local (one Right). + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, tabLocal, m.tab) + require.Len(t, m.filtered, 2) + for _, r := range m.filtered { + assert.Equal(t, TypeLocal, r.Type) + } + + // Local -> Remote (another Right). + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, tabRemote, m.tab) + require.Len(t, m.filtered, 1) + assert.Equal(t, 55, m.filtered[0].Number) + + // Remote -> wraps back to All (another Right). + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, tabAll, m.tab) + assert.Len(t, m.filtered, 3) + + // Left wraps backwards to Remote. + m = drive(m, tea.KeyMsg{Type: tea.KeyLeft}) + assert.Equal(t, tabRemote, m.tab) +} + +func TestTabSwitchResetsCursor(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 2, m.cursor) + m = drive(m, tea.KeyMsg{Type: tea.KeyRight}) + assert.Equal(t, 0, m.cursor, "switching tabs resets the cursor") +} + +func TestSearchFiltering(t *testing.T) { + m := sized(New(sampleRows())) + + m = drive(m, runeKey("/")) + assert.True(t, m.searching) + + m = drive(m, runeKey("a"), runeKey("p"), runeKey("i")) + assert.Equal(t, "api", m.query) + require.Len(t, m.filtered, 1) + assert.Equal(t, 42, m.filtered[0].Number) + + // Backspace widens the results again. + m = drive(m, tea.KeyMsg{Type: tea.KeyBackspace}) + assert.Equal(t, "ap", m.query) + + // Esc clears the search and restores all rows. + m = drive(m, tea.KeyMsg{Type: tea.KeyEsc}) + assert.False(t, m.searching) + assert.Equal(t, "", m.query) + assert.Len(t, m.filtered, 3) +} + +func TestSearchMatchesNumberAndType(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("5"), runeKey("5")) + require.Len(t, m.filtered, 1) + assert.Equal(t, 55, m.filtered[0].Number) + + m = drive(m, tea.KeyMsg{Type: tea.KeyEsc}) + m = drive(m, runeKey("/"), runeKey("r"), runeKey("e"), runeKey("m")) + require.Len(t, m.filtered, 1) + assert.Equal(t, TypeRemote, m.filtered[0].Type) +} + +func TestCursorNavigationClamps(t *testing.T) { + m := sized(New(sampleRows())) + // Up at the top stays at 0. + m = drive(m, tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, 0, m.cursor) + // Down past the end clamps to the last row. + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 2, m.cursor) +} + +func TestEnterSelectsLocalRow(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, tea.KeyMsg{Type: tea.KeyEnter}) + + row, ok := m.Result() + require.True(t, ok) + assert.Equal(t, 0, row.Number) + assert.Equal(t, TypeLocal, row.Type) + require.NotNil(t, row.LocalStack) + assert.False(t, m.Cancelled()) +} + +func TestEnterSelectsRemoteRow(t *testing.T) { + m := sized(New(sampleRows())) + // Move to the remote-only stack (#55, index 1). + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}, tea.KeyMsg{Type: tea.KeyEnter}) + + row, ok := m.Result() + require.True(t, ok) + assert.Equal(t, 55, row.Number) + assert.Equal(t, TypeRemote, row.Type) + assert.Nil(t, row.LocalStack) +} + +func TestCancelKeys(t *testing.T) { + for _, msg := range []tea.Msg{ + tea.KeyMsg{Type: tea.KeyEsc}, + runeKey("q"), + tea.KeyMsg{Type: tea.KeyCtrlC}, + } { + m := sized(New(sampleRows())) + m = drive(m, msg) + _, ok := m.Result() + assert.False(t, ok, "no selection after cancel") + assert.True(t, m.Cancelled()) + } +} + +func TestEnterWithNoMatchesDoesNothing(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("z"), runeKey("z"), runeKey("z")) + require.Empty(t, m.filtered) + + m = drive(m, tea.KeyMsg{Type: tea.KeyEnter}) + _, ok := m.Result() + assert.False(t, ok, "Enter selects nothing when the list is empty") + assert.False(t, m.Cancelled()) +} + +func TestQTypesIntoSearchInsteadOfQuitting(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("q")) + assert.True(t, m.searching) + assert.Equal(t, "q", m.query) + assert.False(t, m.Cancelled()) +} + +func TestView_RendersColumnsAndRows(t *testing.T) { + m := sized(New(sampleRows())) + out := stripANSI(m.View()) + + assert.Contains(t, out, "Checkout a stack") + for _, col := range []string{"#", "Branches", "Base", "Status", "Type", "Created"} { + assert.Contains(t, out, col) + } + assert.Contains(t, out, "wip-a...wip-b") + assert.Contains(t, out, "Remote") + assert.Contains(t, out, "1h ago") + assert.Contains(t, out, "/ search") +} + +func TestView_SearchFooterAndPrompt(t *testing.T) { + m := sized(New(sampleRows())) + m = drive(m, runeKey("/"), runeKey("a")) + out := stripANSI(m.View()) + assert.Contains(t, out, "/ a") + assert.Contains(t, out, "clear search") +} + +func TestView_EmptyStateMessage(t *testing.T) { + m := sized(New(nil)) + out := stripANSI(m.View()) + assert.Contains(t, out, "No stacks match.") +} + +func TestView_NarrowTerminalDoesNotPanic(t *testing.T) { + m := New(sampleRows()) + for _, size := range []tea.WindowSizeMsg{ + {Width: 20, Height: 6}, + {Width: 1, Height: 1}, + {Width: 40, Height: 3}, + } { + mm := drive(m, size) + assert.NotPanics(t, func() { + _ = mm.View() + }) + } +} + +func TestView_ZeroSizeReturnsEmpty(t *testing.T) { + m := New(sampleRows()) + assert.Equal(t, "", m.View()) +} + +func manyRows(n int) []StackRow { + rows := make([]StackRow, n) + for i := 0; i < n; i++ { + rows[i] = StackRow{ + Number: 1000 + i, Type: TypeRemote, + BottomBranch: fmt.Sprintf("b%d", i), TopBranch: fmt.Sprintf("t%d", i), + Base: "main", Status: StatusCounts{Open: 2}, + } + } + return rows +} + +func TestView_InlineHeightIsBounded(t *testing.T) { + // A long list on a tall terminal must not take over the screen: it shows at + // most maxVisibleRows rows plus a little chrome, and offers a scroll hint. + m := drive(New(manyRows(30)), tea.WindowSizeMsg{Width: 90, Height: 50}) + lines := len(strings.Split(m.View(), "\n")) + assert.LessOrEqual(t, lines, maxVisibleRows+6, "picker must not fill a tall terminal") + assert.GreaterOrEqual(t, lines, maxVisibleRows, "shows up to maxVisibleRows rows") + assert.Contains(t, stripANSI(m.View()), "of 30", "shows a scroll position indicator") +} + +func TestView_ShrinksToShortTerminal(t *testing.T) { + m := drive(New(manyRows(30)), tea.WindowSizeMsg{Width: 90, Height: 12}) + lines := len(strings.Split(m.View(), "\n")) + assert.LessOrEqual(t, lines, 12, "must fit within a short terminal") + assert.Less(t, lines, maxVisibleRows+6) +} + +func TestView_ClearsOnExit(t *testing.T) { + selected := drive(sized(New(sampleRows())), tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, "", selected.View(), "inline picker clears itself after selection") + + cancelled := drive(sized(New(sampleRows())), tea.KeyMsg{Type: tea.KeyEsc}) + assert.Equal(t, "", cancelled.View(), "inline picker clears itself after cancel") +} + +func TestView_NoScrollIndicatorWhenAllFit(t *testing.T) { + m := sized(New(sampleRows())) // 3 rows on a 20-line terminal + assert.NotContains(t, stripANSI(m.View()), " of ") +} + +func TestStatusBar_EmptyAndColors(t *testing.T) { + assert.Equal(t, "—", stripANSI(statusBar(StatusCounts{}, false))) + bar := stripANSI(statusBar(StatusCounts{Merged: 1, Open: 2}, false)) + assert.Equal(t, strings.Repeat(statusBox, 3), bar) +} + +func TestStatusBar_CapsLargeStacks(t *testing.T) { + // A huge stack is summarized into at most maxStatusBoxes cells. + bar := stripANSI(statusBar(StatusCounts{Merged: 40, Open: 40, Closed: 20}, false)) + assert.Equal(t, maxStatusBoxes, len([]rune(bar))) +} diff --git a/internal/tui/checkoutview/styles.go b/internal/tui/checkoutview/styles.go new file mode 100644 index 0000000..5cc811a --- /dev/null +++ b/internal/tui/checkoutview/styles.go @@ -0,0 +1,187 @@ +package checkoutview + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + + "github.com/github/gh-stack/internal/tui/shared" +) + +// maxStatusBoxes caps the number of cells in the status bar. The bar is a +// high-level summary, not one cell per PR: larger stacks are downsampled +// proportionally across these cells so a 3-branch and a 100-PR stack both stay +// compact. +const maxStatusBoxes = 5 + +// statusBox is the glyph used for each cell in the status bar. A lower +// three-quarters block is full width (so cells read as one continuous bar, like +// a full block) but only ~75% of the cell height, leaving an empty strip at the +// top of every cell. That keeps a consistent vertical gap between rows across +// terminals — a full block █ has no gap and merges vertically on terminals with +// no line spacing (e.g. Ghostty), while ■ renders rounded on some terminals. +const statusBox = "▆" + +// Muted status-bar colors: each is a blend of the corresponding vivid PR-state +// color toward the faint text gray, so the bar stays legible but understated in +// both light and dark terminals. +var ( + statusMergedColor = lipgloss.AdaptiveColor{Dark: "#957dc1", Light: "#816abf"} // muted purple + statusOpenColor = lipgloss.AdaptiveColor{Dark: "#509661", Light: "#488462"} // muted green + statusClosedColor = lipgloss.AdaptiveColor{Dark: "#b55d5d", Light: "#ab515d"} // muted red +) + +var ( + // Title and tab strip. + titleStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Bold(true) + tabActiveStyle = lipgloss.NewStyle().Foreground(shared.ColorText).Background(shared.ColorRowShade).Bold(true).Padding(0, 1) + tabInactiveStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Padding(0, 1) + + // Table chrome. + headerStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted).Bold(true) + selectorStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) + + // Columns. + numberStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + summaryStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + dotsStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) + baseStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + localTypeStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + remoteTypeStyle = lipgloss.NewStyle().Foreground(shared.ColorPurple) + createdStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + dimStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) + + // Status squares use muted, desaturated variants of the PR-state colors so + // the bar reads as a subtle progress indicator and does not pull focus from + // the leading columns. Unpushed uses the faint text color (least prominent). + statusMergedStyle = lipgloss.NewStyle().Foreground(statusMergedColor) + statusOpenStyle = lipgloss.NewStyle().Foreground(statusOpenColor) + statusClosedStyle = lipgloss.NewStyle().Foreground(statusClosedColor) + statusUnpushedStyle = lipgloss.NewStyle().Foreground(shared.ColorTextFaint) + + // Search + footer. + searchLabelStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent).Bold(true) + searchTextStyle = lipgloss.NewStyle().Foreground(shared.ColorText) + footerKeyStyle = lipgloss.NewStyle().Foreground(shared.ColorAccent) + footerDescStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) + footerSepStyle = lipgloss.NewStyle().Foreground(shared.ColorBorder) + emptyStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted) +) + +// typeStyle returns the style for a stack's Type column. +func typeStyle(t StackType) lipgloss.Style { + if t == TypeRemote { + return remoteTypeStyle + } + return localTypeStyle +} + +// statusBar renders a stack's composition as colored squares: purple (merged), +// green (open), red (closed), gray (unpushed), left to right. It returns "—" +// for an empty stack. Stacks with more than maxStatusBoxes branches are +// downsampled proportionally. When selected, each square carries the +// selected-row background so the shade is continuous. +func statusBar(c StatusCounts, selected bool) string { + if c.Total() == 0 { + return styleBg(dimStyle, selected).Render("—") + } + boxes := distribute(c.boxOrder(), maxStatusBoxes) + styles := []lipgloss.Style{statusMergedStyle, statusOpenStyle, statusClosedStyle, statusUnpushedStyle} + var b strings.Builder + for i, n := range boxes { + if n <= 0 { + continue + } + b.WriteString(styleBg(styles[i], selected).Render(strings.Repeat(statusBox, n))) + } + return b.String() +} + +// boxOrder returns the counts in status-bar render order. +func (c StatusCounts) boxOrder() []int { + return []int{c.Merged, c.Open, c.Closed, c.Unpushed} +} + +// distribute scales counts so they sum to at most max, using the largest- +// remainder method when the total exceeds max. When the total already fits, the +// counts are returned unchanged (one square per branch). +func distribute(counts []int, max int) []int { + total := 0 + for _, n := range counts { + total += n + } + if total <= max { + out := make([]int, len(counts)) + copy(out, counts) + return out + } + + out := make([]int, len(counts)) + rem := make([]float64, len(counts)) + assigned := 0 + for i, n := range counts { + q := float64(n) * float64(max) / float64(total) + out[i] = int(q) + rem[i] = q - float64(out[i]) + assigned += out[i] + } + for assigned < max { + best, bestI := -1.0, -1 + for i := range counts { + if rem[i] > best { + best, bestI = rem[i], i + } + } + if bestI < 0 { + break + } + out[bestI]++ + rem[bestI] = -1 + assigned++ + } + return out +} + +// padRight pads s with spaces to at least width visible columns. +func padRight(s string, width int) string { + w := lipgloss.Width(s) + if w >= width { + return s + } + return s + strings.Repeat(" ", width-w) +} + +// truncate shortens s to at most width visible columns, appending an ellipsis +// when it had to cut. It resets styling at the cut so trailing ANSI does not +// leak. +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + var b strings.Builder + w := 0 + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + } + if inEscape { + b.WriteRune(r) + if r == 'm' { + inEscape = false + } + continue + } + if w >= width-1 { + b.WriteString("…") + b.WriteString("\x1b[0m") + break + } + b.WriteRune(r) + w++ + } + return b.String() +} From 7f71e159f79cb01bfa411aac4792bdb143a9c965 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 16:16:29 -0400 Subject: [PATCH 2/3] search by entire branch list --- internal/tui/checkoutview/data.go | 13 +++++++++++++ internal/tui/checkoutview/data_test.go | 25 +++++++++++++++++++++++++ internal/tui/checkoutview/model.go | 9 ++++++--- internal/tui/checkoutview/model_test.go | 19 +++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/tui/checkoutview/data.go b/internal/tui/checkoutview/data.go index 3c4f3f5..4bd2e02 100644 --- a/internal/tui/checkoutview/data.go +++ b/internal/tui/checkoutview/data.go @@ -66,6 +66,11 @@ type StackRow struct { Created time.Time HasCreated bool + // Branches is the full ordered list of branch names in the stack. Only + // BottomBranch and TopBranch are displayed; the complete list exists so + // search can match any branch, including mid-stack ones. + Branches []string + // LocalStack points at the local stack when Type == TypeLocal, so the caller // can check out its branches directly without cloning. It is nil for // remote-only rows, which are cloned by stack number instead. @@ -185,6 +190,10 @@ func localRow(ls *stack.Stack, rs *github.RemoteStack) (StackRow, bool) { row.BottomBranch = ls.Branches[0].Branch row.TopBranch = ls.Branches[len(ls.Branches)-1].Branch } + row.Branches = make([]string, len(ls.Branches)) + for i := range ls.Branches { + row.Branches[i] = ls.Branches[i].Branch + } if rs != nil { if rs.Number != 0 { @@ -219,6 +228,10 @@ func remoteRow(rs *github.RemoteStack) (StackRow, bool) { BottomBranch: rs.PRDetails[0].Head.Ref, TopBranch: rs.PRDetails[len(rs.PRDetails)-1].Head.Ref, } + row.Branches = make([]string, len(rs.PRDetails)) + for i, p := range rs.PRDetails { + row.Branches[i] = p.Head.Ref + } if t, ok := parseTime(rs.CreatedAt); ok { row.Created, row.HasCreated = t, true } diff --git a/internal/tui/checkoutview/data_test.go b/internal/tui/checkoutview/data_test.go index 66144ec..31aacf3 100644 --- a/internal/tui/checkoutview/data_test.go +++ b/internal/tui/checkoutview/data_test.go @@ -219,6 +219,31 @@ func TestBuildRows_ClosedNonMergedStackIsKept(t *testing.T) { assert.Equal(t, StatusCounts{Closed: 2}, rows[0].Status) } +func TestBuildRows_PopulatesAllBranchesForSearch(t *testing.T) { + local := []stack.Stack{{ + Number: 3, + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{ + {Branch: "a"}, {Branch: "b"}, {Branch: "c"}, + }, + }} + rows := BuildRows(local, nil) + require.Len(t, rows, 1) + assert.Equal(t, []string{"a", "b", "c"}, rows[0].Branches, "local rows carry every branch name for search") + + remote := []github.RemoteStack{{ + ID: 1, Number: 9, Base: github.RemoteStackBase{Ref: "main"}, + PRDetails: []github.RemoteStackPR{ + {Number: 1, State: "open", Head: github.RemoteStackPRHead{Ref: "r1"}}, + {Number: 2, State: "open", Head: github.RemoteStackPRHead{Ref: "r2"}}, + {Number: 3, State: "open", Head: github.RemoteStackPRHead{Ref: "r3"}}, + }, + }} + rrows := BuildRows(nil, remote) + require.Len(t, rrows, 1) + assert.Equal(t, []string{"r1", "r2", "r3"}, rrows[0].Branches, "remote rows carry every branch name for search") +} + func TestBuildRows_UntrackedLocalWithMergedPRFlag(t *testing.T) { local := []stack.Stack{{ Number: 3, diff --git a/internal/tui/checkoutview/model.go b/internal/tui/checkoutview/model.go index 004192c..7775d35 100644 --- a/internal/tui/checkoutview/model.go +++ b/internal/tui/checkoutview/model.go @@ -214,9 +214,12 @@ func matchesQuery(r StackRow, q string) bool { if q == "" { return true } - hay := strings.ToLower(strings.Join([]string{ - r.NumberDisplay(), r.Summary(), r.Base, r.Type.String(), - }, " ")) + // Search the stack number, base, type, and every branch name — including + // mid-stack branches that are not shown in the Branches column. + parts := make([]string, 0, len(r.Branches)+5) + parts = append(parts, r.NumberDisplay(), r.Base, r.Type.String(), r.BottomBranch, r.TopBranch) + parts = append(parts, r.Branches...) + hay := strings.ToLower(strings.Join(parts, " ")) return strings.Contains(hay, q) } diff --git a/internal/tui/checkoutview/model_test.go b/internal/tui/checkoutview/model_test.go index 8f6c71e..d70d368 100644 --- a/internal/tui/checkoutview/model_test.go +++ b/internal/tui/checkoutview/model_test.go @@ -123,6 +123,25 @@ func TestSearchMatchesNumberAndType(t *testing.T) { assert.Equal(t, TypeRemote, m.filtered[0].Type) } +func TestSearchMatchesMidStackBranch(t *testing.T) { + rows := []StackRow{ + {Number: 7, Type: TypeLocal, BottomBranch: "feat/bottom", TopBranch: "feat/top", + Branches: []string{"feat/bottom", "feat/middle-xyz", "feat/top"}, Base: "main"}, + {Number: 8, Type: TypeRemote, BottomBranch: "other/a", TopBranch: "other/b", + Branches: []string{"other/a", "other/b"}, Base: "main"}, + } + m := sized(New(rows)) + m = drive(m, runeKey("/")) + for _, r := range "middle" { + m = drive(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + + require.Len(t, m.filtered, 1, "a mid-stack branch name should match") + assert.Equal(t, 7, m.filtered[0].Number) + // The matched mid-stack branch is not shown in the Branches column. + assert.NotContains(t, stripANSI(m.View()), "middle-xyz") +} + func TestCursorNavigationClamps(t *testing.T) { m := sized(New(sampleRows())) // Up at the top stays at 0. From d7e031376ebcca60a351b18e2d5f112855749998 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Tue, 14 Jul 2026 17:09:17 -0400 Subject: [PATCH 3/3] address review comments --- README.md | 2 +- docs/src/content/docs/reference/cli.md | 2 +- internal/tui/checkoutview/model.go | 23 +++++++++++++++++++++- internal/tui/checkoutview/model_test.go | 26 +++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ad1ef62..c280525 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ When a remote stack is referenced, the command fetches the stack on GitHub, pull When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. +When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, closed, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. **Examples:** diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index d233c94..2e356ac 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -147,7 +147,7 @@ When a remote stack is referenced, the command fetches the stack on GitHub, pull When a branch name is provided, the command resolves it against locally tracked stacks only. -When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. +When run without arguments in an interactive terminal, opens a searchable picker listing every stack available to you — both the stacks tracked locally and the stacks that exist only on GitHub. Each row shows the stack number, its bottom and top branch, base branch, a status bar summarizing how many of its pull requests are merged, open, closed, or not yet pushed, and whether the stack is available locally or only on the remote. Filter with the All / Local / Remote tabs or type `/` to search; fully merged stacks are omitted. Selecting a remote-only stack clones it locally before switching to it. **Examples:** diff --git a/internal/tui/checkoutview/model.go b/internal/tui/checkoutview/model.go index 7775d35..d500d61 100644 --- a/internal/tui/checkoutview/model.go +++ b/internal/tui/checkoutview/model.go @@ -137,6 +137,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Search) && !m.searching: m.searching = true + // Entering search adds the search line, shrinking the body; keep the + // cursor visible so Enter can't select an off-screen row. + m.ensureVisible() return m, nil case !m.searching && key.Matches(msg, keys.Quit): @@ -378,7 +381,25 @@ func (m Model) View() string { } b.WriteString(m.renderFooter()) - return b.String() + // Guarantee no rendered line exceeds the terminal width. Otherwise a line + // wraps, the inline renderer's line count is off, and the bounded/clear + // behavior breaks on narrow terminals. + return clampToWidth(b.String(), m.width) +} + +// clampToWidth truncates every line of s to at most width cells so nothing +// wraps. +func clampToWidth(s string, width int) string { + if width <= 0 { + return s + } + lines := strings.Split(s, "\n") + for i, ln := range lines { + if lipgloss.Width(ln) > width { + lines[i] = truncate(ln, width) + } + } + return strings.Join(lines, "\n") } func (m Model) renderTitle() string { diff --git a/internal/tui/checkoutview/model_test.go b/internal/tui/checkoutview/model_test.go index d70d368..0b6ce2e 100644 --- a/internal/tui/checkoutview/model_test.go +++ b/internal/tui/checkoutview/model_test.go @@ -8,6 +8,7 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -251,6 +252,31 @@ func TestView_NarrowTerminalDoesNotPanic(t *testing.T) { } } +func TestView_NoLineExceedsWidth(t *testing.T) { + // Every rendered line must fit within the terminal width so nothing wraps + // (which would break the inline picker's bounded height). + m := New(sampleRows()) + for _, w := range []int{100, 60, 40, 24, 12} { + mm := drive(m, tea.WindowSizeMsg{Width: w, Height: 20}) + for _, ln := range strings.Split(mm.View(), "\n") { + assert.LessOrEqualf(t, lipgloss.Width(ln), w, "line wider than %d: %q", w, stripANSI(ln)) + } + } +} + +func TestSearchTogglingKeepsCursorVisible(t *testing.T) { + // On a short terminal, entering search shrinks the body; the selected row + // must not scroll out of view (Enter would otherwise select a hidden row). + m := drive(New(manyRows(20)), tea.WindowSizeMsg{Width: 90, Height: 11}) + for m.cursor < m.bodyHeight()-1 { + m = drive(m, tea.KeyMsg{Type: tea.KeyDown}) + } + m = drive(m, runeKey("/")) + require.True(t, m.searching) + assert.GreaterOrEqual(t, m.cursor, m.scrollOffset) + assert.Less(t, m.cursor, m.scrollOffset+m.bodyHeight(), "cursor stays visible after entering search") +} + func TestView_ZeroSizeReturnsEmpty(t *testing.T) { m := New(sampleRows()) assert.Equal(t, "", m.View())