From 836121bf7157ed3d667ce4a845ea25512d2f30be Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 15 Jun 2026 20:41:48 -0400 Subject: [PATCH 1/7] Add submitview data model and PR draft override plumbing Introduce the internal/tui/submitview package that will back the new interactive `gh stack submit` TUI, and wire its per-PR override contract into the submit command without changing current behavior. - submitview: BranchState model (NEW/OPEN/DRAFT/QUEUED/MERGED/CLOSED) with selectability/editability rules, SubmitNode UI state with edit detection, PRDraft override type, state derivation, title/description prefill, and state-badge/panel/tab styles. - submit: refactor ensurePR/createPR to accept an optional per-branch override map (title/body/draft/include); deselected NEW branches are pushed but get no PR. The override map is nil on the --auto / non-interactive path, so the agent-compat contract is unchanged. Fully unit tested. --- cmd/submit.go | 78 ++++++--- cmd/submit_test.go | 103 +++++++++++- internal/tui/submitview/data.go | 222 +++++++++++++++++++++++++ internal/tui/submitview/data_test.go | 214 ++++++++++++++++++++++++ internal/tui/submitview/styles.go | 131 +++++++++++++++ internal/tui/submitview/styles_test.go | 42 +++++ internal/tui/submitview/types.go | 149 +++++++++++++++++ internal/tui/submitview/types_test.go | 119 +++++++++++++ 8 files changed, 1030 insertions(+), 28 deletions(-) create mode 100644 internal/tui/submitview/data.go create mode 100644 internal/tui/submitview/data_test.go create mode 100644 internal/tui/submitview/styles.go create mode 100644 internal/tui/submitview/styles_test.go create mode 100644 internal/tui/submitview/types.go create mode 100644 internal/tui/submitview/types_test.go diff --git a/cmd/submit.go b/cmd/submit.go index 1824bcf..c788b2d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -14,6 +14,7 @@ import ( "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/pr" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/submitview" "github.com/spf13/cobra" ) @@ -181,6 +182,12 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // Push each branch and create/update its PR in stack order (bottom to top). // Sequential pushing ensures each branch's base is up-to-date on the // remote before the next branch is pushed, preventing race conditions. + // + // drafts carries per-PR overrides from the interactive editor. It is nil on + // the --auto / non-interactive path, in which case ensurePR/createPR fall + // back to auto-generated titles and bodies (today's behavior). + var drafts map[string]*submitview.PRDraft + cfg.Printf("Pushing to %s...", remote) for i, b := range s.Branches { if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() { @@ -195,7 +202,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // Find or create PR, and fix base if needed baseBranch := s.ActiveBaseBranch(b.Branch) - if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent); err != nil { + if err := ensurePR(cfg, client, s, i, baseBranch, opts, templateContent, drafts); err != nil { if errors.Is(err, errInterrupt) { printInterrupt(cfg) return ErrSilent @@ -225,7 +232,11 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // ensurePR finds or creates a PR for the branch at index i, and updates // its base branch if needed. This is the single place where PR state is // reconciled during submit. -func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error { +// +// drafts holds optional per-branch overrides from the interactive editor. When +// a NEW branch has been deselected in the editor, it is pushed for stack +// consistency but no PR is created for it. +func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error { b := s.Branches[i] pr, err := client.FindPRForBranch(b.Branch) @@ -235,7 +246,12 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int } if pr == nil { - return createPR(cfg, client, s, i, baseBranch, opts, templateContent) + // A NEW branch the user deselected in the editor: pushed for stack + // consistency, but intentionally left without a PR. + if d := drafts[b.Branch]; d != nil && !d.Include { + return nil + } + return createPR(cfg, client, s, i, baseBranch, opts, templateContent, drafts) } // PR exists — record it and fix base if needed. @@ -292,30 +308,52 @@ func ensurePR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int } // createPR creates a new PR for the branch at index i. -func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string) error { +// +// When the interactive editor has supplied a draft override for this branch +// (drafts[branch] != nil), its title, body, and draft state are used verbatim +// — the attribution footer is appended via generatePRBody. Otherwise the +// auto-generated title/body path (with an optional line prompt in interactive +// mode) is used, preserving today's --auto / non-interactive behavior. +func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int, baseBranch string, opts *submitOptions, templateContent string, drafts map[string]*submitview.PRDraft) error { b := s.Branches[i] - title, commitBody := defaultPRTitleBody(baseBranch, b.Branch) - originalTitle := title - if !opts.auto && cfg.IsInteractive() { - input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) - if err != nil { - if isInterruptError(err) { - return errInterrupt + var title, body string + isDraft := !opts.open + + if d := drafts[b.Branch]; d != nil { + // Interactive editor override. The user already edited the description + // in the TUI (prefilled from the repo template when one exists), so + // d.Body is the final body. Pass no template so generatePRBody keeps the + // user's text and only appends the attribution footer, rather than + // discarding their edits in favor of the raw template. + title = d.Title + body = generatePRBody(d.Body, "") + isDraft = d.Draft + } else { + // Auto / non-interactive default path. + var commitBody string + title, commitBody = defaultPRTitleBody(baseBranch, b.Branch) + originalTitle := title + if !opts.auto && cfg.IsInteractive() { + input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) + if err != nil { + if isInterruptError(err) { + return errInterrupt + } + // Non-interrupt error: keep the auto-generated title. + } else if input != "" { + title = input } - // Non-interrupt error: keep the auto-generated title. - } else if input != "" { - title = input } - } - prBody := commitBody - if title != originalTitle && commitBody != "" { - prBody = originalTitle + "\n\n" + commitBody + prBody := commitBody + if title != originalTitle && commitBody != "" { + prBody = originalTitle + "\n\n" + commitBody + } + body = generatePRBody(prBody, templateContent) } - body := generatePRBody(prBody, templateContent) - newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, !opts.open) + newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft) if createErr != nil { cfg.Warningf("failed to create PR for %s: %v", b.Branch, createErr) return nil diff --git a/cmd/submit_test.go b/cmd/submit_test.go index ae010d9..06f0091 100644 --- a/cmd/submit_test.go +++ b/cmd/submit_test.go @@ -15,6 +15,7 @@ import ( "github.com/github/gh-stack/internal/github" "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/submitview" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -187,7 +188,7 @@ func TestSubmit_DefaultDraft(t *testing.T) { cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, + ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { createdDraft = draft @@ -229,7 +230,7 @@ func TestSubmit_OpenFlag(t *testing.T) { cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, + ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { createdDraft = draft @@ -1649,7 +1650,7 @@ func TestSubmit_UsesPRTemplate(t *testing.T) { cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, + ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { capturedBody = body @@ -1696,7 +1697,7 @@ func TestSubmit_NoTemplate_UsesFooter(t *testing.T) { cfg, _, _ := config.NewTestConfig() cfg.GitHubClientOverride = &github.MockClient{ - ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, + ListStacksFn: func() ([]github.RemoteStack, error) { return nil, nil }, FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { capturedBody = body @@ -1835,8 +1836,8 @@ func TestSubmit_DisablesAutoMergeOnExistingPR(t *testing.T) { case "b2": return &github.PullRequest{ Number: 20, ID: "PR_20", - URL: "https://github.com/owner/repo/pull/20", - BaseRefName: "b1", HeadRefName: "b2", + URL: "https://github.com/owner/repo/pull/20", + BaseRefName: "b1", HeadRefName: "b2", AutoMergeRequest: &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"}, }, nil } @@ -1890,8 +1891,8 @@ func TestSubmit_DisableAutoMergeFailure_ContinuesWithWarning(t *testing.T) { FindPRForBranchFn: func(branch string) (*github.PullRequest, error) { return &github.PullRequest{ Number: 10, ID: "PR_10", - URL: "https://github.com/owner/repo/pull/10", - BaseRefName: "main", HeadRefName: "b1", + URL: "https://github.com/owner/repo/pull/10", + BaseRefName: "main", HeadRefName: "b1", AutoMergeRequest: &github.AutoMergeRequest{EnabledAt: "2024-01-01T00:00:00Z"}, }, nil }, @@ -1963,3 +1964,89 @@ func TestSubmit_NoAutoMerge_SkipsDisable(t *testing.T) { assert.NoError(t, err) } + +// --- Per-PR draft override plumbing (interactive editor contract) --- + +func TestCreatePR_UsesDraftOverride(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + } + + var gotTitle, gotBody string + var gotDraft bool + cfg, _, _ := config.NewTestConfig() + client := &github.MockClient{ + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + gotTitle, gotBody, gotDraft = title, body, draft + return &github.PullRequest{Number: 7, ID: "PR_7", URL: "https://github.com/o/r/pull/7"}, nil + }, + } + + drafts := map[string]*submitview.PRDraft{ + "b1": {Branch: "b1", Include: true, Title: "Custom title", Body: "Custom body", Draft: true}, + } + + // --open would normally force ready; the override's Draft must win. + err := createPR(cfg, client, s, 0, "main", &submitOptions{open: true}, "", drafts) + require.NoError(t, err) + + assert.Equal(t, "Custom title", gotTitle) + assert.Contains(t, gotBody, "Custom body") + assert.Contains(t, gotBody, "GitHub Stacks CLI", "footer is appended at submit time") + assert.True(t, gotDraft, "draft override should be honored over --open") + require.NotNil(t, s.Branches[0].PullRequest) + assert.Equal(t, 7, s.Branches[0].PullRequest.Number) +} + +func TestCreatePR_DraftOverride_KeepsUserBodyOverTemplate(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + } + + var gotBody string + cfg, _, _ := config.NewTestConfig() + client := &github.MockClient{ + CreatePRFn: func(base, head, title, body string, draft bool) (*github.PullRequest, error) { + gotBody = body + return &github.PullRequest{Number: 1, ID: "PR_1"}, nil + }, + } + // The user edited the description in the TUI; the repo also has a template. + // The user's edits must win — the template was only the prefill. + drafts := map[string]*submitview.PRDraft{ + "b1": {Branch: "b1", Include: true, Title: "T", Body: "My edited description"}, + } + + err := createPR(cfg, client, s, 0, "main", &submitOptions{}, "## Raw repo template", drafts) + require.NoError(t, err) + + assert.Contains(t, gotBody, "My edited description", "the user's edited body is used") + assert.NotContains(t, gotBody, "Raw repo template", "the raw template does not override the user's edits") + assert.Contains(t, gotBody, "GitHub Stacks CLI", "the attribution footer is appended") +} + +func TestEnsurePR_DeselectedNewBranchSkipsCreate(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}}, + } + + cfg, _, _ := config.NewTestConfig() + client := &github.MockClient{ + FindPRForBranchFn: func(string) (*github.PullRequest, error) { return nil, nil }, + CreatePRFn: func(string, string, string, string, bool) (*github.PullRequest, error) { + t.Fatal("CreatePR must not be called for a deselected NEW branch") + return nil, nil + }, + } + + drafts := map[string]*submitview.PRDraft{ + "b1": {Branch: "b1", Include: false}, + } + + err := ensurePR(cfg, client, s, 0, "main", &submitOptions{}, "", drafts) + require.NoError(t, err) + assert.Nil(t, s.Branches[0].PullRequest, "no PR should be recorded for a deselected branch") +} diff --git a/internal/tui/submitview/data.go b/internal/tui/submitview/data.go new file mode 100644 index 0000000..de3ef9c --- /dev/null +++ b/internal/tui/submitview/data.go @@ -0,0 +1,222 @@ +package submitview + +import ( + "strings" + + "github.com/github/gh-stack/internal/tui/stackview" +) + +// DeriveState classifies a branch node into a BranchState using both the stack's +// tracked PR reference and any freshly fetched PR details. Merged and queued +// states take priority, followed by closed, draft, and open. A branch with no +// PR at all is NEW. A branch that tracks a PR but for which no fresh details are +// available is treated as open (locked) rather than NEW, so it is never +// presented as editable. +func DeriveState(node stackview.BranchNode) BranchState { + pr := node.PR + ref := node.Ref + + if ref.IsMerged() || (pr != nil && pr.Merged) { + return StateMerged + } + if ref.IsQueued() || (pr != nil && pr.IsQueued) { + return StateQueued + } + if pr != nil { + switch { + case pr.State == "CLOSED": + return StateClosed + case pr.IsDraft: + return StateDraft + default: + return StateOpen + } + } + // A tracked PR reference without fresh details: treat as open (locked), + // never NEW, so we don't offer to create a duplicate PR. + if ref.PullRequest != nil && ref.PullRequest.Number != 0 { + return StateOpen + } + return StateNew +} + +// PrefillTitle returns the default PR title for a node: the subject of the +// branch's first (oldest) commit when it has any commits, otherwise the +// humanized branch name. git.LogRange returns commits newest-first, so the +// oldest commit — the one that established the branch — is the last element. +func PrefillTitle(node stackview.BranchNode) string { + if n := len(node.Commits); n > 0 { + if subject := strings.TrimSpace(node.Commits[n-1].Subject); subject != "" { + return subject + } + } + return humanize(node.Ref.Branch) +} + +// PrefillDescription returns the default PR description following the spec's +// priority order: the repo PR template if one exists, otherwise the single +// commit body, otherwise a bulleted list of commit subjects for multi-commit +// branches. The attribution footer is appended later, at submit time. +func PrefillDescription(node stackview.BranchNode, template string) string { + if t := strings.TrimSpace(template); t != "" { + return t + } + + commits := node.Commits + switch { + case len(commits) == 1: + return strings.TrimSpace(commits[0].Body) + case len(commits) > 1: + var b strings.Builder + // List oldest commit first so the body reads like a changelog. + for i := len(commits) - 1; i >= 0; i-- { + subject := strings.TrimSpace(commits[i].Subject) + if subject == "" { + continue + } + b.WriteString("- ") + b.WriteString(subject) + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) + default: + return "" + } +} + +// NewSubmitNodes builds the per-branch UI state for the submit TUI from loaded +// branch display data. NEW branches default to included; every node's title and +// description are prefilled. New PRs default to ready for review; the per-PR +// draft toggle starts off. The prefill snapshots are retained for edit +// detection. +func NewSubmitNodes(nodes []stackview.BranchNode, template string) []SubmitNode { + out := make([]SubmitNode, len(nodes)) + for i, n := range nodes { + state := DeriveState(n) + title := PrefillTitle(n) + desc := PrefillDescription(n, template) + out[i] = SubmitNode{ + BranchNode: n, + State: state, + Included: state == StateNew, + Title: title, + Description: desc, + titlePrefill: title, + descPrefill: desc, + } + } + return out +} + +// CountNew returns the number of NEW (creatable) branches in the list. +func CountNew(nodes []SubmitNode) int { + n := 0 + for _, node := range nodes { + if node.State == StateNew { + n++ + } + } + return n +} + +// CountSelected returns the number of NEW branches currently marked for +// inclusion. +func CountSelected(nodes []SubmitNode) int { + n := 0 + for _, node := range nodes { + if node.State == StateNew && node.Included { + n++ + } + } + return n +} + +// HasClosed reports whether any branch in the list has a closed PR, which blocks +// the stack and triggers the Step 1 callout. +func HasClosed(nodes []SubmitNode) bool { + for _, node := range nodes { + if node.State == StateClosed { + return true + } + } + return false +} + +// ClosedBranches returns the names of branches with a closed PR, in list order. +func ClosedBranches(nodes []SubmitNode) []string { + var names []string + for _, node := range nodes { + if node.State == StateClosed { + names = append(names, node.Ref.Branch) + } + } + return names +} + +// CommonPrefix returns the longest shared slash-delimited path prefix across the +// given branch names, including a trailing slash. It returns "" when fewer than +// two names are given or there is no shared prefix. This is used to render the +// stack map with short branch names. +func CommonPrefix(names []string) string { + if len(names) < 2 { + return "" + } + + // Split each name into slash-delimited segments and find the longest run of + // leading segments common to all names. + segs := make([][]string, len(names)) + minLen := -1 + for i, n := range names { + segs[i] = strings.Split(n, "/") + // The last segment is the leaf name; only path segments before it can + // be part of a shared prefix. + pathLen := len(segs[i]) - 1 + if minLen == -1 || pathLen < minLen { + minLen = pathLen + } + } + if minLen <= 0 { + return "" + } + + common := 0 + for i := 0; i < minLen; i++ { + seg := segs[0][i] + same := true + for j := 1; j < len(segs); j++ { + if segs[j][i] != seg { + same = false + break + } + } + if !same { + break + } + common++ + } + if common == 0 { + return "" + } + return strings.Join(segs[0][:common], "/") + "/" +} + +// Shortname strips prefix from branch when present, returning the remainder. If +// branch does not start with prefix (or prefix is empty), branch is returned +// unchanged. +func Shortname(branch, prefix string) string { + if prefix == "" { + return branch + } + return strings.TrimPrefix(branch, prefix) +} + +// humanize replaces hyphens and underscores with spaces. It mirrors the helper +// used by the submit command so auto-generated titles match across paths. +func humanize(s string) string { + return strings.Map(func(r rune) rune { + if r == '-' || r == '_' { + return ' ' + } + return r + }, s) +} diff --git a/internal/tui/submitview/data_test.go b/internal/tui/submitview/data_test.go new file mode 100644 index 0000000..d6919df --- /dev/null +++ b/internal/tui/submitview/data_test.go @@ -0,0 +1,214 @@ +package submitview + +import ( + "testing" + + "github.com/github/gh-stack/internal/git" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/stretchr/testify/assert" +) + +// node builds a stackview.BranchNode for a branch with no PR and the given +// commits. +func node(branch string, commits ...git.CommitInfo) stackview.BranchNode { + return stackview.BranchNode{ + Ref: stack.BranchRef{Branch: branch}, + Commits: commits, + } +} + +// withPR attaches fresh PR details to a node. +func withPR(n stackview.BranchNode, pr *ghapi.PRDetails) stackview.BranchNode { + n.PR = pr + return n +} + +// withTrackedPR attaches a tracked PR reference (as persisted in the stack file) +// to a node. +func withTrackedPR(n stackview.BranchNode, ref *stack.PullRequestRef) stackview.BranchNode { + n.Ref.PullRequest = ref + return n +} + +func commit(subject, body string) git.CommitInfo { + return git.CommitInfo{Subject: subject, Body: body} +} + +func TestDeriveState(t *testing.T) { + tests := []struct { + name string + node stackview.BranchNode + want BranchState + }{ + { + name: "no PR is new", + node: node("feat/a"), + want: StateNew, + }, + { + name: "open PR", + node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN"}), + want: StateOpen, + }, + { + name: "draft PR", + node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN", IsDraft: true}), + want: StateDraft, + }, + { + name: "closed PR", + node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "CLOSED"}), + want: StateClosed, + }, + { + name: "merged via PR details", + node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "MERGED", Merged: true}), + want: StateMerged, + }, + { + name: "queued via PR details", + node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN", IsQueued: true}), + want: StateQueued, + }, + { + name: "merged via tracked ref", + node: withTrackedPR(node("feat/a"), &stack.PullRequestRef{Number: 1, Merged: true}), + want: StateMerged, + }, + { + name: "tracked ref without details treated as open", + node: withTrackedPR(node("feat/a"), &stack.PullRequestRef{Number: 7}), + want: StateOpen, + }, + { + name: "merged takes priority over draft", + node: withPR(node("feat/a"), &ghapi.PRDetails{Number: 1, State: "OPEN", IsDraft: true, Merged: true}), + want: StateMerged, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, DeriveState(tt.node)) + }) + } +} + +func TestPrefillTitle(t *testing.T) { + t.Run("single commit uses subject", func(t *testing.T) { + n := node("feat/auth/middleware", commit("Add auth middleware", "body")) + assert.Equal(t, "Add auth middleware", PrefillTitle(n)) + }) + + t.Run("multiple commits use the first (oldest) commit subject", func(t *testing.T) { + // git.LogRange returns commits newest-first, so the last element is the + // oldest — the commit that established the branch. + n := node("feat/auth-middleware", commit("Polish middleware", ""), commit("Add auth middleware", "")) + assert.Equal(t, "Add auth middleware", PrefillTitle(n)) + }) + + t.Run("zero commits humanize branch name", func(t *testing.T) { + n := node("feat/new_feature") + assert.Equal(t, "feat/new feature", PrefillTitle(n)) + }) + + t.Run("blank subject falls back to branch name", func(t *testing.T) { + n := node("feat/x", commit(" ", "")) + assert.Equal(t, "feat/x", PrefillTitle(n)) + }) +} + +func TestPrefillDescription(t *testing.T) { + t.Run("template takes priority", func(t *testing.T) { + n := node("feat/a", commit("subject", "commit body")) + assert.Equal(t, "## Description", PrefillDescription(n, "## Description")) + }) + + t.Run("single commit uses body", func(t *testing.T) { + n := node("feat/a", commit("subject", "Detailed body\nsecond line")) + assert.Equal(t, "Detailed body\nsecond line", PrefillDescription(n, "")) + }) + + t.Run("multi commit lists subjects oldest first", func(t *testing.T) { + // LogRange returns newest first; the body should read oldest first. + n := node("feat/a", commit("newest", ""), commit("middle", ""), commit("oldest", "")) + got := PrefillDescription(n, "") + assert.Equal(t, "- oldest\n- middle\n- newest", got) + }) + + t.Run("no commits no template is empty", func(t *testing.T) { + n := node("feat/a") + assert.Equal(t, "", PrefillDescription(n, "")) + }) +} + +func TestNewSubmitNodes(t *testing.T) { + nodes := []stackview.BranchNode{ + node("feat/a", commit("Add a", "body a")), + withPR(node("feat/b"), &ghapi.PRDetails{Number: 2, State: "OPEN"}), + } + + got := NewSubmitNodes(nodes, "") + + assert.Len(t, got, 2) + + // NEW branch: included by default, prefilled, ready (not draft). + assert.Equal(t, StateNew, got[0].State) + assert.True(t, got[0].Included) + assert.Equal(t, "Add a", got[0].Title) + assert.Equal(t, "body a", got[0].Description) + assert.False(t, got[0].Draft, "new PRs default to ready for review") + assert.False(t, got[0].Edited(), "freshly prefilled node is not edited") + + // OPEN branch: not included, locked. + assert.Equal(t, StateOpen, got[1].State) + assert.False(t, got[1].Included) +} + +func TestCounts(t *testing.T) { + nodes := []SubmitNode{ + {State: StateNew, Included: true}, + {State: StateNew, Included: false}, + {State: StateOpen}, + {State: StateClosed}, + } + assert.Equal(t, 2, CountNew(nodes)) + assert.Equal(t, 1, CountSelected(nodes)) + assert.True(t, HasClosed(nodes)) +} + +func TestClosedBranches(t *testing.T) { + nodes := []SubmitNode{ + {State: StateNew, BranchNode: node("feat/a")}, + {State: StateClosed, BranchNode: node("feat/legacy")}, + } + assert.Equal(t, []string{"feat/legacy"}, ClosedBranches(nodes)) +} + +func TestCommonPrefix(t *testing.T) { + tests := []struct { + name string + names []string + want string + }{ + {"shared two-segment prefix", []string{"feat/auth/a", "feat/auth/b", "feat/auth/c"}, "feat/auth/"}, + {"shared one-segment prefix", []string{"feat/a", "feat/b"}, "feat/"}, + {"no shared prefix", []string{"feat/a", "fix/b"}, ""}, + {"single name has no prefix", []string{"feat/a"}, ""}, + {"empty list", nil, ""}, + {"flat names share nothing", []string{"a", "b"}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, CommonPrefix(tt.names)) + }) + } +} + +func TestShortname(t *testing.T) { + assert.Equal(t, "middleware", Shortname("feat/auth/middleware", "feat/auth/")) + assert.Equal(t, "feat/auth/middleware", Shortname("feat/auth/middleware", "")) + assert.Equal(t, "other/x", Shortname("other/x", "feat/auth/")) +} diff --git a/internal/tui/submitview/styles.go b/internal/tui/submitview/styles.go new file mode 100644 index 0000000..35c0353 --- /dev/null +++ b/internal/tui/submitview/styles.go @@ -0,0 +1,131 @@ +package submitview + +import "github.com/charmbracelet/lipgloss" + +// State foreground colors, aligned with the gh stack view / modify palette. +var stateColors = map[BranchState]lipgloss.Color{ + StateNew: lipgloss.Color("2"), // green + StateOpen: lipgloss.Color("4"), // blue + StateDraft: lipgloss.Color("3"), // amber + StateQueued: lipgloss.Color("130"), // orange + StateMerged: lipgloss.Color("5"), // purple + StateClosed: lipgloss.Color("1"), // red +} + +// State background tints for pill badges (dark 256-color shades that read as a +// low-opacity wash of the foreground color across most terminal themes). +var stateBgColors = map[BranchState]lipgloss.Color{ + StateNew: lipgloss.Color("22"), // dark green + StateOpen: lipgloss.Color("18"), // dark blue + StateDraft: lipgloss.Color("58"), // dark amber + StateQueued: lipgloss.Color("52"), // dark orange/red + StateMerged: lipgloss.Color("53"), // dark purple + StateClosed: lipgloss.Color("52"), // dark red +} + +// Label returns the uppercase badge text for a state (e.g. "NEW"). +func (s BranchState) Label() string { + switch s { + case StateNew: + return "NEW" + case StateOpen: + return "OPEN" + case StateDraft: + return "DRAFT" + case StateQueued: + return "QUEUED" + case StateMerged: + return "MERGED" + case StateClosed: + return "CLOSED" + default: + return "" + } +} + +// Color returns the foreground color associated with a state. +func (s BranchState) Color() lipgloss.Color { return stateColors[s] } + +// Dot returns the compact legend glyph for a state, used in the Step 2 stack +// map and legend. +func (s BranchState) Dot() string { + switch s { + case StateNew: + return "●" + case StateOpen: + return "○" + case StateDraft: + return "◐" + case StateQueued: + return "◌" + case StateMerged: + return "◍" + case StateClosed: + return "✗" + default: + return "·" + } +} + +// RenderBadge renders a state as a pill badge: the uppercase label in the state +// color on a tinted background with single-column horizontal padding. +func RenderBadge(s BranchState) string { + return lipgloss.NewStyle(). + Foreground(s.Color()). + Background(stateBgColors[s]). + Bold(true). + Padding(0, 1). + Render(s.Label()) +} + +// RenderDot renders the state's legend glyph in the state color. +func RenderDot(s BranchState) string { + return lipgloss.NewStyle().Foreground(s.Color()).Render(s.Dot()) +} + +// Shared submit-view styles. These are intentionally centralized so Step 1, +// Step 2, the editor, and the diff tab render with a consistent visual +// language. +var ( + // FocusAccent is the left accent bar that marks the focused row/branch. + FocusAccent = "▌" + + focusAccentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan + focusNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + normalNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) + + // lockedStyle dims locked rows (~45% opacity feel). + lockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + // Checkbox styles for Step 1. + checkboxCheckedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green + checkboxUncheckedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + checkboxLockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + // Panel borders for the Step 2 two-panel layout. + panelBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(0, 1) + panelFocusedBorderStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("14")). + Padding(0, 1) + + // Section labels (e.g. STACK, EDITING, TITLE, DESCRIPTION). + sectionLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Bold(true) + + // Tab strip styles. + tabActiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) + tabInactiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // Footer / status styles. + footerKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + footerDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // Callouts. + calloutErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) + hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + readyTagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + editTagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) +) diff --git a/internal/tui/submitview/styles_test.go b/internal/tui/submitview/styles_test.go new file mode 100644 index 0000000..a72be3c --- /dev/null +++ b/internal/tui/submitview/styles_test.go @@ -0,0 +1,42 @@ +package submitview + +import ( + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/stretchr/testify/assert" +) + +func TestStateLabel(t *testing.T) { + cases := map[BranchState]string{ + StateNew: "NEW", + StateOpen: "OPEN", + StateDraft: "DRAFT", + StateQueued: "QUEUED", + StateMerged: "MERGED", + StateClosed: "CLOSED", + } + for state, want := range cases { + assert.Equal(t, want, state.Label()) + } +} + +func TestStateColorAndDot(t *testing.T) { + for _, s := range []BranchState{StateNew, StateOpen, StateDraft, StateQueued, StateMerged, StateClosed} { + assert.NotEqual(t, lipgloss.Color(""), s.Color(), "state %s should have a color", s.Label()) + assert.NotEmpty(t, s.Dot(), "state %s should have a dot", s.Label()) + } +} + +func TestRenderBadgeContainsLabel(t *testing.T) { + // The rendered badge may contain ANSI escapes; assert the label text is + // present regardless of styling. + for _, s := range []BranchState{StateNew, StateOpen, StateDraft, StateQueued, StateMerged, StateClosed} { + out := RenderBadge(s) + assert.Contains(t, out, s.Label()) + } +} + +func TestRenderDotContainsGlyph(t *testing.T) { + assert.Contains(t, RenderDot(StateNew), StateNew.Dot()) +} diff --git a/internal/tui/submitview/types.go b/internal/tui/submitview/types.go new file mode 100644 index 0000000..07a79d5 --- /dev/null +++ b/internal/tui/submitview/types.go @@ -0,0 +1,149 @@ +// Package submitview implements the interactive two-step TUI used by +// `gh stack submit` to create a stack of pull requests. Step 1 selects which +// branches without a PR should become PRs; Step 2 is a two-panel editor for +// drafting each PR's title, description, and draft state before a single +// batch submit. +// +// The package builds on the shared Charm components in internal/tui/shared and +// reuses the branch display data loaded by internal/tui/stackview. The submit +// command supplies the per-PR overrides this package produces; the underlying +// push/create/relink engine is unchanged. +package submitview + +import ( + "github.com/github/gh-stack/internal/tui/stackview" +) + +// BranchState classifies a branch by the status of its pull request. The state +// determines whether a branch is selectable in Step 1, editable in Step 2, and +// which badge color it renders with. +type BranchState int + +const ( + // StateNew is a branch with no PR yet. It is the only interactive state: + // selectable in Step 1 (default on) and editable in Step 2. + StateNew BranchState = iota + // StateOpen is a branch with an open (non-draft) PR. Locked, shown for context. + StateOpen + // StateDraft is a branch with a draft PR. Locked, shown for context. + StateDraft + // StateQueued is a branch whose PR is in a merge queue. Locked. + StateQueued + // StateMerged is a branch whose PR has merged. Locked, historical context. + StateMerged + // StateClosed is a branch with a closed PR. It blocks the stack and is + // neither selectable nor editable. + StateClosed +) + +// Selectable reports whether a branch in this state can be toggled in Step 1. +// Only NEW branches are selectable. +func (s BranchState) Selectable() bool { return s == StateNew } + +// Editable reports whether a branch in this state opens the editor in Step 2. +// Only NEW branches are editable. +func (s BranchState) Editable() bool { return s == StateNew } + +// Locked reports whether a branch is shown for context only (open, draft, +// queued, or merged). Closed is handled separately because it blocks the stack. +func (s BranchState) Locked() bool { + switch s { + case StateOpen, StateDraft, StateQueued, StateMerged: + return true + default: + return false + } +} + +// Blocks reports whether a branch in this state blocks the stack. Only closed +// PRs block. +func (s BranchState) Blocks() bool { return s == StateClosed } + +// SubmitNode wraps a stackview.BranchNode with the per-branch UI state used by +// the submit TUI: its derived state, inclusion, the in-progress title and +// description draft, and the draft toggle. Prefill snapshots are retained so +// the model can detect unsaved edits for the quit confirmation. +type SubmitNode struct { + stackview.BranchNode + + // State is the derived PR state for this branch. + State BranchState + + // Included reports whether a PR should be created for this branch on + // submit. Only meaningful for StateNew branches; defaults to true. + Included bool + + // Title and Description hold the in-progress PR draft, prefilled from the + // branch's commits and the repo PR template (see data.go). + Title string + Description string + + // Draft is the per-PR "Open as draft" toggle. + Draft bool + + // Submitted is set once this branch's PR has been created during the + // current session. It drives the inline "✓ready" tag in the stack map. + Submitted bool + + // prefill snapshots used to detect user edits. + titlePrefill string + descPrefill string + draftPrefill bool +} + +// Edited reports whether the user has changed any field of this NEW branch from +// its prefilled defaults: title, description, draft toggle, or inclusion. +// Non-NEW branches are never editable and so are never considered edited. +func (n SubmitNode) Edited() bool { + if n.State != StateNew { + return false + } + return n.Title != n.titlePrefill || + n.Description != n.descPrefill || + n.Draft != n.draftPrefill || + !n.Included +} + +// PRDraft is the per-branch override the TUI hands back to the submit command. +// The command's create path consumes these instead of auto-generating titles +// and bodies. The attribution footer is appended by the command at submit time, +// so Body holds only the user-authored description. +type PRDraft struct { + // Branch is the head branch the PR will be created from. + Branch string + // Include reports whether to create a PR for this branch. Deselected NEW + // branches are still pushed for stack consistency but get no PR. + Include bool + // Title is the PR title. + Title string + // Body is the user-authored PR description, without the attribution footer. + Body string + // Draft reports whether the PR should be created as a draft. + Draft bool +} + +// ToDraft converts a SubmitNode into the command-layer PRDraft override. +func (n SubmitNode) ToDraft() PRDraft { + return PRDraft{ + Branch: n.Ref.Branch, + Include: n.Included, + Title: n.Title, + Body: n.Description, + Draft: n.Draft, + } +} + +// BuildDrafts returns the per-branch overrides for every NEW branch, keyed by +// branch name. Non-NEW branches are omitted because their PRs are never +// modified by the TUI. +func BuildDrafts(nodes []SubmitNode) map[string]*PRDraft { + drafts := make(map[string]*PRDraft) + for _, n := range nodes { + if n.State != StateNew { + continue + } + d := n.ToDraft() + drafts[n.Ref.Branch] = &d + } + return drafts +} diff --git a/internal/tui/submitview/types_test.go b/internal/tui/submitview/types_test.go new file mode 100644 index 0000000..988de9c --- /dev/null +++ b/internal/tui/submitview/types_test.go @@ -0,0 +1,119 @@ +package submitview + +import ( + "testing" + + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/stretchr/testify/assert" +) + +func TestBranchStatePredicates(t *testing.T) { + tests := []struct { + state BranchState + selectable bool + editable bool + locked bool + blocks bool + }{ + {StateNew, true, true, false, false}, + {StateOpen, false, false, true, false}, + {StateDraft, false, false, true, false}, + {StateQueued, false, false, true, false}, + {StateMerged, false, false, true, false}, + {StateClosed, false, false, false, true}, + } + for _, tt := range tests { + t.Run(tt.state.Label(), func(t *testing.T) { + assert.Equal(t, tt.selectable, tt.state.Selectable()) + assert.Equal(t, tt.editable, tt.state.Editable()) + assert.Equal(t, tt.locked, tt.state.Locked()) + assert.Equal(t, tt.blocks, tt.state.Blocks()) + }) + } +} + +func TestSubmitNodeEdited(t *testing.T) { + base := SubmitNode{ + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/a"}}, + State: StateNew, + Included: true, + Title: "Title", + Description: "Desc", + Draft: false, + titlePrefill: "Title", + descPrefill: "Desc", + draftPrefill: false, + } + + t.Run("unchanged is not edited", func(t *testing.T) { + assert.False(t, base.Edited()) + }) + + t.Run("title change is edited", func(t *testing.T) { + n := base + n.Title = "New title" + assert.True(t, n.Edited()) + }) + + t.Run("description change is edited", func(t *testing.T) { + n := base + n.Description = "New desc" + assert.True(t, n.Edited()) + }) + + t.Run("draft toggle is edited", func(t *testing.T) { + n := base + n.Draft = true + assert.True(t, n.Edited()) + }) + + t.Run("deselection is edited", func(t *testing.T) { + n := base + n.Included = false + assert.True(t, n.Edited()) + }) + + t.Run("non-new node is never edited", func(t *testing.T) { + n := base + n.State = StateOpen + n.Title = "changed" + assert.False(t, n.Edited()) + }) +} + +func TestToDraft(t *testing.T) { + n := SubmitNode{ + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/a"}}, + State: StateNew, + Included: true, + Title: "Add feature", + Description: "Body", + Draft: true, + } + got := n.ToDraft() + assert.Equal(t, PRDraft{ + Branch: "feat/a", + Include: true, + Title: "Add feature", + Body: "Body", + Draft: true, + }, got) +} + +func TestBuildDrafts(t *testing.T) { + nodes := []SubmitNode{ + {State: StateNew, Included: true, Title: "A", BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/a"}}}, + {State: StateNew, Included: false, Title: "B", BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/b"}}}, + {State: StateOpen, BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: "feat/c"}}}, + } + + drafts := BuildDrafts(nodes) + + assert.Len(t, drafts, 2, "only NEW branches produce drafts") + assert.NotNil(t, drafts["feat/a"]) + assert.True(t, drafts["feat/a"].Include) + assert.NotNil(t, drafts["feat/b"]) + assert.False(t, drafts["feat/b"].Include) + assert.Nil(t, drafts["feat/c"], "non-new branch has no draft") +} From 56f723adf054063f56f1ff577039545d457430bf Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 19 Jun 2026 00:37:43 -0400 Subject: [PATCH 2/7] Add single-screen submit TUI Introduce an interactive, single-screen editor for `gh stack submit`, built on Bubble Tea and Lip Gloss. The left panel renders the stack as a connected tree down to the trunk. Every branch without a PR is included by default; deselect one with its checkbox or `^x`. Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it that it depends on. The cursor uses its own cyan accent so it reads distinctly from the green new/included color; existing PRs are shown dimmed with a no-entry glyph. The right panel edits the focused branch's PR in web-create-PR order: a header with the branch name and an include chip ("Creating PR" / "Skipped"), the title, a scrollable description (Glamour markdown preview and $EDITOR escape, with a scrollbar and mouse click-to-position), and a ready to draft segmented toggle (defaulting to ready). A footer strip shows the PR progress, the next branch, and the editor hints. Skipping a branch dims its body; branches that already have a PR show a read-only card linking to the PR. It shares the gh-stack header (art, title, stack info, and keyboard shortcuts) with `gh stack view` and `gh stack modify` for a unified look. Submit every included PR at once with Ctrl+S. Full keyboard and mouse support throughout. --- go.mod | 12 +- go.sum | 26 + internal/tui/submitview/editor.go | 585 ++++++++++++ internal/tui/submitview/help.go | 151 +++ internal/tui/submitview/integration_test.go | 62 ++ internal/tui/submitview/model.go | 271 ++++++ internal/tui/submitview/model_test.go | 385 ++++++++ internal/tui/submitview/mouse.go | 306 +++++++ internal/tui/submitview/preview.go | 150 +++ internal/tui/submitview/preview_test.go | 90 ++ internal/tui/submitview/render.go | 174 ++++ internal/tui/submitview/screen.go | 958 ++++++++++++++++++++ internal/tui/submitview/screen_test.go | 722 +++++++++++++++ internal/tui/submitview/styles.go | 113 ++- 14 files changed, 3967 insertions(+), 38 deletions(-) create mode 100644 internal/tui/submitview/editor.go create mode 100644 internal/tui/submitview/help.go create mode 100644 internal/tui/submitview/integration_test.go create mode 100644 internal/tui/submitview/model.go create mode 100644 internal/tui/submitview/model_test.go create mode 100644 internal/tui/submitview/mouse.go create mode 100644 internal/tui/submitview/preview.go create mode 100644 internal/tui/submitview/preview_test.go create mode 100644 internal/tui/submitview/render.go create mode 100644 internal/tui/submitview/screen.go create mode 100644 internal/tui/submitview/screen_test.go diff --git a/go.mod b/go.mod index 6307665..561c585 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,13 @@ require ( github.com/BourgeoisBear/rasterm v1.1.2 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/cli/cli/v2 v2.93.0 github.com/cli/go-gh/v2 v2.13.0 github.com/cli/shurcooL-graphql v0.0.4 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d + github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/sys v0.45.0 @@ -20,19 +22,24 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.19.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/cli/browser v1.3.0 // indirect github.com/cli/safeexec v1.0.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/henvic/httpretty v0.1.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect @@ -42,14 +49,17 @@ require ( github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/thlib/go-timezone-local v0.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yuin/goldmark v1.8.2 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.55.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1acb610..e0970f3 100644 --- a/go.sum +++ b/go.sum @@ -6,18 +6,28 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.19.0 h1:Im+SLRgT8maArxv81mULDWN8oKxkzboH07CHesxElq4= +github.com/alecthomas/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 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.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= +github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= @@ -26,6 +36,8 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 h1:VHLoEcL+kH60a4F8qMsPfOIfWjFE3ciaW4gge2YR3sA= +github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= @@ -51,14 +63,20 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/henvic/httpretty v0.1.4 h1:Jo7uwIRWVFxkqOnErcoYfH90o3ddQyVrSANeS4cxYmU= github.com/henvic/httpretty v0.1.4/go.mod h1:Dn60sQTZfbt2dYsdUSNsCljyF4AfdqnuJFDLJA1I4AM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -85,6 +103,8 @@ github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 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= @@ -118,6 +138,10 @@ github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4 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= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -127,6 +151,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/internal/tui/submitview/editor.go b/internal/tui/submitview/editor.go new file mode 100644 index 0000000..c4d6d92 --- /dev/null +++ b/internal/tui/submitview/editor.go @@ -0,0 +1,585 @@ +package submitview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/lipgloss" +) + +// currentNode returns a pointer to the focused node, or nil. +func (m Model) currentNode() *SubmitNode { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return nil + } + return &m.nodes[m.cursor] +} + +// renderIncludedEditor renders Mode 1: the editor for an included NEW branch — +// the header (with the "Creating PR" chip), a separator rule, the title and +// description fields and ready/draft toggle, and the footer strip. +func (m Model) renderIncludedEditor(n SubmitNode, innerW int) string { + var b strings.Builder + b.WriteString(m.renderRightHeader(n, innerW)) + b.WriteString("\n") + b.WriteString(rule(innerW)) + b.WriteString("\n") + b.WriteString(m.renderEditBody(innerW)) + b.WriteString("\n") + b.WriteString(rule(innerW)) + b.WriteString("\n") + b.WriteString(m.renderRightFooter(n, innerW)) + return b.String() +} + +// renderSkippedCard renders Mode 2: a NEW branch the user opted out of. The +// editor body is shown dimmed and non-interactive so it is clear what would be +// created, with the header chip reading "Skipped". +func (m Model) renderSkippedCard(n SubmitNode, innerW int) string { + var b strings.Builder + b.WriteString(m.renderRightHeader(n, innerW)) + b.WriteString("\n") + b.WriteString(rule(innerW)) + b.WriteString("\n") + b.WriteString(dimBodyStyle.Render(stripANSI(m.renderEditBody(innerW)))) + b.WriteString("\n") + b.WriteString(rule(innerW)) + b.WriteString("\n") + b.WriteString(m.renderRightFooter(n, innerW)) + return b.String() +} + +// renderLockedCard renders Mode 3: a read-only card for a branch that already +// has a PR. It shows the PR title and a scrollable markdown preview of the +// description, with an "Open on GitHub" button in the top-right. A closed PR +// blocks the stack, so it shows a short callout instead. +func (m Model) renderLockedCard(n SubmitNode, innerW int) string { + var b strings.Builder + b.WriteString(composeLR(m.renderRightHeader(n, innerW), m.renderOpenButton(n), innerW)) + b.WriteString("\n") + + if n.State.Blocks() { + b.WriteString("\n") + b.WriteString(calloutErrorStyle.Render(" This branch has a closed pull request, which blocks the stack.")) + b.WriteString("\n") + b.WriteString(hintStyle.Render(" Reopen it, or unstack and recreate to remove it.")) + return b.String() + } + + b.WriteString(rule(innerW)) + b.WriteString("\n") + b.WriteString(sectionLabelStyle.Render("TITLE")) + b.WriteString("\n") + b.WriteString(fieldBox(lockedTitleStyle.Render(truncateVisible(n.Title, innerW-4)), innerW, false)) + b.WriteString("\n") + b.WriteString(sectionLabelStyle.Render("DESCRIPTION")) + b.WriteString("\n") + b.WriteString(descBox(m.lockedDescContent(innerW), innerW, false)) + return b.String() +} + +// renderOpenButton renders the "↗ Open on GitHub (^o)" button for a locked +// PR's card, or "" when there is no PR URL to open. "↗ Open on GitHub" is one +// underlined white link; "(^o)" is a dim, non-link keyboard hint. +func (m Model) renderOpenButton(n SubmitNode) string { + if lockedURL(n) == "" { + return "" + } + return openLinkStyle.Render("↗ Open on GitHub") + " " + hintStyle.Render("(^o)") +} + +// lockedHeaderTargets returns the screen-x half-open ranges [start,end) of the +// two click targets on a locked card's header row: the existing-PR number and +// the "Open on GitHub" button. A zero-width range (start == end) means that +// target is absent. handleClick and tests share this so the click regions stay +// in sync with what renderLockedCard draws. +func (m Model) lockedHeaderTargets(n SubmitNode) (numStart, numEnd, btnStart, btnEnd int) { + leftW, rightW := m.panelWidths() + contentLeft := leftW + 3 // left gap + panel border + panel padding + contentRight := contentLeft + (rightW - 4) // exclusive: panel content right edge + + if num := prNumber(n); num != 0 { + lead := headerBranchStyle.Render(n.Ref.Branch) + " " + RenderBadge(n.State) + numStart = contentLeft + lipgloss.Width(lead) + numEnd = numStart + lipgloss.Width(fmt.Sprintf(" #%d", num)) + } + if btn := m.renderOpenButton(n); btn != "" { + btnEnd = contentRight + btnStart = contentRight - lipgloss.Width(btn) + } + return numStart, numEnd, btnStart, btnEnd +} + +// lockedDescContent renders the focused locked branch's description as a +// scrollable markdown preview with a scrollbar, matching the editor's preview. +func (m Model) lockedDescContent(innerW int) string { + textWidth := descTextWidth(innerW) + height := m.lockedDescHeight() + lines := m.descPreviewLines(innerW) + scroll := clampScroll(m.descScroll, len(lines), height) + rows := clipScrollRows(lines, scroll, height) + return addScrollbar(rows, scroll, len(lines), height, textWidth) +} + +// renderRightHeader renders the right-panel heading: the focused branch name +// (white), its state badge, and — for a NEW branch — a "CREATE PR" switch on the +// right (on when the branch will become a PR, off when skipped). +func (m Model) renderRightHeader(n SubmitNode, innerW int) string { + left := headerBranchStyle.Render(n.Ref.Branch) + " " + RenderBadge(n.State) + if n.State != StateNew { + if num := prNumber(n); num != 0 { + left += " " + prNumberStyle.Render(fmt.Sprintf("#%d", num)) + } + return left + } + return composeLR(left, m.renderIncludeChip(n), innerW) +} + +// renderIncludeChip renders the "CREATE PR" toggle: the label in the shared +// section-heading style, a dim Ctrl+X hint, then the two-state switch. +func (m Model) renderIncludeChip(n SubmitNode) string { + return sectionLabelStyle.Render("CREATE PR") + " " + hintStyle.Render("(^x)") + " " + renderSwitch(n.Included) +} + +// renderSwitch draws a two-state pill toggle with a square knob that slides +// between the ends, kept one cell off each border so it never touches the edge: +// " ■ " (green track, black knob right) when on, " ■ " (light track, dark +// knob left) when off. +func renderSwitch(on bool) string { + if on { + return switchOnStyle.Render(" ") + switchOnStyle.Foreground(switchOnKnob).Render("■") + switchOnStyle.Render(" ") + } + return switchOffStyle.Render(" ") + switchOffStyle.Foreground(switchOffKnob).Render("■") + switchOffStyle.Render(" ") +} + +// renderEditBody renders the editable fields in web-create-PR order: the title +// input, the description input with an edit/preview sub-toggle, and the +// ready/draft segmented toggle. The skip and editor shortcuts are shown inline +// (the CREATE PR switch and the CREATE AS row), not in the footer. +func (m Model) renderEditBody(innerW int) string { + var b strings.Builder + + // TITLE + b.WriteString(sectionLabelFor("TITLE", m.focusedField == fieldTitle)) + b.WriteString("\n") + b.WriteString(fieldBox(m.titleInput.View(), innerW, m.focusedField == fieldTitle)) + b.WriteString("\n") + + // DESCRIPTION with edit/preview sub-toggle on the right. + b.WriteString(composeLR(sectionLabelFor("DESCRIPTION", m.focusedField == fieldDescription), m.renderDescToggle(), innerW)) + b.WriteString("\n") + b.WriteString(descBox(m.descContent(innerW), innerW, m.focusedField == fieldDescription)) + b.WriteString("\n") + + // CREATE AS segmented toggle (with the Open in Editor hint on the right). + b.WriteString(m.renderDraftToggle(innerW)) + + return b.String() +} + +// rule renders a full-width horizontal divider. +func rule(width int) string { + if width < 1 { + width = 1 + } + return ruleStyle.Render(strings.Repeat("─", width)) +} + +// renderRightFooter renders the thin footer strip at the bottom of the right +// panel: the PR progress on the left and a bottom-right action — "NEXT BRANCH" +// or, on the last PR, "SUBMIT N PRs" — right-aligned. +func (m Model) renderRightFooter(n SubmitNode, innerW int) string { + return composeLR(m.renderProgress(n), m.footerRightButton(), innerW) +} + +// footerRightButton renders the bottom-right footer action: "NEXT BRANCH" (white) +// when another PR remains up the stack, or a prominent "SUBMIT N PRs" button on +// the last PR. A skipped branch shows an inert gray "SKIPPED" instead. The +// keyboard hint sits to the LEFT so the label/button stays anchored to the right +// edge. The "(tab)" hint shows only while the CREATE AS row is focused — the +// point at which Tab actually advances to the next branch. The NEXT BRANCH / +// SUBMIT actions are clickable (handleClick); empty when there is nothing to do. +func (m Model) footerRightButton() string { + if n := m.currentNode(); n != nil && n.State == StateNew && !n.Included { + return sectionLabelStyle.Render("SKIPPED") + } + if m.nextEditableIndex() != -1 { + label := nextBranchStyle.Render("NEXT BRANCH") + if m.focusedField == fieldDraft { + return hintStyle.Render("(tab) ") + label + } + return label + } + if _, total := m.prProgress(); total > 0 { + noun := "PRs" + if total == 1 { + noun = "PR" + } + return hintStyle.Render("(^s) ") + submitButtonStyle.Render(fmt.Sprintf("SUBMIT %d %s", total, noun)) + } + return "" +} + +// renderProgress renders the "●○ PR k of m" progress affordance for the new-PR +// set. When the focused branch is not an included NEW branch, only the count is +// shown. +func (m Model) renderProgress(n SubmitNode) string { + pos, total := m.prProgress() + if total == 0 { + return "" + } + var dots strings.Builder + for i := 1; i <= total; i++ { + if i == pos { + dots.WriteString(footerKeyStyle.Render("●")) + } else { + dots.WriteString(hintStyle.Render("○")) + } + } + if pos == 0 { + word := "PRs" + if total == 1 { + word = "PR" + } + return dots.String() + hintStyle.Render(fmt.Sprintf(" %d %s", total, word)) + } + return dots.String() + hintStyle.Render(fmt.Sprintf(" PR %d of %d", pos, total)) +} + +// scrollbarReserve is the number of columns reserved on the right of the +// description box for a 1-column margin plus the 1-column scrollbar. +const scrollbarReserve = 2 + +// descTextWidth returns the wrap width for the description text. The description +// box uses left-only padding (descBox) so its content area is innerW-3; the +// scrollbar and its margin take scrollbarReserve more. +func descTextWidth(innerW int) int { + w := innerW - 3 - scrollbarReserve + if w < 10 { + w = 10 + } + return w +} + +// descBox wraps the description content in a rounded box with left-only padding, +// so the scrollbar (the content's last column) sits flush against the right +// border with no extra margin. +func descBox(content string, width int, focused bool) string { + bc := lipgloss.Color("8") + if focused { + bc = lipgloss.Color("14") + } + w := width - 2 + if w < 1 { + w = 1 + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(bc). + Padding(0, 0, 0, 1). + Width(w). + Render(content) +} + +// descContent returns the description body to display: the rendered markdown +// preview or the editable text, both shown through a scrollable viewport with a +// scrollbar on the right. +func (m Model) descContent(innerW int) string { + textWidth := descTextWidth(innerW) + height := m.descAreaHeight() + + if m.descPreview { + lines := m.descPreviewLines(innerW) + scroll := clampScroll(m.descScroll, len(lines), height) + rows := clipScrollRows(lines, scroll, height) + return addScrollbar(rows, scroll, len(lines), height, textWidth) + } + + lines := m.descFullLines(innerW) + cursorRow := m.descCursorRow(innerW) + + // When not free-scrolling, the viewport follows the cursor; otherwise the + // user's absolute scroll offset is used. + scroll := m.descScroll + if !m.descScrollPinned { + scroll = cursorViewTop(cursorRow, height) + } + scroll = clampScroll(scroll, len(lines), height) + + // Overlay the block cursor when the description is focused and the cursor is + // within the visible window. + if m.descArea.Focused() && cursorRow >= scroll && cursorRow < scroll+height && cursorRow < len(lines) { + lines = overlayCursor(lines, cursorRow, m.descArea.LineInfo().ColumnOffset) + } + rows := clipScrollRows(lines, scroll, height) + return addScrollbar(rows, scroll, len(lines), height, textWidth) +} + +// clampScroll bounds a scroll offset to [0, total-height]. +func clampScroll(scroll, total, height int) int { + if max := total - height; scroll > max { + scroll = max + } + if scroll < 0 { + scroll = 0 + } + return scroll +} + +// descPreviewLines renders the description markdown to visual rows for the +// scrollable preview. +func (m Model) descPreviewLines(innerW int) []string { + desc := "" + if n := m.currentNode(); n != nil { + desc = n.Description + } + return strings.Split(renderMarkdown(desc, descTextWidth(innerW)), "\n") +} + +// cursorViewTop returns the scroll offset that keeps the cursor's row visible at +// the bottom of the viewport once the text grows past it (a normal editor feel). +func cursorViewTop(cursorRow, height int) int { + if cursorRow >= height { + return cursorRow - height + 1 + } + return 0 +} + +// descFullLines renders the whole description, wrapped exactly like the editing +// textarea (same width, same component) but with no cursor, as visual rows. It +// reads the live textarea value so it always matches descCursorRow. +func (m Model) descFullLines(innerW int) []string { + return wrapDescLines(m.descArea.Value(), descTextWidth(innerW)) +} + +// descCursorRow returns the cursor's absolute visual row in the wrapped +// description, accounting for soft-wrapped lines above it. +func (m Model) descCursorRow(innerW int) int { + li := m.descArea.LineInfo() + bufRow := m.descArea.Line() + if bufRow <= 0 { + return li.RowOffset + } + bufLines := strings.Split(m.descArea.Value(), "\n") + if bufRow > len(bufLines) { + bufRow = len(bufLines) + } + // A zero-width sentinel keeps a trailing empty line from being trimmed away, + // so the rows above the cursor are counted correctly. + before := strings.Join(bufLines[:bufRow], "\n") + "\u200b" + return len(wrapDescLines(before, descTextWidth(innerW))) + li.RowOffset +} + +// clipScrollRows returns height rows of lines starting at offset, padding with +// blank rows when the offset runs past the end. +func clipScrollRows(lines []string, offset, height int) []string { + out := make([]string, 0, height) + for i := offset; i < offset+height; i++ { + if i >= 0 && i < len(lines) { + out = append(out, lines[i]) + } else { + out = append(out, "") + } + } + return out +} + +// addScrollbar appends a 1-column margin and a vertical scrollbar to each row. +// The thumb size and position reflect the visible window over the total rows; +// when everything fits, only blank margin is added so the layout is stable. +func addScrollbar(rows []string, scroll, total, height, textWidth int) string { + start, size := scrollbarThumb(scroll, total, height) + var b strings.Builder + for i, row := range rows { + if i > 0 { + b.WriteByte('\n') + } + if gap := textWidth - lipgloss.Width(row); gap > 0 { + row += strings.Repeat(" ", gap) + } + b.WriteString(row) + b.WriteByte(' ') // margin between text and bar + switch { + case total <= height: + b.WriteByte(' ') // content fits: no bar + case i >= start && i < start+size: + b.WriteString(scrollThumbStyle.Render("┃")) + default: + b.WriteString(scrollTrackStyle.Render("│")) + } + } + return b.String() +} + +// scrollbarThumb returns the thumb's start row and size for a viewport of the +// given height over total rows scrolled to offset. +func scrollbarThumb(scroll, total, height int) (start, size int) { + if total <= height || height <= 0 { + return 0, 0 + } + size = height * height / total + if size < 1 { + size = 1 + } + if size > height { + size = height + } + if denom := total - height; denom > 0 { + start = scroll * (height - size) / denom + } + if start < 0 { + start = 0 + } + if start > height-size { + start = height - size + } + return start, size +} + +// wrapDescLines wraps text to width using a throwaway textarea so the wrapping +// matches the editor exactly, returning the visual rows as plain text (ANSI +// stripped) with trailing blank rows trimmed. Stripping ANSI is essential: in a +// real terminal the textarea pads blank rows with styled spaces, which would +// otherwise defeat the trailing-blank trim (inflating the scroll range) and the +// cursor-overlay column math. +func wrapDescLines(text string, width int) []string { + if width < 10 { + width = 10 + } + ta := textarea.New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.MaxHeight = 0 + ta.SetWidth(width) + ta.SetHeight(strings.Count(text, "\n") + 1 + len([]rune(text))/width + 5) + ta.SetValue(text) + lines := strings.Split(ta.View(), "\n") + for i := range lines { + lines[i] = stripANSI(lines[i]) + } + for len(lines) > 1 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +// stripANSI removes ANSI escape sequences from s. +func stripANSI(s string) string { + var b strings.Builder + inEsc := false + for _, r := range s { + if r == '\x1b' { + inEsc = true + continue + } + if inEsc { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + inEsc = false + } + continue + } + b.WriteRune(r) + } + return b.String() +} + +// overlayCursor renders a block cursor at (row, col) within the given lines. +func overlayCursor(lines []string, row, col int) []string { + if row < 0 || row >= len(lines) || col < 0 { + return lines + } + r := []rune(lines[row]) + for len(r) <= col { + r = append(r, ' ') + } + lines[row] = string(r[:col]) + descCursorStyle.Render(string(r[col])) + string(r[col+1:]) + return lines +} + +// renderDescToggle renders the inline edit/preview sub-toggle with a dim Ctrl+P +// hint to its right. +func (m Model) renderDescToggle() string { + var toggle string + if m.descPreview { + toggle = tabInactiveStyle.Render("edit") + stackInfoStyle.Render(" · ") + tabActiveStyle.Render("preview") + } else { + toggle = tabActiveStyle.Render("edit") + stackInfoStyle.Render(" · ") + tabInactiveStyle.Render("preview") + } + return toggle + " " + hintStyle.Render("(^p)") +} + +// renderDraftToggle renders the ready-for-review ↔ draft choice as a single +// segmented control "[ Ready | Draft ]" under a "CREATE AS" label, with the +// "Open in Editor (^e)" hint right-aligned on the same line. The selected +// segment is filled green; the other is dim. New PRs default to ready for review. +func (m Model) renderDraftToggle(innerW int) string { + n := m.currentNode() + draft := n != nil && n.Draft + + ready := segOffStyle.Render("Ready") + draftOpt := segOffStyle.Render("Draft") + if draft { + draftOpt = segOnStyle.Render("Draft") + } else { + ready = segOnStyle.Render("Ready") + } + seg := segFrameStyle.Render("[") + ready + segFrameStyle.Render("|") + draftOpt + segFrameStyle.Render("]") + + left := sectionLabelFor("CREATE AS", m.focusedField == fieldDraft) + " " + seg + return composeLR(left, hintStyle.Render("open in $EDITOR (^e)"), innerW) +} + +// sectionLabelFor renders a field's section label, turning it cyan when the +// field is focused (matching the focused branch name and CREATE AS treatment). +func sectionLabelFor(text string, focused bool) string { + if focused { + return focusNameStyle.Render(text) + } + return sectionLabelStyle.Render(text) +} + +// draftSegmentBounds returns the screen-x coordinates of the CREATE AS segmented +// control on the draft line: segStart (the "[" column), dividerX (the "|" +// column), and segEnd (exclusive, just past "]"). handleClick uses these so only +// clicks inside the brackets change the value — clicks on the "CREATE AS" label or +// elsewhere on the line are ignored — and the half a click lands in selects that +// option (left of the divider = Ready, right = Draft). +func (m Model) draftSegmentBounds() (segStart, dividerX, segEnd int) { + leftW, _ := m.panelWidths() + contentLeft := leftW + 3 // left gap + panel border + panel padding + segStart = contentLeft + lipgloss.Width("CREATE AS") + 2 // label + two-space gap + dividerX = segStart + lipgloss.Width("[") + lipgloss.Width(segOffStyle.Render("Ready")) + segEnd = dividerX + lipgloss.Width("|") + lipgloss.Width(segOffStyle.Render("Draft")) + lipgloss.Width("]") + return segStart, dividerX, segEnd +} + +// fieldBox wraps a field's content in a rounded box whose border highlights when +// focused. width is the desired outer width. +func fieldBox(content string, width int, focused bool) string { + bc := lipgloss.Color("8") + if focused { + bc = lipgloss.Color("14") + } + w := width - 2 + if w < 1 { + w = 1 + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(bc). + Padding(0, 1). + Width(w). + Render(content) +} + +// lockedURL returns the web URL for a locked branch's PR, or "". +func lockedURL(n SubmitNode) string { + if n.PR != nil && n.PR.URL != "" { + return n.PR.URL + } + if n.Ref.PullRequest != nil { + return n.Ref.PullRequest.URL + } + return "" +} diff --git a/internal/tui/submitview/help.go b/internal/tui/submitview/help.go new file mode 100644 index 0000000..21561aa --- /dev/null +++ b/internal/tui/submitview/help.go @@ -0,0 +1,151 @@ +package submitview + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + helpOverlayStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(1, 2) + helpTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) + helpSectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) + helpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + helpDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + +// helpEntry is a single key/description pair in the help overlay. +type helpEntry struct { + key string + desc string +} + +// helpSection groups related key bindings under a heading. +type helpSection struct { + heading string + entries []helpEntry +} + +var helpSections = []helpSection{ + { + heading: "Choose which branches become PRs", + entries: []helpEntry{ + {"↑ / ↓ (or k / j)", "move between branches"}, + {"^x / space", "skip / include the focused branch (works while typing)"}, + {"", "skipping also skips the branches above; including adds those below"}, + }, + }, + { + heading: "Edit PR details", + entries: []helpEntry{ + {"tab / shift+tab", "cycle fields (title, description, ready/draft) and PRs"}, + {"space / ← / →", "flip the ready ↔ draft toggle"}, + {"^p", "toggle description preview"}, + {"^e", "open $EDITOR for the description"}, + }, + }, + { + heading: "Existing PRs", + entries: []helpEntry{ + {"^o", "open the focused branch's PR on the web"}, + }, + }, + { + heading: "Anywhere", + entries: []helpEntry{ + {"^s", "submit all included PRs"}, + {"? / ^h", "toggle this help (^h also works while editing a field)"}, + {"q / esc", "quit (confirm if edits exist)"}, + {"mouse", "click rows, checkboxes, the include switch, toggles, and fields"}, + }, + }, +} + +// renderHelpOverlay renders a centered, full-screen help modal listing every +// key binding. +func renderHelpOverlay(width, height int) string { + var b strings.Builder + b.WriteString(helpTitleStyle.Render("gh stack submit — keyboard & mouse")) + b.WriteString("\n") + + // Compute the key-column width for alignment. + keyW := 0 + for _, s := range helpSections { + for _, e := range s.entries { + if w := lipgloss.Width(e.key); w > keyW { + keyW = w + } + } + } + + for _, s := range helpSections { + b.WriteString("\n") + b.WriteString(helpSectionStyle.Render(s.heading)) + b.WriteString("\n") + for _, e := range s.entries { + pad := keyW - lipgloss.Width(e.key) + if pad < 0 { + pad = 0 + } + b.WriteString(" ") + b.WriteString(helpKeyStyle.Render(e.key)) + b.WriteString(strings.Repeat(" ", pad)) + b.WriteString(" ") + b.WriteString(helpDescStyle.Render(e.desc)) + b.WriteString("\n") + } + } + + b.WriteString("\n") + b.WriteString(helpDescStyle.Render("Press ?, ^h, or Esc to close")) + + styled := helpOverlayStyle.Render(b.String()) + return centerOverlay(styled, width, height) +} + +// renderQuitConfirm renders the centered discard-edits confirmation modal. +func renderQuitConfirm(width, height int) string { + body := helpTitleStyle.Render("Discard edits?") + "\n\n" + + helpDescStyle.Render("You have unsaved changes. Quit without submitting?") + "\n\n" + + helpKeyStyle.Render("y") + helpDescStyle.Render(" quit ") + + helpKeyStyle.Render("n") + helpDescStyle.Render(" keep editing") + styled := helpOverlayStyle.Render(body) + return centerOverlay(styled, width, height) +} + +// centerOverlay positions a styled block in the center of the viewport. +func centerOverlay(block string, width, height int) string { + blockLines := strings.Split(block, "\n") + blockHeight := len(blockLines) + blockWidth := 0 + for _, l := range blockLines { + if w := lipgloss.Width(l); w > blockWidth { + blockWidth = w + } + } + + topPad := (height - blockHeight) / 2 + if topPad < 0 { + topPad = 0 + } + leftPad := (width - blockWidth) / 2 + if leftPad < 0 { + leftPad = 0 + } + + var b strings.Builder + for i := 0; i < topPad; i++ { + b.WriteString("\n") + } + for i, l := range blockLines { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(strings.Repeat(" ", leftPad)) + b.WriteString(l) + } + return b.String() +} diff --git a/internal/tui/submitview/integration_test.go b/internal/tui/submitview/integration_test.go new file mode 100644 index 0000000..0cb626b --- /dev/null +++ b/internal/tui/submitview/integration_test.go @@ -0,0 +1,62 @@ +package submitview + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSubmit_EmptyTitleGuard(t *testing.T) { + m := testModel(t, newNodes()) + m.titleInput.SetValue("") // blank the focused branch's title + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = updated.(Model) + assert.False(t, m.submitRequested) + assert.True(t, m.statusIsError) + assert.Nil(t, cmd) + assert.Equal(t, fieldTitle, m.focusedField) + assert.Equal(t, 1, m.cursor) +} + +func TestSubmit_SucceedsWhenTitlesPresent(t *testing.T) { + m := testModel(t, newNodes()) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = updated.(Model) + assert.True(t, m.submitRequested) + assert.NotNil(t, cmd) +} + +func TestSubmit_SkippedBranchWithoutTitleDoesNotBlock(t *testing.T) { + m := testModel(t, newNodes()) + // Blank the first branch's title, then skip it; submit should proceed. + m.titleInput.SetValue("") + m.saveEditor() + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip branch 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) + m = updated.(Model) + assert.True(t, m.submitRequested, "a skipped branch's empty title does not block submit") +} + +func TestBuildDrafts_FromModel(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // focus the top branch (0) + require.Equal(t, 0, m.cursor) + m.nodes[1].Title = "Custom title" + // Skip the top branch (branch 0); the branch below it stays included. + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + require.False(t, m.nodes[0].Included) + require.True(t, m.nodes[1].Included) + + drafts := BuildDrafts(m.Nodes()) + require.NotNil(t, drafts["feat/auth/tests"]) + assert.False(t, drafts["feat/auth/tests"].Include, "skipped branch") + require.NotNil(t, drafts["feat/auth/middleware"]) + assert.True(t, drafts["feat/auth/middleware"].Include, "still included") + assert.Equal(t, "Custom title", drafts["feat/auth/middleware"].Title) + // Locked branches never produce drafts. + assert.Nil(t, drafts["feat/auth/handlers"]) +} diff --git a/internal/tui/submitview/model.go b/internal/tui/submitview/model.go new file mode 100644 index 0000000..7a74f24 --- /dev/null +++ b/internal/tui/submitview/model.go @@ -0,0 +1,271 @@ +package submitview + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/stack" +) + +// editField identifies the focused field in the right editor panel. +type editField int + +const ( + fieldTitle editField = iota + fieldDescription + fieldDraft +) + +// keyMap holds the bindings the model matches centrally (the help overlay lists +// the full set separately). +type keyMap struct { + Help key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Help: key.NewBinding( + key.WithKeys("?", "ctrl+h"), + key.WithHelp("?", "help"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), +} + +// Options configures a new submit TUI model. +type Options struct { + // Nodes is the branch list in display order (index 0 = top of stack). + Nodes []SubmitNode + // Trunk is the stack's trunk branch, shown for context. + Trunk stack.BranchRef + // StackName is the human-readable stack name shown in the header. + StackName string + // RepoLabel is the "owner/repo" string shown in the header. + RepoLabel string + // Version is the CLI version string. + Version string +} + +// Model is the Bubble Tea model backing the interactive `gh stack submit` TUI. +type Model struct { + nodes []SubmitNode + trunk stack.BranchRef + stackName string + repoLabel string + version string + + // prefix is the common slash-delimited branch-name prefix, used to render + // short branch names in the stack map. + prefix string + + cursor int // index into nodes (the focused branch) + + width, height int + + // Editor state for the focused branch. + titleInput textinput.Model + descArea textarea.Model + focusedField editField + descPreview bool // description preview (vs edit) + + // descScroll is the wheel-scroll offset (absolute top visual row) for the + // description box. descScrollPinned is true while the user is free-scrolling + // with the wheel; when false the view follows the cursor. A keystroke unpins + // it so editing always shows the cursor. + descScroll int + descScrollPinned bool + + showHelp bool + + // Transient status line shown below the content (cleared on next key). + statusMessage string + statusIsError bool + + // hoverRow is the node index currently under the mouse pointer, or -1. + hoverRow int + + // leftScroll is the first visible row offset of the left stack timeline when + // its content is taller than the panel. + leftScroll int + + // confirmingQuit is true while the discard-edits confirmation is shown. + confirmingQuit bool + + // Outcome flags consumed by the command layer once the program exits. + submitRequested bool + cancelled bool + + // openURL, when non-nil, is called instead of launching the system browser + // to open an existing PR. Tests inject a no-op so the suite never spawns a + // real browser. + openURL func(string) +} + +// New constructs a submit TUI model from the given options. The single screen +// opens immediately with the first branch focused (preferring the first NEW +// branch) and the title field ready for editing. +func New(opts Options) Model { + branchNames := make([]string, len(opts.Nodes)) + for i, n := range opts.Nodes { + branchNames[i] = n.Ref.Branch + } + + // Start on the bottom-most NEW branch (closest to trunk) — the first PR + // created, in stack order. Nodes are ordered top (index 0) to bottom, so the + // bottom-most NEW branch is the highest-indexed one. + cursor := 0 + for i, n := range opts.Nodes { + if n.State == StateNew { + cursor = i + } + } + + ti := textinput.New() + ti.Prompt = "" + ti.CharLimit = 256 + + ta := textarea.New() + ta.Prompt = "" + ta.ShowLineNumbers = false + ta.CharLimit = 0 // unlimited + // Soften the textarea chrome; the panel provides the border. + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + + m := Model{ + nodes: opts.Nodes, + trunk: opts.Trunk, + stackName: opts.StackName, + repoLabel: opts.RepoLabel, + version: opts.Version, + prefix: CommonPrefix(branchNames), + cursor: cursor, + hoverRow: -1, + + titleInput: ti, + descArea: ta, + focusedField: fieldTitle, + } + + m.loadEditor() + // Focus the first field of the initial branch (title for an included NEW + // branch, the Create-PR toggle otherwise). + _ = m.focusFirstField() + + return m +} + +// --- Getters for the command layer --- + +// SubmitRequested reports whether the user confirmed the batch submit. +func (m Model) SubmitRequested() bool { return m.submitRequested } + +// Cancelled reports whether the user quit without submitting. +func (m Model) Cancelled() bool { return m.cancelled } + +// Nodes returns the current per-branch state, from which the command builds +// the per-PR overrides. +func (m Model) Nodes() []SubmitNode { return m.nodes } + +// --- Bubble Tea interface --- + +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +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.resizeEditor() + return m, nil + + case tea.KeyMsg: + // Any key dismisses a transient status hint. + m.statusMessage = "" + m.statusIsError = false + + if m.confirmingQuit { + return m.updateQuitConfirm(msg) + } + if m.showHelp { + return m.updateHelp(msg) + } + return m.updateScreen(msg) + + case tea.MouseMsg: + return m.handleMouse(msg) + + case editorFinishedMsg: + return m.handleEditorFinished(msg) + } + + return m, nil +} + +// updateHelp handles keys while the help overlay is visible. +func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, keys.Help) || msg.Type == tea.KeyEscape { + m.showHelp = false + } + return m, nil +} + +// updateQuitConfirm handles keys while the discard-edits confirmation is shown. +func (m Model) updateQuitConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y", "Y": + m.cancelled = true + return m, tea.Quit + case "n", "N", "esc": + m.confirmingQuit = false + return m, nil + } + if msg.Type == tea.KeyCtrlC { + m.cancelled = true + return m, tea.Quit + } + return m, nil +} + +func (m Model) View() string { + if m.width == 0 { + return "" + } + if m.confirmingQuit { + return renderQuitConfirm(m.width, m.height) + } + if m.showHelp { + return renderHelpOverlay(m.width, m.height) + } + return m.viewScreen() +} + +// anyEdited reports whether the user has made any change worth a quit +// confirmation: a deselected NEW branch or an edited title/description/draft. +func (m Model) anyEdited() bool { + for _, n := range m.nodes { + if n.Edited() { + return true + } + } + return false +} + +// quit marks the session cancelled and exits. If the user has unsaved edits, it +// first raises a discard-edits confirmation instead of quitting immediately. +func (m Model) quit() (tea.Model, tea.Cmd) { + if m.anyEdited() && !m.confirmingQuit { + m.confirmingQuit = true + return m, nil + } + m.cancelled = true + return m, tea.Quit +} + +// Ensure Model satisfies the tea.Model interface. +var _ tea.Model = Model{} diff --git a/internal/tui/submitview/model_test.go b/internal/tui/submitview/model_test.go new file mode 100644 index 0000000..68b25ff --- /dev/null +++ b/internal/tui/submitview/model_test.go @@ -0,0 +1,385 @@ +package submitview + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + ghapi "github.com/github/gh-stack/internal/github" + "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- test helpers --- + +// newNode builds a SubmitNode with prefill snapshots set (so it reads as +// unedited) for the given branch and state. +func newNode(branch string, st BranchState) SubmitNode { + title := "Title " + branch + desc := "Desc" + n := SubmitNode{ + BranchNode: stackview.BranchNode{Ref: stack.BranchRef{Branch: branch}}, + State: st, + Included: st == StateNew, + Title: title, + Description: desc, + titlePrefill: title, + descPrefill: desc, + } + if st != StateNew { + n.Ref.PullRequest = &stack.PullRequestRef{Number: 1240, URL: "https://github.com/o/r/pull/1240"} + n.PR = &ghapi.PRDetails{Number: 1240, State: "OPEN", URL: "https://github.com/o/r/pull/1240"} + } + return n +} + +// newNodes returns a representative stack: two NEW branches followed by locked +// branches in several states. +func newNodes() []SubmitNode { + return []SubmitNode{ + newNode("feat/auth/tests", StateNew), + newNode("feat/auth/middleware", StateNew), + newNode("feat/auth/handlers", StateDraft), + newNode("feat/auth/models", StateOpen), + newNode("feat/auth/router", StateQueued), + newNode("feat/auth/ui", StateMerged), + } +} + +func testModel(t *testing.T, nodes []SubmitNode) Model { + t.Helper() + m := New(Options{ + Nodes: nodes, + Trunk: stack.BranchRef{Branch: "main"}, + StackName: "feat/auth", + RepoLabel: "myorg/myrepo", + Version: "1.0.0", + }) + // Never spawn a real browser from the test suite when opening an existing PR. + m.openURL = func(string) {} + // Height accommodates the shared 12-line header plus the full editor. + updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + return updated.(Model) +} + +func sendKey(t *testing.T, m Model, msg tea.KeyMsg) Model { + t.Helper() + updated, _ := m.Update(msg) + return updated.(Model) +} + +func runeKey(r rune) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} +} + +// --- constructor --- + +func TestNew_OpensEditorOnBottomMostNew(t *testing.T) { + m := testModel(t, newNodes()) + assert.Equal(t, 1, m.cursor, "opens on the bottom-most NEW branch (closest to trunk)") + assert.Equal(t, fieldTitle, m.focusedField) + assert.True(t, m.titleInput.Focused(), "title is focused for immediate editing") +} + +func TestNew_AllNewIncludedByDefault(t *testing.T) { + m := testModel(t, newNodes()) + assert.True(t, m.nodes[0].Included) + assert.True(t, m.nodes[1].Included) +} + +func TestNew_ComputesPrefix(t *testing.T) { + m := testModel(t, newNodes()) + assert.Equal(t, "feat/auth/", m.prefix) +} + +// --- navigation --- + +func TestNavigation_MovesAcrossAllBranches(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, 1, m.cursor) // bottom-most NEW + + // Lands on locked branches too (Mode 3). + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, 2, m.cursor) + assert.True(t, m.nodes[m.cursor].State.Locked()) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, 1, m.cursor) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, 0, m.cursor) +} + +func TestNavigation_ClampsAtEnds(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, 0, m.cursor) + + for i := 0; i < 20; i++ { + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + } + assert.Equal(t, len(m.nodes)-1, m.cursor) +} + +func TestNavigation_FocusesLockedWithNoTextInput(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked + require.True(t, m.nodes[m.cursor].State.Locked()) + assert.False(t, m.titleInput.Focused()) + assert.False(t, m.descArea.Focused()) +} + +// --- include / skip --- + +func TestCtrlX_TogglesInclude(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // focus the top NEW branch + require.Equal(t, 0, m.cursor) + require.True(t, m.nodes[0].Included) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + assert.False(t, m.nodes[0].Included, "^x skips the focused NEW branch") + assert.False(t, m.titleInput.Focused(), "a skipped branch's title is non-interactive") + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + assert.True(t, m.nodes[0].Included, "^x includes it again") + assert.True(t, m.titleInput.Focused(), "re-including focuses the title for editing") +} + +func TestCtrlX_WorksWhileTypingTitle(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // focus the top NEW branch + require.Equal(t, fieldTitle, m.focusedField) + m = sendKey(t, m, runeKey('Z')) // typing into title + require.Contains(t, m.nodes[0].Title, "Z") + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + assert.False(t, m.nodes[0].Included, "^x works mid-text-entry") + assert.Contains(t, m.nodes[0].Title, "Z", "in-progress title is preserved") +} + +func TestCtrlX_NoOpOnLockedBranch(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked + require.True(t, m.nodes[m.cursor].State.Locked()) + before := m.nodes[m.cursor].Included + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + assert.Equal(t, before, m.nodes[m.cursor].Included) +} + +func TestSpace_ReincludesSkippedBranch(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch + require.False(t, m.nodes[m.cursor].Included) + + // space on a skipped branch re-includes it (no Create-PR field anymore). + m = sendKey(t, m, runeKey(' ')) + assert.True(t, m.nodes[m.cursor].Included) +} + +// --- quit --- + +func TestQuit_WhenUnedited(t *testing.T) { + m := testModel(t, newNodes()) + require.False(t, m.anyEdited()) + // 'q' quits from a non-text field (it is literal while editing a title). + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // ready/draft toggle + require.Equal(t, fieldDraft, m.focusedField) + updated, cmd := m.Update(runeKey('q')) + m = updated.(Model) + assert.True(t, m.cancelled) + assert.False(t, m.confirmingQuit) + assert.NotNil(t, cmd) +} + +func TestEsc_Quits(t *testing.T) { + m := testModel(t, newNodes()) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape}) + m = updated.(Model) + assert.True(t, m.cancelled) +} + +func TestQuitConfirm_WhenEdited(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip a branch = an edit + require.True(t, m.anyEdited()) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEscape}) + m = updated.(Model) + assert.True(t, m.confirmingQuit) + assert.False(t, m.cancelled) + assert.Contains(t, m.View(), "Discard edits") + + m = sendKey(t, m, runeKey('n')) + assert.False(t, m.confirmingQuit) + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEscape}) + m = updated.(Model) + require.True(t, m.confirmingQuit) + updated, cmd := m.Update(runeKey('y')) + m = updated.(Model) + assert.True(t, m.cancelled) + assert.NotNil(t, cmd) +} + +// --- help --- + +func TestHelpToggle(t *testing.T) { + m := testModel(t, newNodes()) + // '?' opens help only when not in a text field; move to the ready/draft toggle. + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // ready/draft toggle + require.Equal(t, fieldDraft, m.focusedField) + m = sendKey(t, m, runeKey('?')) + assert.True(t, m.showHelp) + assert.Contains(t, m.View(), "keyboard & mouse") + + m = sendKey(t, m, runeKey('?')) + assert.False(t, m.showHelp) +} + +func TestHelp_NotOpenedWhileTypingTitle(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, fieldTitle, m.focusedField) + m = sendKey(t, m, runeKey('?')) + assert.False(t, m.showHelp, "'?' is literal while editing the title") + assert.Contains(t, m.nodes[1].Title, "?") +} + +// --- view --- + +func TestView_RendersKeyElements(t *testing.T) { + m := testModel(t, newNodes()) + out := m.View() + assert.Contains(t, out, "Submit Stack") // shared header title + assert.Contains(t, out, "Repo: myorg/myrepo") // repo info line (header) + assert.Contains(t, out, "Creating 2 PRs") // pending-PR info line (header) + assert.Contains(t, out, "submit PRs") // keyboard shortcut in the shared header + assert.Contains(t, out, "CREATE PR") // include switch + assert.Contains(t, out, "tests") + assert.Contains(t, out, "main") +} + +func TestHelp_CtrlHTogglesWhileEditing(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, fieldTitle, m.focusedField) // editing the title + + // '?' is typed into the title field, not treated as help, while editing. + m = sendKey(t, m, runeKey('?')) + assert.False(t, m.showHelp, "? is typed into the field while editing") + assert.Contains(t, m.nodes[m.cursor].Title, "?") + + // ^h opens help even while editing a field. + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + m = updated.(Model) + assert.True(t, m.showHelp, "^h opens help while editing") + + // ^h closes it again. + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + m = updated.(Model) + assert.False(t, m.showHelp, "^h closes help") +} + +func TestHelp_OverlayListsFullShortcuts(t *testing.T) { + m := testModel(t, newNodes()) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlH}) + m = updated.(Model) + out := m.View() + for _, s := range []string{"^x", "^p", "^e", "^o", "^s", "^h", "tab", "mouse"} { + assert.Contains(t, out, s, "the help overlay lists %q", s) + } +} + +func TestHeaderConfig_InfoLines(t *testing.T) { + // Repo and base are the first two info lines, in order. + cfg := testModel(t, newNodes()).buildHeaderConfig() + require.GreaterOrEqual(t, len(cfg.InfoLines), 3) + assert.Equal(t, "○", cfg.InfoLines[0].Icon) + assert.Equal(t, "Repo: myorg/myrepo", cfg.InfoLines[0].Label) + assert.Equal(t, "◆", cfg.InfoLines[1].Icon) + assert.Equal(t, "Base: main", cfg.InfoLines[1].Label) + + // Two included NEW branches -> solid (styled) square, pluralized. + last := cfg.InfoLines[len(cfg.InfoLines)-1] + assert.Equal(t, "■", last.Icon) + assert.Equal(t, "Creating 2 PRs", last.Label) + assert.NotNil(t, last.IconStyle, "the creating line is styled yellow") + + // A single included NEW branch -> singular. + oneCfg := testModel(t, []SubmitNode{newNode("feat/x", StateNew)}).buildHeaderConfig() + oneLast := oneCfg.InfoLines[len(oneCfg.InfoLines)-1] + assert.Equal(t, "■", oneLast.Icon) + assert.Equal(t, "Creating 1 PR", oneLast.Label) + + // No NEW branches (all already have PRs) -> empty square, default style. + noneCfg := testModel(t, []SubmitNode{ + newNode("feat/a", StateOpen), + newNode("feat/b", StateMerged), + }).buildHeaderConfig() + noneLast := noneCfg.InfoLines[len(noneCfg.InfoLines)-1] + assert.Equal(t, "□", noneLast.Icon) + assert.Equal(t, "No pending PRs", noneLast.Label) + assert.Nil(t, noneLast.IconStyle, "the empty line uses the default icon style") +} + +func TestView_ClosedBanner(t *testing.T) { + nodes := newNodes() + nodes = append(nodes, newNode("feat/auth/legacy", StateClosed)) + m := testModel(t, nodes) + out := m.View() + assert.Contains(t, out, "closed PR") + assert.Contains(t, out, "feat/auth/legacy") +} + +func TestView_EmptyWhenNoSize(t *testing.T) { + m := New(Options{Nodes: newNodes(), Trunk: stack.BranchRef{Branch: "main"}}) + assert.Equal(t, "", m.View()) +} + +// --- mouse --- + +func TestMouse_ClickBranchFocuses(t *testing.T) { + m := testModel(t, newNodes()) + y := m.panelTopRow() + 2 + 1 // branch index 1 + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 10, Y: y}) + m = updated.(Model) + assert.Equal(t, 1, m.cursor) +} + +func TestMouse_ClickCheckboxToggles(t *testing.T) { + m := testModel(t, newNodes()) + require.True(t, m.nodes[0].Included) + y, cbX := leftBranchNode(m, 0) + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: cbX, Y: y}) + m = updated.(Model) + assert.False(t, m.nodes[0].Included) +} + +func TestMouse_HoverTracksRow(t *testing.T) { + m := testModel(t, newNodes()) + y, _ := leftBranchNode(m, 2) + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionMotion, X: 6, Y: y}) + m = updated.(Model) + assert.Equal(t, 2, m.hoverRow) +} + +// leftBranchNode returns the screen Y of branch idx's node line and the X of its +// right-edge checkbox, using the live left-panel timeline layout (scroll-aware). +func leftBranchNode(m Model, idx int) (y, checkboxX int) { + leftW, _ := m.panelWidths() + rows := m.buildLeftRows(leftW - 2) + visH := m.leftVisibleHeight() + scroll := clampScroll(m.leftScroll, len(rows), visH) + for i, r := range rows { + if r.branch == idx && r.nodeLine { + y = m.panelTopRow() + (i - scroll) + break + } + } + checkboxX = leftW - 4 // middle column of the right-aligned "[x]" + return y, checkboxX +} diff --git a/internal/tui/submitview/mouse.go b/internal/tui/submitview/mouse.go new file mode 100644 index 0000000..62aeac2 --- /dev/null +++ b/internal/tui/submitview/mouse.go @@ -0,0 +1,306 @@ +package submitview + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.showHelp || m.confirmingQuit { + if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { + m.showHelp = false + } + return m, nil + } + + switch msg.Action { + case tea.MouseActionMotion: + m.updateHover(msg.X, msg.Y) + return m, nil + case tea.MouseActionPress: + leftW, _ := m.panelWidths() + overLeft := msg.X >= 0 && msg.X < leftW + switch msg.Button { + case tea.MouseButtonWheelUp: + if overLeft { + m.scrollLeft(-leftScrollStep) + } else if m.isDescScrollable() { + m.scrollDesc(-descScrollStep) + } + return m, nil + case tea.MouseButtonWheelDown: + if overLeft { + m.scrollLeft(leftScrollStep) + } else if m.isDescScrollable() { + m.scrollDesc(descScrollStep) + } + return m, nil + case tea.MouseButtonLeft: + return m.handleClick(msg.X, msg.Y) + } + } + return m, nil +} + +// leftScrollStep is the number of rows the left timeline scrolls per wheel notch. +const leftScrollStep = 2 + +// descScrollStep is the number of rows the description scrolls per wheel notch. +const descScrollStep = 3 + +// scrollDesc moves the description's scroll offset by delta rows, clamped to the +// scrollable range. In edit mode the first scroll pins the offset to the current +// cursor view for continuity; in preview mode the offset is absolute. +func (m *Model) scrollDesc(delta int) { + _, rightW := m.panelWidths() + innerW := rightW - 4 + // In the editor's edit mode the first scroll pins the offset to the cursor + // view; the locked preview (and edit-preview) use an absolute offset. + n := m.currentNode() + editing := n != nil && n.State == StateNew && !m.descPreview + if editing && !m.descScrollPinned { + m.descScroll = cursorViewTop(m.descCursorRow(innerW), m.descAreaHeight()) + m.descScrollPinned = true + } + next := m.descScroll + delta + if next < 0 { + next = 0 + } + if max := m.maxDescScroll(innerW); next > max { + next = max + } + m.descScroll = next +} + +// isDescScrollable reports whether the wheel should scroll the description: the +// focused description of an included NEW branch, or a locked PR's read-only +// description preview. +func (m Model) isDescScrollable() bool { + n := m.currentNode() + if n == nil { + return false + } + if n.State == StateNew { + return n.Included && m.focusedField == fieldDescription + } + return n.State.Locked() +} + +// panelTopRow returns the screen row of the first inner line of the panels +// (past the header, the optional closed-PR banner, and the panel top border). +func (m Model) panelTopRow() int { + row := m.headerHeight() + if m.renderClosedBanner() != "" { + row++ + } + return row + 1 +} + +// branchRowAt maps screen coordinates to a left-panel branch index, or -1. A +// click anywhere on a branch's node, wrapped-name, or meta lines resolves to it. +func (m Model) branchRowAt(x, y int) int { + leftW, _ := m.panelWidths() + if x < 0 || x >= leftW { + return -1 + } + rows := m.buildLeftRows(leftW - 2) + visH := m.leftVisibleHeight() + scroll := clampScroll(m.leftScroll, len(rows), visH) + vis := y - m.panelTopRow() + if vis < 0 || vis >= visH { + return -1 + } + off := vis + scroll + if off < 0 || off >= len(rows) { + return -1 + } + return rows[off].branch +} + +// leftCheckboxHit reports whether (x,y) lands on a NEW branch's right-edge +// include checkbox in the left panel. +func (m Model) leftCheckboxHit(x, y int) bool { + leftW, _ := m.panelWidths() + rows := m.buildLeftRows(leftW - 2) + visH := m.leftVisibleHeight() + scroll := clampScroll(m.leftScroll, len(rows), visH) + vis := y - m.panelTopRow() + if vis < 0 || vis >= visH { + return false + } + off := vis + scroll + if off < 0 || off >= len(rows) { + return false + } + r := rows[off] + if r.branch < 0 || !r.nodeLine || m.nodes[r.branch].State != StateNew { + return false + } + // The checkbox sits one cell in from the right border: cols [leftW-5, leftW-2). + return x >= leftW-5 && x < leftW-2 +} + +// handleClick routes a left click to a branch row (left map) or an editor +// element (right panel). +func (m Model) handleClick(x, y int) (tea.Model, tea.Cmd) { + m.statusMessage = "" + m.statusIsError = false + + leftW, rightW := m.panelWidths() + + // Left panel: focus a branch, toggling include when its checkbox is clicked. + if idx := m.branchRowAt(x, y); idx != -1 { + onCheckbox := m.leftCheckboxHit(x, y) && m.nodes[idx].State == StateNew + m.saveEditor() + m.cursor = idx + m.scrollLeftToCursor() + m.loadEditor() + if onCheckbox { + excluding := m.nodes[idx].Included + extra := m.applyIncludeCascade(idx) + m.setCascadeStatus(excluding, extra) + cmd := m.focusFirstField() + return m, cmd + } + cmd := m.focusFirstField() + return m, cmd + } + + // Right panel. + if x < leftW { + return m, nil + } + n := m.currentNode() + if n == nil { + return m, nil + } + + // Mode 3 (locked): the existing PR opens only from the PR number or the + // "↗ Open on GitHub" button on the header row, not from clicking the card body. + if n.State.Locked() || n.State.Blocks() { + if lockedURL(*n) != "" && y-m.panelTopRow() == 0 { + numStart, numEnd, btnStart, btnEnd := m.lockedHeaderTargets(*n) + onNumber := numEnd > numStart && x >= numStart && x < numEnd + onButton := btnEnd > btnStart && x >= btnStart && x < btnEnd + if onNumber || onButton { + m.openFocusedPR() + } + } + return m, nil + } + + rel := y - m.panelTopRow() + titleLine, descLabel, descTop, descBot, draftLine := m.rightZones() + rightEdge := leftW + 1 + rightW + + // Include chip (header row 0, right side) toggles inclusion. + if rel == 0 { + if x >= rightEdge-2-lipgloss.Width(m.renderIncludeChip(*n)) { + cmd := m.toggleInclude() + return m, cmd + } + return m, nil + } + + // Mode 2 (skipped): the body is non-interactive (the footer shows an inert + // "SKIPPED"); only the chip toggles. + if n.State == StateNew && !n.Included { + return m, nil + } + + // Footer bottom-right action (NEXT BRANCH / SUBMIT) for an included branch. + // The footer sits two rows below the CREATE AS line. + if rel == draftLine+2 { + if btn := m.footerRightButton(); btn != "" && x >= rightEdge-2-lipgloss.Width(btn) { + if next := m.nextEditableIndex(); next != -1 { + cmd := m.moveCursor(next - m.cursor) + return m, cmd + } + return m.requestSubmit() + } + return m, nil + } + + // Mode 1 (included editor). + switch { + case rel == descLabel: + // The edit/preview sub-toggle is right-aligned on the DESCRIPTION line. + if x >= rightEdge-2-lipgloss.Width(m.renderDescToggle()) { + cmd := m.togglePreview() + return m, cmd + } + return m, nil + case rel >= titleLine-1 && rel <= titleLine+1: + cmd := m.focusField(fieldTitle) + return m, cmd + case rel >= descTop && rel <= descBot: + // Resolve the current top visible row, then map the clicked screen row to + // an absolute row in the wrapped text. + innerW := rightW - 4 + scroll := m.descScroll + if !m.descScrollPinned { + scroll = cursorViewTop(m.descCursorRow(innerW), m.descAreaHeight()) + } + cmd := m.focusField(fieldDescription) + if !m.descPreview { + m.positionDescCursor(scroll+rel-(descTop+1), x-(leftW+5)) + // Keep the clicked view fixed so the cursor lands where it was clicked. + m.descScroll = scroll + m.descScrollPinned = true + } + return m, cmd + case rel == draftLine: + // Only clicks inside the [ Ready | Draft ] brackets change the value; the + // "CREATE AS" label and the rest of the line are inert. The half the click + // lands in selects that option (left = Ready, right = Draft). + segStart, dividerX, segEnd := m.draftSegmentBounds() + if x >= segStart && x < segEnd { + _ = m.focusField(fieldDraft) + n.Draft = x >= dividerX + } + return m, nil + } + return m, nil +} + +// rightZones returns the right-panel line offsets (from panelTopRow) of the +// interactive editor elements for the included-NEW layout. +func (m Model) rightZones() (titleLine, descLabel, descTop, descBot, draftLine int) { + descH := m.descAreaHeight() + titleLine = 4 // title box content (header 0, rule 1, label 2, box 3..5) + descLabel = 6 + descTop = 7 // description box top border + descBot = 8 + descH + draftLine = 9 + descH + return +} + +// positionDescCursor moves the description textarea's cursor to the clicked +// visual row and column. It resets to the top of the buffer and walks down, so +// it is exact when the text is not scrolled (the common case now that the box +// fills the panel) and approximate otherwise. CursorUp/CursorDown move by +// visual line, so soft-wrapped lines are handled correctly. +func (m *Model) positionDescCursor(visRow, col int) { + if visRow < 0 { + visRow = 0 + } + if col < 0 { + col = 0 + } + for guard := 0; guard < 1000; guard++ { + row, off := m.descArea.Line(), m.descArea.LineInfo().RowOffset + m.descArea.CursorUp() + if m.descArea.Line() == row && m.descArea.LineInfo().RowOffset == off { + break // reached the top-left of the buffer + } + } + for i := 0; i < visRow; i++ { + m.descArea.CursorDown() + } + m.descArea.SetCursor(m.descArea.LineInfo().StartColumn + col) +} + +// updateHover tracks the branch row under the pointer for hover styling. +func (m *Model) updateHover(x, y int) { + m.hoverRow = m.branchRowAt(x, y) +} diff --git a/internal/tui/submitview/preview.go b/internal/tui/submitview/preview.go new file mode 100644 index 0000000..8a2e17f --- /dev/null +++ b/internal/tui/submitview/preview.go @@ -0,0 +1,150 @@ +package submitview + +import ( + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/styles" +) + +// editorFinishedMsg is delivered after the external $EDITOR process exits. +type editorFinishedMsg struct { + path string + err error +} + +// togglePreview flips the description between edit and preview, blurring or +// focusing the textarea accordingly. It returns any focus command. +func (m *Model) togglePreview() tea.Cmd { + // Edit and preview use different line coordinates, so reset the scroll. + m.descScroll = 0 + m.descScrollPinned = false + m.descPreview = !m.descPreview + if m.descPreview { + m.descArea.Blur() + return nil + } + if m.focusedField == fieldDescription { + return m.descArea.Focus() + } + return nil +} + +// openEditor launches $EDITOR on the focused branch's description, returning the +// ExecProcess command. If no editor is configured it surfaces a brief error and +// leaves the in-TUI textarea editable. +func (m Model) openEditor() (tea.Model, tea.Cmd) { + n := m.currentNode() + if n == nil || n.State != StateNew { + return m, nil + } + m.saveEditor() + + editor := resolveEditor() + if editor == "" { + m.statusMessage = "$EDITOR is not set — edit inline or set $EDITOR" + m.statusIsError = true + return m, nil + } + + path, err := writeTempDescription(m.nodes[m.cursor].Description) + if err != nil { + m.statusMessage = "Could not open editor: " + err.Error() + m.statusIsError = true + return m, nil + } + + fields := strings.Fields(editor) + args := append(fields[1:], path) + cmd := exec.Command(fields[0], args...) + return m, tea.ExecProcess(cmd, func(err error) tea.Msg { + return editorFinishedMsg{path: path, err: err} + }) +} + +// handleEditorFinished reloads the description from the temp file after the +// editor exits, then removes the file. +func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) { + defer func() { _ = os.Remove(msg.path) }() + + if msg.err != nil { + m.statusMessage = "Editor exited with an error — your inline edits are kept" + m.statusIsError = true + return m, nil + } + + data, err := os.ReadFile(msg.path) + if err != nil { + m.statusMessage = "Could not read the editor's output" + m.statusIsError = true + return m, nil + } + + content := strings.TrimRight(string(data), "\n") + if m.cursor >= 0 && m.cursor < len(m.nodes) { + m.nodes[m.cursor].Description = content + m.descArea.SetValue(content) + } + return m, nil +} + +// resolveEditor returns the configured editor command, checking GH_EDITOR, +// VISUAL, then EDITOR. It returns "" when none are set. +func resolveEditor() string { + for _, key := range []string{"GH_EDITOR", "VISUAL", "EDITOR"} { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + } + return "" +} + +// writeTempDescription writes content to a temporary markdown file and returns +// its path. +func writeTempDescription(content string) (string, error) { + f, err := os.CreateTemp("", "gh-stack-pr-*.md") + if err != nil { + return "", err + } + defer f.Close() + if _, err := f.WriteString(content); err != nil { + return "", err + } + return f.Name(), nil +} + +// renderMarkdown renders markdown to styled terminal output using Glamour. It +// uses a fixed dark style rather than glamour.WithAutoStyle(): auto-style probes +// the terminal background with an OSC query whose response is consumed by Bubble +// Tea's own input reader, so the query blocks forever and freezes the UI. On any +// error it falls back to the raw markdown so the user still sees their content. +func renderMarkdown(md string, width int) string { + if strings.TrimSpace(md) == "" { + return hintStyle.Render("(no description)") + } + if width < 10 { + width = 10 + } + // Use glamour's "dark" style but drop the document block's default 2-column + // margin so the preview text aligns flush-left with the edit-mode textarea + // instead of being indented. Copying the struct and replacing the Margin + // pointer leaves the shared package-level style untouched. + style := styles.DarkStyleConfig + var noMargin uint + style.Document.Margin = &noMargin + r, err := glamour.NewTermRenderer( + glamour.WithStyles(style), + glamour.WithWordWrap(width), + ) + if err != nil { + return md + } + out, err := r.Render(md) + if err != nil { + return md + } + return strings.Trim(out, "\n") +} diff --git a/internal/tui/submitview/preview_test.go b/internal/tui/submitview/preview_test.go new file mode 100644 index 0000000..3fd65d4 --- /dev/null +++ b/internal/tui/submitview/preview_test.go @@ -0,0 +1,90 @@ +package submitview + +import ( + "os" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTogglePreview(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + require.Equal(t, fieldDescription, m.focusedField) + require.True(t, m.descArea.Focused()) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP}) + assert.True(t, m.descPreview) + assert.False(t, m.descArea.Focused(), "textarea blurs in preview") + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP}) + assert.False(t, m.descPreview) + assert.True(t, m.descArea.Focused(), "textarea refocuses on returning to edit") +} + +func TestResolveEditor(t *testing.T) { + t.Setenv("GH_EDITOR", "") + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + assert.Equal(t, "", resolveEditor()) + + t.Setenv("EDITOR", "nano") + assert.Equal(t, "nano", resolveEditor()) + t.Setenv("VISUAL", "vim") + assert.Equal(t, "vim", resolveEditor()) + t.Setenv("GH_EDITOR", "code --wait") + assert.Equal(t, "code --wait", resolveEditor()) +} + +func TestOpenEditor_NoEditorSet(t *testing.T) { + t.Setenv("GH_EDITOR", "") + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + m := testModel(t, newNodes()) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlE}) + m = updated.(Model) + assert.Nil(t, cmd) + assert.True(t, m.statusIsError) + assert.Contains(t, m.statusMessage, "EDITOR") +} + +func TestHandleEditorFinished_UpdatesDescription(t *testing.T) { + m := testModel(t, newNodes()) + f, err := os.CreateTemp(t.TempDir(), "ed-*.md") + require.NoError(t, err) + _, _ = f.WriteString("Edited externally\n") + require.NoError(t, f.Close()) + + updated, _ := m.Update(editorFinishedMsg{path: f.Name()}) + m = updated.(Model) + assert.Equal(t, "Edited externally", m.nodes[1].Description) + + _, statErr := os.Stat(f.Name()) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestRenderMarkdown(t *testing.T) { + assert.Contains(t, renderMarkdown("", 40), "no description") + // Glamour styles each word as a separate ANSI span, so assert on the words + // individually rather than the literal "Hello World". + out := renderMarkdown("# Hello World", 40) + assert.Contains(t, out, "Hello") + assert.Contains(t, out, "World") +} + +func TestPreview_RendersWithoutHanging(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description (saves prefill) + // Set the description after focusing so the Tab's saveEditor doesn't clobber it. + m.nodes[1].Description = "# Heading\n\nBody text here." + m.descArea.SetValue("# Heading\n\nBody text here.") + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP}) // switch to preview + require.True(t, m.descPreview) + // View() exercises the Glamour render path; with a fixed style it must not + // query the terminal (which would block) and must show the content. + out := m.View() + assert.Contains(t, out, "Heading") +} diff --git a/internal/tui/submitview/render.go b/internal/tui/submitview/render.go new file mode 100644 index 0000000..5d9233a --- /dev/null +++ b/internal/tui/submitview/render.go @@ -0,0 +1,174 @@ +package submitview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/tui/shared" +) + +// Chrome styles shared across the submit views. +var ( + stackInfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) +) + +// headerHeight returns the number of screen rows the shared header occupies, or +// 0 when the terminal is too small to show it. +func (m Model) headerHeight() int { + if shared.ShouldShowHeader(m.width, m.height) { + return shared.HeaderHeightFor(m.buildHeaderConfig()) + } + return 0 +} + +// renderHeader renders the shared gh-stack header (GitHub art, title, stack +// info, and keyboard shortcuts), matching `gh stack view` and `gh stack +// modify`. It returns "" when the terminal is too small for the header. +func (m Model) renderHeader() string { + if !shared.ShouldShowHeader(m.width, m.height) { + return "" + } + var b strings.Builder + shared.RenderHeader(&b, m.buildHeaderConfig(), m.width, m.height) + return strings.TrimSuffix(b.String(), "\n") +} + +// buildHeaderConfig assembles the shared header configuration: title, stack +// info lines (including the consequence summary), and keyboard shortcuts. +func (m Model) buildHeaderConfig() shared.HeaderConfig { + repo := m.repoLabel + if repo == "" { + repo = "unknown" + } + + infoLines := []shared.HeaderInfoLine{ + {Icon: "○", Label: "Repo: " + repo}, + {Icon: "◆", Label: "Base: " + m.trunk.Branch}, + } + + // Third line mirrors the modify header's pending line: a solid yellow square + // with the count when PRs will be created, or an empty square otherwise. + newCount := 0 + for _, n := range m.nodes { + if n.State == StateNew && n.Included { + newCount++ + } + } + if newCount > 0 { + yellowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + prWord := "PRs" + if newCount == 1 { + prWord = "PR" + } + infoLines = append(infoLines, shared.HeaderInfoLine{ + Icon: "■", + Label: fmt.Sprintf("Creating %d %s", newCount, prWord), + IconStyle: &yellowStyle, + }) + } else { + infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "□", Label: "No pending PRs"}) + } + + return shared.HeaderConfig{ + ShowArt: true, + Title: "Submit Stack", + Subtitle: "v" + m.version, + InfoLines: infoLines, + ShortcutColumns: 1, + Shortcuts: m.headerShortcuts(), + } +} + +// headerShortcuts returns the six primary single-screen keyboard shortcuts shown +// in the header (the help overlay lists the full set). +func (m Model) headerShortcuts() []shared.ShortcutEntry { + return []shared.ShortcutEntry{ + {Key: "↑↓", Desc: "select branch"}, + {Key: "tab", Desc: "cycle field"}, + {Key: "^x", Desc: "skip/include"}, + {Key: "^s", Desc: "submit PRs"}, + {Key: "^h", Desc: "help"}, + {Key: "esc", Desc: "quit"}, + } +} + +// renderClosedBanner renders a slim full-width red banner directly under the +// header when the stack contains a closed PR, or "" otherwise. +func (m Model) renderClosedBanner() string { + closed := ClosedBranches(m.nodes) + if len(closed) == 0 { + return "" + } + verb := "branch has" + if len(closed) > 1 { + verb = "branches have" + } + msg := fmt.Sprintf(" %s %d %s a closed PR (%s) — reopen it, or unstack and recreate.", + RenderDot(StateClosed), len(closed), verb, strings.Join(closed, ", ")) + if lipgloss.Width(msg) > m.width { + msg = truncateVisible(msg, m.width) + } + return calloutErrorStyle.Render(msg) +} + +// composeLR places left and right content on one line of the given width with +// the right content flush to the right edge. +func composeLR(left, right string, width int) string { + lw := lipgloss.Width(left) + rw := lipgloss.Width(right) + gap := width - lw - rw + if gap < 1 { + gap = 1 + // Truncate the left side so the right stays visible. + if lw > width-rw-1 { + left = truncateVisible(left, width-rw-1) + } + } + return left + strings.Repeat(" ", gap) + right +} + +// truncateVisible truncates a possibly-styled string to at most maxWidth +// visible columns, appending an ellipsis when it had to cut. It resets styling +// at the cut so trailing ANSI does not leak. +func truncateVisible(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + if lipgloss.Width(s) <= maxWidth { + return s + } + var b strings.Builder + width := 0 + inEscape := false + for _, r := range s { + if r == '\x1b' { + inEscape = true + } + if inEscape { + b.WriteRune(r) + if r == 'm' { + inEscape = false + } + continue + } + if width >= maxWidth-1 { + b.WriteString("…") + b.WriteString("\x1b[0m") + break + } + b.WriteRune(r) + width++ + } + return b.String() +} + +// contentHeight returns the number of lines available for the two panels, +// between the header and the reserved status line. +func (m Model) contentHeight() int { + h := m.height - m.headerHeight() - 1 // status line + if h < 1 { + h = 1 + } + return h +} diff --git a/internal/tui/submitview/screen.go b/internal/tui/submitview/screen.go new file mode 100644 index 0000000..011ae3f --- /dev/null +++ b/internal/tui/submitview/screen.go @@ -0,0 +1,958 @@ +package submitview + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/github/gh-stack/internal/tui/shared" +) + +// --- Update --- + +// updateScreen handles all key input on the single submit screen. +func (m Model) updateScreen(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Global keys, handled regardless of focus. + switch msg.Type { + case tea.KeyCtrlC: + return m.quit() + case tea.KeyEsc: + m.saveEditor() + return m.quit() + case tea.KeyCtrlS: + m.saveEditor() + return m.requestSubmit() + case tea.KeyCtrlX: + cmd := m.toggleInclude() + return m, cmd + case tea.KeyCtrlO: + m.openFocusedPR() + return m, nil + case tea.KeyCtrlH: + // Global help so it works even while editing a field (where ? is typed). + m.showHelp = true + return m, nil + } + + node := m.currentNode() + editable := node != nil && node.State == StateNew && node.Included + + // Field cycling and editor controls. + if editable { + switch msg.Type { + case tea.KeyCtrlP: + cmd := m.togglePreview() + return m, cmd + case tea.KeyCtrlE: + return m.openEditor() + case tea.KeyTab: + m.saveEditor() + cmd := m.advanceField() + return m, cmd + case tea.KeyShiftTab: + m.saveEditor() + cmd := m.retreatField() + return m, cmd + } + } else { + // Skipped NEW or locked: tab just moves between branches. + switch msg.Type { + case tea.KeyTab: + cmd := m.moveCursor(1) + return m, cmd + case tea.KeyShiftTab: + cmd := m.moveCursor(-1) + return m, cmd + } + } + + // Up/down move between branches, except while actively editing the + // multi-line description text. + editingDesc := editable && m.focusedField == fieldDescription && !m.descPreview + if !editingDesc { + switch msg.Type { + case tea.KeyUp: + cmd := m.moveCursor(-1) + return m, cmd + case tea.KeyDown: + cmd := m.moveCursor(1) + return m, cmd + } + } + + // Locked branch (Mode 3): read-only card. + if node != nil && (node.State.Locked() || node.State.Blocks()) { + switch msg.String() { + case "?": + m.showHelp = true + return m, nil + case "q": + return m.quit() + } + return m, nil + } + + // Skipped NEW branch (Mode 2): only the Create-PR toggle is actionable. + if node != nil && node.State == StateNew && !node.Included { + switch { + case isSpaceKey(msg) || msg.Type == tea.KeyEnter: + cmd := m.toggleInclude() + return m, cmd + case msg.String() == "?": + m.showHelp = true + return m, nil + case msg.String() == "q": + return m.quit() + } + return m, nil + } + + // Included NEW branch (Mode 1): field-specific handling. + switch m.focusedField { + case fieldDraft: + switch msg.Type { + case tea.KeyLeft: + if node != nil { + node.Draft = false + } + return m, nil + case tea.KeyRight: + if node != nil { + node.Draft = true + } + return m, nil + } + switch { + case isSpaceKey(msg) || msg.Type == tea.KeyEnter: + if node != nil { + node.Draft = !node.Draft + } + return m, nil + case msg.String() == "?": + m.showHelp = true + return m, nil + case msg.String() == "q": + return m.quit() + } + return m, nil + + case fieldDescription: + if m.descPreview { + return m, nil + } + m.descScrollPinned = false // a keystroke snaps the view back to the cursor + var cmd tea.Cmd + m.descArea, cmd = m.descArea.Update(msg) + if node != nil { + node.Description = m.descArea.Value() + } + return m, cmd + + case fieldTitle: + var cmd tea.Cmd + m.titleInput, cmd = m.titleInput.Update(msg) + if node != nil { + node.Title = m.titleInput.Value() + } + return m, cmd + } + + return m, nil +} + +// --- Navigation & focus --- + +// moveCursor moves the focused branch by delta (clamped), saving and reloading +// the editor and focusing the new branch's first field. +func (m *Model) moveCursor(delta int) tea.Cmd { + next := m.cursor + delta + if next < 0 || next >= len(m.nodes) { + return nil + } + m.saveEditor() + m.cursor = next + m.scrollLeftToCursor() + m.loadEditor() + return m.focusFirstField() +} + +// moveCursorFocusLast is moveCursor but focuses the new branch's last field +// (used by shift+tab onto the previous branch). +func (m *Model) moveCursorFocusLast(delta int) tea.Cmd { + next := m.cursor + delta + if next < 0 || next >= len(m.nodes) { + return nil + } + m.saveEditor() + m.cursor = next + m.scrollLeftToCursor() + m.loadEditor() + return m.focusLastField() +} + +// focusFirstField focuses the first focusable element of the current branch: +// the title for an included NEW branch, nothing (a neutral state) otherwise. +func (m *Model) focusFirstField() tea.Cmd { + n := m.currentNode() + if n != nil && n.State == StateNew && n.Included { + return m.focusField(fieldTitle) + } + m.titleInput.Blur() + m.descArea.Blur() + m.focusedField = fieldTitle + return nil +} + +// focusLastField focuses the last focusable element of the current branch. +func (m *Model) focusLastField() tea.Cmd { + n := m.currentNode() + if n != nil && n.State == StateNew && n.Included { + return m.focusField(fieldDraft) + } + m.titleInput.Blur() + m.descArea.Blur() + m.focusedField = fieldTitle + return nil +} + +// toggleInclude flips the included state of the focused NEW branch, cascading +// the stack dependency to dependent branches, and keeps the Create-PR toggle +// focused. +func (m *Model) toggleInclude() tea.Cmd { + n := m.currentNode() + if n == nil || n.State != StateNew { + return nil + } + m.saveEditor() + excluding := n.Included + extra := m.applyIncludeCascade(m.cursor) + m.setCascadeStatus(excluding, extra) + return m.focusFirstField() +} + +// applyIncludeCascade flips the included state of the NEW branch at idx and +// propagates the stack dependency: because each PR builds on the branch below +// it, excluding a branch also excludes every branch stacked above it, while +// including a branch also includes every branch below it that it depends on. +// The cascade stops at the first non-NEW (locked) branch, which already +// provides a base. It returns the number of OTHER branches whose inclusion +// changed, for the status hint. +func (m *Model) applyIncludeCascade(idx int) int { + if idx < 0 || idx >= len(m.nodes) || m.nodes[idx].State != StateNew { + return 0 + } + if m.nodes[idx].Included { + return m.excludeFrom(idx) + } + return m.includeFrom(idx) +} + +// excludeFrom skips the NEW branch at idx and every NEW branch stacked above it +// (lower indices), stopping at the first locked branch. It returns the count of +// branches other than idx that changed. +func (m *Model) excludeFrom(idx int) int { + changed := 0 + for i := idx; i >= 0; i-- { + n := &m.nodes[i] + if n.State != StateNew { + break + } + if n.Included { + n.Included = false + if i != idx { + changed++ + } + } + } + return changed +} + +// includeFrom includes the NEW branch at idx and every NEW branch below it +// (higher indices) that it depends on, stopping at the first locked branch. It +// returns the count of branches other than idx that changed. +func (m *Model) includeFrom(idx int) int { + changed := 0 + for i := idx; i < len(m.nodes); i++ { + n := &m.nodes[i] + if n.State != StateNew { + break + } + if !n.Included { + n.Included = true + if i != idx { + changed++ + } + } + } + return changed +} + +// setCascadeStatus surfaces a transient hint when toggling a branch's inclusion +// also changed other branches because of the stack dependency. +func (m *Model) setCascadeStatus(excluded bool, extra int) { + if extra <= 0 { + return + } + noun := "branch" + if extra > 1 { + noun = "branches" + } + m.statusIsError = false + if excluded { + m.statusMessage = fmt.Sprintf("Also skipped %d %s stacked above — a PR builds on the branch below it", extra, noun) + return + } + m.statusMessage = fmt.Sprintf("Also included %d %s below that this PR depends on", extra, noun) +} + +// openFocusedPR opens the focused locked branch's PR in the browser. +func (m *Model) openFocusedPR() { + n := m.currentNode() + if n == nil { + return + } + url := "" + if n.PR != nil { + url = n.PR.URL + } else if n.Ref.PullRequest != nil { + url = n.Ref.PullRequest.URL + } + if url == "" { + return + } + if m.openURL != nil { + m.openURL(url) + return + } + shared.OpenBrowserInBackground(url) +} + +// advanceField moves focus to the next field on an included NEW branch, flowing +// onto the next PR (the next included NEW branch up the stack) after the +// ready/draft toggle. +func (m *Model) advanceField() tea.Cmd { + switch m.focusedField { + case fieldTitle: + return m.focusField(fieldDescription) + case fieldDescription: + return m.focusField(fieldDraft) + case fieldDraft: + if idx := m.nextEditableIndex(); idx != -1 { + return m.moveCursor(idx - m.cursor) + } + } + return nil +} + +// retreatField moves focus to the previous field, flowing onto the previous PR +// (the next included NEW branch down the stack) before the title. +func (m *Model) retreatField() tea.Cmd { + switch m.focusedField { + case fieldDraft: + return m.focusField(fieldDescription) + case fieldDescription: + return m.focusField(fieldTitle) + case fieldTitle: + if idx := m.prevEditableIndex(); idx != -1 { + return m.moveCursorFocusLast(idx - m.cursor) + } + } + return nil +} + +// --- Editor field/content management --- + +// focusField blurs all text components and focuses the given field, returning +// any cursor-blink command. The Create-PR and draft toggles are not text inputs. +func (m *Model) focusField(f editField) tea.Cmd { + m.focusedField = f + m.titleInput.Blur() + m.descArea.Blur() + switch f { + case fieldTitle: + return m.titleInput.Focus() + case fieldDescription: + if !m.descPreview { + return m.descArea.Focus() + } + } + return nil +} + +// maxDescScroll is the largest valid description scroll offset: the number of +// wrapped rows beyond what the box can show at once. +func (m Model) maxDescScroll(innerW int) int { + if n := m.currentNode(); n != nil && n.State.Locked() { + max := len(m.descPreviewLines(innerW)) - m.lockedDescHeight() + if max < 0 { + return 0 + } + return max + } + total := len(m.descFullLines(innerW)) + if m.descPreview { + total = len(m.descPreviewLines(innerW)) + } + max := total - m.descAreaHeight() + if max < 0 { + return 0 + } + return max +} + +// loadEditor loads the focused branch's draft into the input components. +func (m *Model) loadEditor() { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return + } + n := m.nodes[m.cursor] + m.titleInput.SetValue(n.Title) + m.descArea.SetValue(n.Description) + // Start at the top: SetValue leaves the cursor at the end, so move it back to + // the first line and reset the scroll. This keeps a prefilled (e.g. template) + // description scrolled to the top with the cursor on the first line. + m.descArea.CursorStart() + for i := 0; i < 100000 && m.descArea.Line() > 0; i++ { + m.descArea.CursorUp() + } + m.descArea.CursorStart() + m.descScroll = 0 + m.descScrollPinned = false +} + +// saveEditor writes the input components back to the focused branch. +func (m *Model) saveEditor() { + if m.cursor < 0 || m.cursor >= len(m.nodes) { + return + } + n := &m.nodes[m.cursor] + if n.State != StateNew { + return + } + n.Title = m.titleInput.Value() + n.Description = m.descArea.Value() +} + +// resizeEditor sizes the text components to the right panel's inner width. +func (m *Model) resizeEditor() { + _, rightW := m.panelWidths() + innerW := rightW - 4 // border + padding + if innerW < 10 { + innerW = 10 + } + // Field boxes add a border (2) and horizontal padding (2). The description + // also reserves columns for its scrollbar so its wrap matches descContent. + m.titleInput.Width = innerW - 4 + m.descArea.SetWidth(descTextWidth(innerW)) + m.descArea.SetHeight(m.descAreaHeight()) +} + +// nextEditableIndex returns the index of the next PR up the stack — the nearest +// included NEW branch above the cursor (lower index) — or -1. PRs are created +// bottom-up, so "up the stack" is the next PR in the flow. +func (m Model) nextEditableIndex() int { + for i := m.cursor - 1; i >= 0; i-- { + if m.nodes[i].State == StateNew && m.nodes[i].Included { + return i + } + } + return -1 +} + +// prevEditableIndex returns the index of the previous PR — the nearest included +// NEW branch below the cursor (higher index) — or -1. +func (m Model) prevEditableIndex() int { + for i := m.cursor + 1; i < len(m.nodes); i++ { + if m.nodes[i].State == StateNew && m.nodes[i].Included { + return i + } + } + return -1 +} + +// prProgress returns the focused branch's position in the new-PR set and the +// total number of PRs to create. PRs are numbered bottom-up (the branch closest +// to trunk is PR 1). pos is 0 when the focused branch is not an included NEW +// branch. +func (m Model) prProgress() (pos, total int) { + for i, n := range m.nodes { + if n.State == StateNew && n.Included { + total++ + if i >= m.cursor { + pos++ + } + } + } + if cur := m.currentNode(); cur == nil || cur.State != StateNew || !cur.Included { + pos = 0 + } + return pos, total +} + +// requestSubmit validates the included PRs and, if they are complete, marks the +// batch submit as requested and exits. If any included branch has an empty +// title, it focuses that branch's title field and surfaces a hint instead. +func (m Model) requestSubmit() (tea.Model, tea.Cmd) { + m.saveEditor() + if idx := m.firstEmptyTitleIndex(); idx != -1 { + m.cursor = idx + m.loadEditor() + _ = m.focusField(fieldTitle) + m.statusMessage = "A title is required — fill it in for " + m.nodes[idx].Ref.Branch + m.statusIsError = true + return m, nil + } + m.submitRequested = true + return m, tea.Quit +} + +// firstEmptyTitleIndex returns the index of the first included NEW branch with a +// blank title, or -1. +func (m Model) firstEmptyTitleIndex() int { + for i, n := range m.nodes { + if n.State == StateNew && n.Included && strings.TrimSpace(n.Title) == "" { + return i + } + } + return -1 +} + +// --- View --- + +// panelWidths returns the left and right panel outer widths. +func (m Model) panelWidths() (left, right int) { + left = m.width * 30 / 100 + if left < 22 { + left = 22 + } + if left > 34 { + left = 34 + } + right = m.width - left - 1 // 1-column gap + if right < 20 { + right = 20 + } + return left, right +} + +// descAreaHeight returns the number of text rows for the description textarea, +// sized so the description box fills the right panel's remaining vertical space. +func (m Model) descAreaHeight() int { + // Right-panel rows excluding the description text: header (1), separator + // rule (1), TITLE label + box (4), DESCRIPTION label (1), description box + // borders (2), OPEN-AS toggle (1), footer rule (1), footer strip (1) = 12. + overhead := 12 + // contentHeight() already excludes the slim header and bottom bar; subtract + // the panel border (2) and the chrome so the textarea fills the rest. + h := m.contentHeight() - 2 - overhead + if h < 3 { + h = 3 + } + return h +} + +// lockedDescHeight returns the rows for the read-only description preview in a +// locked PR's card, sized to fill the panel. Its chrome is lighter than the +// editor's: header(1), rule(1), TITLE label(1) + title box(3), DESCRIPTION +// label(1), and the description box borders(2) = 9, plus the panel border (2). +func (m Model) lockedDescHeight() int { + h := m.contentHeight() - 2 - 9 + if h < 3 { + h = 3 + } + return h +} + +func (m Model) viewScreen() string { + status := m.renderStatusLine() + banner := m.renderClosedBanner() + + panelH := m.contentHeight() + if banner != "" { + panelH-- // the banner occupies one content row + } + leftW, rightW := m.panelWidths() + + left := m.renderLeftPanel(leftW, panelH) + right := m.renderRightPanel(rightW, panelH) + body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) + + var out strings.Builder + if header := m.renderHeader(); header != "" { + out.WriteString(header) + out.WriteString("\n") + } else { + // The header (and its inline-image logo) is hidden; clear any logo that + // was previously drawn so it does not linger in the graphics layer. + out.WriteString(shared.ClearLogo()) + } + if banner != "" { + out.WriteString(banner) + out.WriteString("\n") + } + out.WriteString(body) + out.WriteString("\n") + out.WriteString(status) + return out.String() +} + +// renderStatusLine renders the transient status/hint line at the bottom. +func (m Model) renderStatusLine() string { + if m.statusMessage == "" { + return "" + } + if m.statusIsError { + return " " + calloutErrorStyle.Render("✗ "+m.statusMessage) + } + return " " + hintStyle.Render(m.statusMessage) +} + +// leftRow is one rendered line of the left stack panel plus the metadata the +// mouse layer needs to map a click back to a branch. +type leftRow struct { + text string // rendered content (without the panel frame) + branch int // owning branch index, or -1 for chrome (header/connector/trunk) + nodeLine bool // the branch's first line, where the right-edge checkbox sits +} + +// renderLeftPanel renders the stack as a vertical timeline: a circle node per +// branch — filled cyan when it will become a PR, a dotted gray ring when skipped, +// a state-colored ring for an existing PR — joined by a spine down to the trunk. +// Existing PRs show "state · #num" on a second line. The focused branch's full +// row (and a gap above/below) is shaded edge to edge. Long names wrap, and the +// content scrolls vertically when it is taller than the panel. +func (m Model) renderLeftPanel(width, height int) string { + fullW := width - 2 // border only; rows manage their own gutters + if fullW < 6 { + fullW = 6 + } + rows := m.buildLeftRows(fullW) + visH := height - 2 + if visH < 1 { + visH = 1 + } + scroll := clampScroll(m.leftScroll, len(rows), visH) + end := scroll + visH + if end > len(rows) { + end = len(rows) + } + + var b strings.Builder + for i, r := range rows[scroll:end] { + if i > 0 { + b.WriteString("\n") + } + b.WriteString(r.text) + } + return leftPanelBox(b.String(), width, height) +} + +// leftPanelBox frames the left panel with the shared rounded border but no inner +// horizontal padding, so a focused row's shade can span the full inner width. +func leftPanelBox(content string, width, height int) string { + innerW := width - 2 + innerH := height - 2 + if innerW < 1 { + innerW = 1 + } + if innerH < 1 { + innerH = 1 + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Width(innerW). + Height(innerH). + MaxHeight(height). + Render(content) +} + +// buildLeftRows lays out the whole left panel: the STACK header, each branch's +// node/name/meta lines, a spine gap between branches (shaded as padding around +// the focused branch), and the trunk. It is deterministic given the +// nodes/cursor/width, so the mouse layer can recompute it to resolve clicks. +func (m Model) buildLeftRows(fullW int) []leftRow { + cur := m.cursor + rows := []leftRow{ + {text: pad(1, false) + sectionLabelStyle.Render("STACK"), branch: -1}, + {text: m.gapRow(fullW, false, cur == 0), branch: -1}, // blank under STACK; top pad for branch 0 + } + for i := range m.nodes { + rows = append(rows, m.branchRows(i, fullW)...) + // Spine gap below branch i (to the next branch or the trunk). Shaded when + // it borders the focused branch, giving the highlight vertical padding. + rows = append(rows, leftRow{text: m.gapRow(fullW, true, i == cur || i+1 == cur), branch: -1}) + } + rows = append(rows, leftRow{ + text: pad(1, false) + spineStyle.Render("└─ ") + shared.TrunkStyle.Render(m.trunk.Branch), + branch: -1, + }) + return rows +} + +// gapRow renders a full-width spacer row: a 1-col gutter, an optional spine, then +// shaded fill. Shaded gaps form the focused branch's vertical padding. +func (m Model) gapRow(fullW int, withSpine, shaded bool) string { + lead := pad(1, shaded) + if withSpine { + lead += bgIf(spineStyle, shaded).Render("│") + } + return lead + pad(fullW-lipgloss.Width(lead), shaded) +} + +// branchRows renders one branch: the node line (circle + name, plus a right-edge +// include checkbox for a NEW branch), any wrapped name lines (spine + name), and +// — for an existing PR — a "state · #num" line below the name. The focused +// branch is shaded edge to edge. +func (m Model) branchRows(idx, fullW int) []leftRow { + n := m.nodes[idx] + focused := idx == m.cursor + nameStyle := m.branchNameStyle(n, focused) + + // Lead is gutter(1) + glyph(1) + gap(2) = 4; the trailing gutter is 1. A NEW + // branch also reserves its checkbox plus a separating space on the node line. + contWidth := fullW - 4 - 1 + firstWidth := contWidth + var checkbox string + if n.State == StateNew { + checkbox = m.branchCheckbox(n, focused) + firstWidth = fullW - 4 - lipgloss.Width(checkbox) - 1 - 1 + } + if firstWidth < 4 { + firstWidth = 4 + } + if contWidth < 4 { + contWidth = 4 + } + + parts := wrapName(n.Ref.Branch, firstWidth, contWidth) + rows := make([]leftRow, 0, len(parts)+1) + for li, part := range parts { + glyph := bgIf(spineStyle, focused).Render("│") + trailing := "" + if li == 0 { + glyph = m.branchCircle(n, focused) + trailing = checkbox // "" for existing PRs + } + rows = append(rows, leftRow{ + text: leftLine(glyph, nameStyle.Render(part), trailing, focused, fullW), + branch: idx, + nodeLine: li == 0, + }) + } + if n.State != StateNew { + rows = append(rows, leftRow{ + text: leftLine(bgIf(spineStyle, focused).Render("│"), m.branchMetaLine(n, focused), "", focused, fullW), + branch: idx, + }) + } + return rows +} + +// leftLine assembles a full-width (fullW) timeline row: a 1-col gutter, the +// timeline glyph, a 2-col gap, the body, an optional right-aligned trailing +// element before a 1-col gutter, with the remainder filled. Pre-styled pieces +// already carry the focus shade; the gutters/fill add it via pad. +func leftLine(glyph, body, trailing string, focused bool, fullW int) string { + lead := pad(1, focused) + glyph + pad(2, focused) + if trailing != "" { + gap := fullW - lipgloss.Width(lead) - lipgloss.Width(body) - lipgloss.Width(trailing) - 1 + if gap < 1 { + gap = 1 + } + return lead + body + pad(gap, focused) + trailing + pad(1, focused) + } + fill := fullW - lipgloss.Width(lead) - lipgloss.Width(body) + if fill < 0 { + fill = 0 + } + return lead + body + pad(fill, focused) +} + +// branchCircle renders the timeline node for a branch: a filled cyan circle when +// it will become a PR, a dotted gray ring when skipped, or a state-colored open +// ring for an existing PR. +func (m Model) branchCircle(n SubmitNode, focused bool) string { + glyph, color := "○", n.State.Color() + switch { + case n.State == StateNew && n.Included: + glyph, color = "●", lipgloss.Color("14") // filled cyan + case n.State == StateNew: + glyph, color = "◌", lipgloss.Color("245") // dotted ring: skipped + } + return bgIf(lipgloss.NewStyle().Foreground(color), focused).Render(glyph) +} + +// branchNameStyle returns the full-name style: white and bold for a branch that +// will become a PR, muted gray for skipped or existing-PR branches. +func (m Model) branchNameStyle(n SubmitNode, focused bool) lipgloss.Style { + st := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + if n.State == StateNew && n.Included { + st = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + } + return bgIf(st, focused) +} + +// branchCheckbox renders a NEW branch's include checkbox: cyan [x] when included, +// gray [ ] when skipped. +func (m Model) branchCheckbox(n SubmitNode, focused bool) string { + if n.Included { + return bgIf(lipgloss.NewStyle().Foreground(lipgloss.Color("14")), focused).Render("[x]") + } + return bgIf(lipgloss.NewStyle().Foreground(lipgloss.Color("8")), focused).Render("[ ]") +} + +// branchMetaLine renders an existing PR's "state · #num" line, the state word in +// its color and the separator/number in dim gray. +func (m Model) branchMetaLine(n SubmitNode, focused bool) string { + state := bgIf(lipgloss.NewStyle().Foreground(n.State.Color()), focused).Render(strings.ToLower(n.State.Label())) + if num := prNumber(n); num != 0 { + return state + bgIf(stackInfoStyle, focused).Render(fmt.Sprintf(" · #%d", num)) + } + return state +} + +// bgIf returns s with the focused row shade applied when focused, else s. +func bgIf(s lipgloss.Style, focused bool) lipgloss.Style { + if focused { + return s.Background(rowShadeColor) + } + return s +} + +// pad renders n spaces, shaded when focused, used to fill a focused row's +// background across the panel's content width. +func pad(n int, focused bool) string { + if n <= 0 { + return "" + } + return bgIf(lipgloss.NewStyle(), focused).Render(strings.Repeat(" ", n)) +} + +// leftVisibleHeight is the number of timeline rows the left panel can show. +func (m Model) leftVisibleHeight() int { + h := m.contentHeight() - 2 // panel border + if h < 1 { + h = 1 + } + return h +} + +// scrollLeftToCursor adjusts leftScroll so the focused branch's rows are visible. +func (m *Model) scrollLeftToCursor() { + leftW, _ := m.panelWidths() + rows := m.buildLeftRows(leftW - 2) + visH := m.leftVisibleHeight() + first, lastRow := -1, -1 + for i, r := range rows { + if r.branch == m.cursor { + if first < 0 { + first = i + } + lastRow = i + } + } + if first < 0 { + return + } + if m.leftScroll > first { + m.leftScroll = first + } + if m.leftScroll < lastRow-visH+1 { + m.leftScroll = lastRow - visH + 1 + } + m.leftScroll = clampScroll(m.leftScroll, len(rows), visH) +} + +// scrollLeft moves the left timeline's scroll offset by delta rows, clamped. +func (m *Model) scrollLeft(delta int) { + leftW, _ := m.panelWidths() + rows := m.buildLeftRows(leftW - 2) + m.leftScroll = clampScroll(m.leftScroll+delta, len(rows), m.leftVisibleHeight()) +} + +// wrapName splits a branch name into lines, the first at most firstWidth runes +// (the node line, which also carries the right-edge checkbox) and the rest at +// most contWidth runes. The full name is always shown — long names wrap rather +// than truncate. +func wrapName(s string, firstWidth, contWidth int) []string { + if firstWidth < 1 { + firstWidth = 1 + } + if contWidth < 1 { + contWidth = 1 + } + r := []rune(s) + if len(r) == 0 { + return []string{""} + } + var lines []string + w := firstWidth + for len(r) > w { + lines = append(lines, string(r[:w])) + r = r[w:] + w = contWidth + } + return append(lines, string(r)) +} + +// renderRightPanel renders the editor panel for the focused branch in one of +// three modes: included editor, skipped placeholder, or locked read-only card. +func (m Model) renderRightPanel(width, height int) string { + innerW := width - 4 + if innerW < 8 { + innerW = 8 + } + + n := m.currentNode() + var b strings.Builder + + switch { + case n == nil: + // Nothing focused. + case n.State.Locked() || n.State.Blocks(): + b.WriteString(m.renderLockedCard(*n, innerW)) + case n.State == StateNew && !n.Included: + b.WriteString(m.renderSkippedCard(*n, innerW)) + default: + b.WriteString(m.renderIncludedEditor(*n, innerW)) + } + + return panelBox(b.String(), width, height) +} + +// panelBox wraps content in a rounded panel of the given outer dimensions. Both +// panels use the same muted border; focus is conveyed by the highlighted input +// field inside, not the panel frame. +func panelBox(content string, width, height int) string { + innerW := width - 2 + innerH := height - 2 + if innerW < 1 { + innerW = 1 + } + if innerH < 1 { + innerH = 1 + } + return panelBorderStyle.Width(innerW).Height(innerH).MaxHeight(height).Render(content) +} + +// isSpaceKey reports whether a key message represents the space bar. +func isSpaceKey(msg tea.KeyMsg) bool { + if msg.Type == tea.KeySpace { + return true + } + return msg.Type == tea.KeyRunes && len(msg.Runes) == 1 && msg.Runes[0] == ' ' +} + +// prNumber returns the PR number associated with a node, preferring fresh +// details, or 0 if none. +func prNumber(n SubmitNode) int { + if n.PR != nil && n.PR.Number != 0 { + return n.PR.Number + } + if n.Ref.PullRequest != nil { + return n.Ref.PullRequest.Number + } + return 0 +} diff --git a/internal/tui/submitview/screen_test.go b/internal/tui/submitview/screen_test.go new file mode 100644 index 0000000..7018680 --- /dev/null +++ b/internal/tui/submitview/screen_test.go @@ -0,0 +1,722 @@ +package submitview + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFieldCycling_IncludedBranch(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, fieldTitle, m.focusedField) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, fieldDescription, m.focusedField) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) + assert.Equal(t, fieldDraft, m.focusedField) + + // shift+tab back up through the fields to the title. + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyShiftTab}) + assert.Equal(t, fieldDescription, m.focusedField) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyShiftTab}) + assert.Equal(t, fieldTitle, m.focusedField) +} + +func TestFieldCycling_TabFromDraftMovesToNextPR(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, 1, m.cursor) // bottom-most NEW (PR 1) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // desc + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // next PR (up the stack) + assert.Equal(t, 0, m.cursor) + assert.Equal(t, fieldTitle, m.focusedField) +} + +func TestTypingEditsTitle(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, runeKey('!')) + assert.Contains(t, m.nodes[1].Title, "!") +} + +func TestDraftToggle(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // desc + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft + require.Equal(t, fieldDraft, m.focusedField) + before := m.nodes[1].Draft + m = sendKey(t, m, runeKey(' ')) + assert.Equal(t, !before, m.nodes[1].Draft) +} + +func TestDraftToggle_DefaultsToReady(t *testing.T) { + m := testModel(t, newNodes()) + assert.False(t, m.nodes[1].Draft, "new PRs default to ready for review, not draft") +} + +func TestDraftToggle_ArrowKeysSelectOption(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // desc + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft + require.Equal(t, fieldDraft, m.focusedField) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyRight}) + assert.True(t, m.nodes[1].Draft, "→ selects draft") + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyRight}) + assert.True(t, m.nodes[1].Draft, "→ is idempotent") + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + assert.False(t, m.nodes[1].Draft, "← selects ready for review") + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + assert.False(t, m.nodes[1].Draft, "← is idempotent") +} + +func TestDraftToggle_RendersBothOptions(t *testing.T) { + m := testModel(t, newNodes()) + out := m.View() + assert.Contains(t, out, "Ready") + assert.Contains(t, out, "Draft") + assert.Contains(t, out, "CREATE AS") + assert.NotContains(t, out, "OPEN AS", "the label was renamed to CREATE AS") + assert.NotContains(t, out, "Open as draft", "the old checkbox label is gone") + assert.NotContains(t, out, "submitted as ready by default") +} + +func TestRightPanel_InlineShortcutHints(t *testing.T) { + m := testModel(t, newNodes()) + out := m.View() + assert.Contains(t, out, "CREATE PR (^x)", "the include switch shows its Ctrl+X hint") + assert.Contains(t, out, "preview (^p)", "the description toggle shows its Ctrl+P hint") + assert.Contains(t, out, "open in $EDITOR (^e)", "the CREATE AS row shows the editor shortcut") +} + +func TestRightFooter_NextBranchAndSubmit(t *testing.T) { + m := testModel(t, newNodes()) + _, rightW := m.panelWidths() + innerW := rightW - 4 + + // Bottom-most NEW (cursor starts here, title focused) has a PR up the stack. + require.NotEqual(t, -1, m.nextEditableIndex()) + require.NotEqual(t, fieldDraft, m.focusedField) + footer := m.renderRightFooter(m.nodes[m.cursor], innerW) + assert.Contains(t, footer, "NEXT BRANCH") + assert.NotContains(t, footer, "(tab)", "the tab hint is hidden unless the CREATE AS row is focused") + + // Focus the CREATE AS row: the tab hint appears (to the left of NEXT BRANCH). + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // draft (CREATE AS) + require.Equal(t, fieldDraft, m.focusedField) + footer = m.renderRightFooter(m.nodes[m.cursor], innerW) + assert.Contains(t, footer, "(tab) ", "the tab hint shows on the CREATE AS row") + assert.True(t, strings.Index(footer, "(tab)") < strings.Index(footer, "NEXT BRANCH"), "the tab hint sits to the left") + + // Move up to the top-most NEW: it is the last PR, so the footer offers submit. + for m.nextEditableIndex() != -1 { + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + } + footer = m.renderRightFooter(m.nodes[m.cursor], innerW) + assert.Contains(t, footer, "SUBMIT 2 PRs") + assert.Contains(t, footer, "(^s)") + assert.True(t, strings.Index(footer, "(^s)") < strings.Index(footer, "SUBMIT"), "the submit hint sits to the left") +} + +func TestRightFooter_SkippedShowsInertLabel(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch + require.False(t, m.nodes[m.cursor].Included) + _, rightW := m.panelWidths() + footer := m.renderRightFooter(m.nodes[m.cursor], rightW-4) + assert.Contains(t, footer, "SKIPPED", "a skipped branch shows SKIPPED") + assert.NotContains(t, footer, "SUBMIT", "no submit button on a skipped branch") + assert.NotContains(t, footer, "NEXT BRANCH", "no next-branch label on a skipped branch") + + // Clicking the SKIPPED label does nothing. + before := m.cursor + _, _, _, _, draftLine := m.rightZones() + leftW, rW := m.panelWidths() + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 1 + rW - 4, Y: m.panelTopRow() + draftLine + 2}) + m = updated.(Model) + assert.Equal(t, before, m.cursor, "clicking SKIPPED is inert") + assert.False(t, m.submitRequested) +} + +func TestMouse_ClickNextBranchAdvances(t *testing.T) { + m := testModel(t, newNodes()) + start := m.cursor + next := m.nextEditableIndex() + require.NotEqual(t, -1, next) + _, _, _, _, draftLine := m.rightZones() + leftW, rightW := m.panelWidths() + y := m.panelTopRow() + draftLine + 2 // footer row + x := leftW + 1 + rightW - 4 // within the right-aligned button + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: x, Y: y}) + m = updated.(Model) + assert.Equal(t, next, m.cursor, "clicking NEXT BRANCH advances to the next PR") + assert.NotEqual(t, start, m.cursor) +} + +func TestMouse_ClickSubmitRequestsSubmit(t *testing.T) { + m := testModel(t, newNodes()) + for m.nextEditableIndex() != -1 { // move to the last PR + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + } + _, _, _, _, draftLine := m.rightZones() + leftW, rightW := m.panelWidths() + y := m.panelTopRow() + draftLine + 2 + x := leftW + 1 + rightW - 4 + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: x, Y: y}) + m = updated.(Model) + assert.True(t, m.submitRequested, "clicking SUBMIT requests the batch submit") +} + +// --- dependency cascade --- + +func TestCascade_SkippingCascadesUp(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, 1, m.cursor) // bottom-most NEW + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip it + assert.False(t, m.nodes[1].Included, "the toggled branch is skipped") + assert.False(t, m.nodes[0].Included, "the branch stacked above it is skipped too") + assert.Contains(t, m.statusMessage, "Also skipped") +} + +func TestCascade_IncludingCascadesDown(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, 1, m.cursor) + // Skip branch 1, which cascades up to also skip branch 0. + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + require.False(t, m.nodes[0].Included) + require.False(t, m.nodes[1].Included) + + // Re-include branch 0 (top); the branch below it that it depends on is + // re-included too. + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + require.Equal(t, 0, m.cursor) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + assert.True(t, m.nodes[0].Included) + assert.True(t, m.nodes[1].Included, "the dependency below is re-included") + assert.Contains(t, m.statusMessage, "Also included") +} + +func TestCascade_TopBranchToggleDoesNotCascade(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // top branch + require.Equal(t, 0, m.cursor) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) + assert.False(t, m.nodes[0].Included) + assert.True(t, m.nodes[1].Included, "branches below the top are unaffected") + assert.Empty(t, m.statusMessage, "no cascade hint when nothing else changed") +} + +func TestPerBranchPersistence(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, 1, m.cursor) + m = sendKey(t, m, runeKey('X')) + edited := m.nodes[1].Title + require.Contains(t, edited, "X") + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) // branch 0 + require.Equal(t, 0, m.cursor) + assert.NotEqual(t, edited, m.titleInput.Value()) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // back to 1 + assert.Equal(t, edited, m.titleInput.Value()) +} + +func TestSkippedBranch_ShowsDimmedBody(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch + out := m.View() + assert.Contains(t, out, "CREATE PR", "the header switch reads CREATE PR") + assert.Contains(t, out, "include", "the footer offers to re-include") + assert.NotContains(t, out, "Create a pull request", "the old checkbox is gone") +} + +func TestSkippedBranch_SpaceReincludes(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip + require.False(t, m.nodes[1].Included) + + m = sendKey(t, m, runeKey(' ')) // re-include the skipped branch + assert.True(t, m.nodes[1].Included) +} + +func TestIncludeSwitch_ReflectsState(t *testing.T) { + on := []rune(stripANSI(renderSwitch(true))) + off := []rune(stripANSI(renderSwitch(false))) + knobIdx := func(rs []rune) int { + for i, r := range rs { + if r == '■' { + return i + } + } + return -1 + } + onIdx, offIdx := knobIdx(on), knobIdx(off) + require.GreaterOrEqual(t, onIdx, 0, "on switch must show a square knob") + require.GreaterOrEqual(t, offIdx, 0, "off switch must show a square knob") + assert.Greater(t, onIdx, offIdx, "the knob sits further right when on than off") + // The knob is inset one cell from each border in both states. + assert.Greater(t, onIdx, 0, "knob has left padding when on") + assert.Less(t, onIdx, len(on)-1, "knob has right padding when on") + assert.Greater(t, offIdx, 0, "knob has left padding when off") + assert.Less(t, offIdx, len(off)-1, "knob has right padding when off") +} + +func TestLockedBranch_ShowsReadOnlyCard(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) // locked (draft PR) + require.True(t, m.nodes[m.cursor].State.Locked()) + out := m.View() + assert.NotContains(t, out, "already has a pull request") + assert.NotContains(t, out, "pushed as part of this submit") + assert.Contains(t, out, "TITLE") + assert.Contains(t, out, "DESCRIPTION") + assert.Contains(t, out, "↗ Open on GitHub") + assert.Contains(t, out, "^o") +} + +func TestLockedBranch_CtrlOOpensPR(t *testing.T) { + m := testModel(t, newNodes()) + // Capture the open instead of launching a real browser. + var opened string + m.openURL = func(url string) { opened = url } + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + require.True(t, m.nodes[m.cursor].State.Locked()) + // ^o opens the focused branch's existing PR and must not error or quit. + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) + m = updated.(Model) + assert.False(t, m.cancelled) + assert.Nil(t, cmd) + assert.Equal(t, m.nodes[m.cursor].PR.URL, opened, "^o opens the focused PR's URL") +} + +func TestLockedBranch_DescriptionScrolls(t *testing.T) { + open := newNode("feat/open", StateOpen) + open.Description = "# Heading\n\n" + strings.Repeat("- a list item\n", 200) + m := testModel(t, []SubmitNode{newNode("feat/new", StateNew), open}) + for m.nodes[m.cursor].State == StateNew { + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + } + require.True(t, m.nodes[m.cursor].State.Locked()) + assert.True(t, m.isDescScrollable(), "a locked PR's description should be scrollable") + + m.scrollDesc(descScrollStep) + assert.Equal(t, descScrollStep, m.descScroll, "wheel-down advances the locked description") + m.scrollDesc(-descScrollStep * 2) + assert.Equal(t, 0, m.descScroll, "scrolling is clamped at the top") +} + +// navigateToLocked moves the cursor down to the first locked branch. +func navigateToLocked(t *testing.T, m Model) Model { + t.Helper() + for !m.nodes[m.cursor].State.Locked() { + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + } + return m +} + +func TestLockedBranch_ClickOpenButtonOpensPR(t *testing.T) { + m := navigateToLocked(t, testModel(t, newNodes())) + n := m.currentNode() + var opened string + m.openURL = func(url string) { opened = url } + _, _, btnStart, btnEnd := m.lockedHeaderTargets(*n) + require.Greater(t, btnEnd, btnStart, "the Open on GitHub button needs a click range") + + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: btnStart, Y: m.panelTopRow()}) + m = updated.(Model) + assert.Equal(t, n.PR.URL, opened, "clicking the Open on GitHub button opens the PR") +} + +func TestLockedBranch_ClickPRNumberOpensPR(t *testing.T) { + m := navigateToLocked(t, testModel(t, newNodes())) + n := m.currentNode() + var opened string + m.openURL = func(url string) { opened = url } + numStart, numEnd, _, _ := m.lockedHeaderTargets(*n) + require.Greater(t, numEnd, numStart, "the PR number needs a click range") + + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (numStart + numEnd) / 2, Y: m.panelTopRow()}) + m = updated.(Model) + assert.Equal(t, n.PR.URL, opened, "clicking the PR number opens the PR") +} + +func TestLockedBranch_ClickCardBodyDoesNotOpen(t *testing.T) { + m := navigateToLocked(t, testModel(t, newNodes())) + n := m.currentNode() + opened := "" + m.openURL = func(url string) { opened = url } + numStart, numEnd, btnStart, btnEnd := m.lockedHeaderTargets(*n) + require.Greater(t, numEnd, numStart) + require.Greater(t, btnEnd, btnStart) + require.Less(t, numEnd, btnStart, "the number and button should not be adjacent") + leftW, _ := m.panelWidths() + + // A click on the header row between the two targets does nothing. + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (numEnd + btnStart) / 2, Y: m.panelTopRow()}) + m = updated.(Model) + assert.Empty(t, opened, "clicking between the targets must not open the PR") + + // A click deeper in the card body (description area) does nothing either. + updated, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: m.panelTopRow() + 6}) + m = updated.(Model) + assert.Empty(t, opened, "clicking the card body must not open the PR") +} + +func TestEsc_QuitsFromAnyField(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // description focused + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = updated.(Model) + assert.True(t, m.cancelled) + assert.NotNil(t, cmd) +} + +// --- mouse on the editor --- + +func TestMouse_ClickIncludeChip(t *testing.T) { + m := testModel(t, newNodes()) + leftW, rightW := m.panelWidths() + y := m.panelTopRow() // header row 0 + x := leftW + 1 + rightW - 5 // the chip is right-aligned in the header + require.True(t, m.nodes[m.cursor].Included) + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: x, Y: y}) + m = updated.(Model) + assert.False(t, m.nodes[m.cursor].Included, "clicking the chip skips the branch") +} + +func TestMouse_CheckboxClickCascadesUp(t *testing.T) { + m := testModel(t, newNodes()) + // Click the include checkbox on branch index 1 (its right-edge box). + y, cbX := leftBranchNode(m, 1) + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: cbX, Y: y}) + m = updated.(Model) + require.Equal(t, 1, m.cursor) + assert.False(t, m.nodes[1].Included) + assert.False(t, m.nodes[0].Included, "clicking a checkbox cascades like ^x") + assert.Contains(t, m.statusMessage, "Also skipped") +} + +func TestLeftPanel_TimelineNoLegend(t *testing.T) { + m := testModel(t, newNodes()) + out := m.View() + assert.NotContains(t, out, "LEGEND", "the legend was removed") + assert.Contains(t, out, "feat/auth/tests", "the full branch name (incl prefix) is shown") + assert.Contains(t, out, "· #1240", "existing PRs show their number on the right") + assert.Contains(t, out, "└─ main", "the trunk anchors the timeline") +} + +func TestLeftPanel_SkippedUsesDottedRing(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlX}) // skip the focused branch + out := m.View() + assert.Contains(t, out, "◌", "skipped branches use a dotted ring") + assert.Contains(t, out, "[ ]", "skipped branches show an empty checkbox") +} + +func TestMouse_ClickWrappedNameFocuses(t *testing.T) { + long := newNode("feat/really/long/branch/name/that/wraps", StateNew) + m := testModel(t, []SubmitNode{long, newNode("feat/x", StateNew)}) + require.Equal(t, 1, m.cursor) // starts on the bottom-most NEW + leftW, _ := m.panelWidths() + rows := m.buildLeftRows(leftW - 4) + contY := -1 + for i, r := range rows { + if r.branch == 0 && !r.nodeLine { + contY = m.panelTopRow() + i + break + } + } + require.GreaterOrEqual(t, contY, 0, "the long branch name should wrap") + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: 6, Y: contY}) + m = updated.(Model) + assert.Equal(t, 0, m.cursor, "clicking a wrapped name line focuses that branch") +} + +func TestLeftPanel_ExistingPRMetaOnSecondLine(t *testing.T) { + m := testModel(t, newNodes()) + leftW, _ := m.panelWidths() + rows := m.buildLeftRows(leftW - 2) + bi := -1 + for i, n := range m.nodes { + if n.State != StateNew { + bi = i + break + } + } + require.GreaterOrEqual(t, bi, 0, "fixture has an existing-PR branch") + + var nodeText, metaText string + for _, r := range rows { + if r.branch != bi { + continue + } + if r.nodeLine { + nodeText = stripANSI(r.text) + } else { + metaText += stripANSI(r.text) + } + } + assert.NotContains(t, nodeText, "·", "the node line shows only the branch name") + assert.Contains(t, metaText, "· #", "the PR state and number sit on a line below the name") +} + +func TestLeftPanel_ScrollsToFocusedBranch(t *testing.T) { + m := testModel(t, newNodes()) + u, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 20}) // short window -> overflow + m = u.(Model) + leftW, _ := m.panelWidths() + require.Greater(t, len(m.buildLeftRows(leftW-2)), m.leftVisibleHeight(), "timeline overflows the panel") + require.Equal(t, 0, m.leftScroll) + + for m.cursor < len(m.nodes)-1 { + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyDown}) + } + assert.Greater(t, m.leftScroll, 0, "navigating down scrolls the focused branch into view") + + rows := m.buildLeftRows(leftW - 2) + visH := m.leftVisibleHeight() + scroll := clampScroll(m.leftScroll, len(rows), visH) + nodeIdx := -1 + for i, r := range rows { + if r.branch == m.cursor && r.nodeLine { + nodeIdx = i + break + } + } + require.GreaterOrEqual(t, nodeIdx, 0) + assert.GreaterOrEqual(t, nodeIdx, scroll, "the focused node is not above the window") + assert.Less(t, nodeIdx, scroll+visH, "the focused node is within the window") + + before := m.leftScroll + mm, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelUp, X: 3}) + m = mm.(Model) + assert.Less(t, m.leftScroll, before, "wheel-up over the left panel scrolls it up") +} + +func TestMouse_ClickTitleFocuses(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // move off title + require.Equal(t, fieldDescription, m.focusedField) + + titleLine, _, _, _, _ := m.rightZones() + leftW, _ := m.panelWidths() + y := m.panelTopRow() + titleLine + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: y}) + m = updated.(Model) + assert.Equal(t, fieldTitle, m.focusedField) +} + +func TestMouse_ClickDraftSegmentSelects(t *testing.T) { + m := testModel(t, newNodes()) + _, _, _, _, draftLine := m.rightZones() + y := m.panelTopRow() + draftLine + segStart, dividerX, segEnd := m.draftSegmentBounds() + require.False(t, m.nodes[1].Draft, "new PRs default to ready for review") + + // Clicking the right (Draft) half selects draft. + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (dividerX + segEnd) / 2, Y: y}) + m = updated.(Model) + assert.True(t, m.nodes[1].Draft, "clicking the Draft segment selects draft") + assert.Equal(t, fieldDraft, m.focusedField) + + // Clicking the left (Ready) half selects ready. + updated, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: (segStart + dividerX) / 2, Y: y}) + m = updated.(Model) + assert.False(t, m.nodes[1].Draft, "clicking the Ready segment selects ready") +} + +func TestMouse_ClickDraftLabelDoesNotToggle(t *testing.T) { + m := testModel(t, newNodes()) + _, _, _, _, draftLine := m.rightZones() + leftW, _ := m.panelWidths() + y := m.panelTopRow() + draftLine + before := m.nodes[1].Draft + + // A click on the "OPEN AS" label (left of the brackets) is inert. + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: y}) + m = updated.(Model) + assert.Equal(t, before, m.nodes[1].Draft, "clicking the OPEN AS label must not toggle the value") + + // A click past the closing bracket is also inert. + _, _, segEnd := m.draftSegmentBounds() + updated, _ = m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: segEnd + 3, Y: y}) + m = updated.(Model) + assert.Equal(t, before, m.nodes[1].Draft, "clicking past the brackets must not toggle the value") +} + +// --- description editor --- + +func TestDescription_ArrowsMoveCursorWithinText(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + require.Equal(t, fieldDescription, m.focusedField) + m.descArea.SetValue("one\ntwo\nthree") + m.descArea.CursorEnd() // last line + start := m.descArea.Line() + require.Greater(t, start, 0) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, start-1, m.descArea.Line(), "up arrow moves the cursor up a line") +} + +func TestMouse_ClickDescriptionPositionsCursor(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + m.descArea.SetValue("alpha\nbravo\ncharlie\ndelta") + leftW, _ := m.panelWidths() + _, _, descTop, _, _ := m.rightZones() + y := m.panelTopRow() + descTop + 1 + 2 // textarea content row index 2 + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonLeft, X: leftW + 5, Y: y}) + m = updated.(Model) + assert.Equal(t, 2, m.descArea.Line(), "clicking content row 2 positions the cursor on the third line") + assert.True(t, m.descArea.Focused()) +} + +func TestMouse_WheelScrollsDescriptionViewport(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + var sb strings.Builder + for i := 0; i < 40; i++ { + sb.WriteString("line\n") + } + m.nodes[0].Description = sb.String() + m.descArea.SetValue(sb.String()) + m.descArea.CursorEnd() + for i := 0; i < 60; i++ { + m.descArea.CursorUp() // cursor to the top + } + require.Equal(t, 0, m.descArea.Line()) + + updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60}) + m = updated.(Model) + assert.Greater(t, m.descScroll, 0, "wheel scrolls the viewport") + assert.True(t, m.descScrollPinned, "wheel pins the scroll offset") + assert.Equal(t, 0, m.descArea.Line(), "the cursor does not move while scrolling") + + // A keystroke returns to the cursor-following view. + m = sendKey(t, m, runeKey('z')) + assert.False(t, m.descScrollPinned, "typing returns to the cursor-following view") +} + +func TestMouse_WheelScrollBoundedByContent(t *testing.T) { + // The over-scroll bug only appears with a real color profile (the textarea + // pads blank rows with styled spaces), so force one here. + old := lipgloss.DefaultRenderer().ColorProfile() + lipgloss.SetColorProfile(termenv.ANSI256) + defer lipgloss.SetColorProfile(old) + + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + var sb strings.Builder + for i := 0; i < 30; i++ { + sb.WriteString("row\n") + } + m.nodes[0].Description = strings.TrimRight(sb.String(), "\n") + m.descArea.SetValue(strings.TrimRight(sb.String(), "\n")) + + _, rightW := m.panelWidths() + innerW := rightW - 4 + // The wrapped line count must match the real content (30 rows), not the + // textarea's padded height — otherwise the scroll range over-counts and the + // user can scroll past the last line. + assert.Equal(t, 30, len(m.descFullLines(innerW)), "wrapped line count matches content") + + // Wheel down far past the end. + for i := 0; i < 50; i++ { + u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60}) + m = u.(Model) + } + assert.Equal(t, m.maxDescScroll(innerW), m.descScroll, "scroll is clamped to the content") + // The last rendered row must be real content, not a blank past the last line. + rows := strings.Split(m.descContent(innerW), "\n") + last := strings.TrimSpace(stripANSI(rows[len(rows)-1])) + assert.NotEmpty(t, last, "cannot scroll past the last line of text") +} + +func TestDescAreaHeight_FillsAvailableSpace(t *testing.T) { + tall, _ := testModel(t, newNodes()).Update(tea.WindowSizeMsg{Width: 100, Height: 70}) + short, _ := testModel(t, newNodes()).Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + tallH := tall.(Model).descAreaHeight() + shortH := short.(Model).descAreaHeight() + assert.Greater(t, tallH, shortH, "the description box grows with the terminal") + assert.Greater(t, tallH, 20, "the box is no longer clamped to 20 rows") +} + +func TestLoadEditor_StartsAtTop(t *testing.T) { + m := testModel(t, newNodes()) + var sb strings.Builder + for i := 0; i < 20; i++ { + sb.WriteString("line\n") + } + m.nodes[m.cursor].Description = strings.TrimRight(sb.String(), "\n") + m.loadEditor() + assert.Equal(t, 0, m.descArea.Line(), "cursor starts on the first line") + assert.Equal(t, 0, m.descScroll, "view starts scrolled to the top") + assert.False(t, m.descScrollPinned) +} + +func TestDescScroll_PreservedWhenDefocused(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + var sb strings.Builder + for i := 0; i < 30; i++ { + sb.WriteString("line\n") + } + m.nodes[0].Description = strings.TrimRight(sb.String(), "\n") + m.descArea.SetValue(strings.TrimRight(sb.String(), "\n")) + for i := 0; i < 3; i++ { + u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60}) + m = u.(Model) + } + require.Greater(t, m.descScroll, 0) + scrolled := m.descScroll + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // de-focus (move to draft) + require.NotEqual(t, fieldDescription, m.focusedField) + assert.Equal(t, scrolled, m.descScroll, "scroll is preserved when the box loses focus") + assert.True(t, m.descScrollPinned) +} + +func TestScrollbar_RendersWhenOverflowing(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) + var sb strings.Builder + for i := 0; i < 40; i++ { + sb.WriteString("line\n") + } + m.nodes[0].Description = strings.TrimRight(sb.String(), "\n") + m.descArea.SetValue(strings.TrimRight(sb.String(), "\n")) + _, rightW := m.panelWidths() + out := m.descContent(rightW - 4) + assert.Contains(t, out, "┃", "scrollbar thumb is drawn when content overflows") + assert.Contains(t, out, "│", "scrollbar track is drawn when content overflows") +} + +func TestPreview_ScrollableWithScrollbar(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + var sb strings.Builder + sb.WriteString("# Title\n\n") + for i := 0; i < 40; i++ { + sb.WriteString("A paragraph of preview text.\n\n") + } + m.nodes[1].Description = strings.TrimRight(sb.String(), "\n") + m.descArea.SetValue(strings.TrimRight(sb.String(), "\n")) + + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyCtrlP}) // switch to preview + require.True(t, m.descPreview) + _, rightW := m.panelWidths() + assert.Contains(t, m.descContent(rightW-4), "┃", "preview has a scrollbar when it overflows") + + before := m.descScroll + u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown, X: 60}) + m = u.(Model) + assert.Greater(t, m.descScroll, before, "the wheel scrolls the preview") +} diff --git a/internal/tui/submitview/styles.go b/internal/tui/submitview/styles.go index 35c0353..9aa1dcd 100644 --- a/internal/tui/submitview/styles.go +++ b/internal/tui/submitview/styles.go @@ -2,12 +2,12 @@ package submitview import "github.com/charmbracelet/lipgloss" -// State foreground colors, aligned with the gh stack view / modify palette. +// State foreground colors, matching how GitHub.com colors these PR states. var stateColors = map[BranchState]lipgloss.Color{ - StateNew: lipgloss.Color("2"), // green - StateOpen: lipgloss.Color("4"), // blue - StateDraft: lipgloss.Color("3"), // amber - StateQueued: lipgloss.Color("130"), // orange + StateNew: lipgloss.Color("4"), // blue + StateOpen: lipgloss.Color("2"), // green + StateDraft: lipgloss.Color("250"), // gray + StateQueued: lipgloss.Color("137"), // brown StateMerged: lipgloss.Color("5"), // purple StateClosed: lipgloss.Color("1"), // red } @@ -15,12 +15,12 @@ var stateColors = map[BranchState]lipgloss.Color{ // State background tints for pill badges (dark 256-color shades that read as a // low-opacity wash of the foreground color across most terminal themes). var stateBgColors = map[BranchState]lipgloss.Color{ - StateNew: lipgloss.Color("22"), // dark green - StateOpen: lipgloss.Color("18"), // dark blue - StateDraft: lipgloss.Color("58"), // dark amber - StateQueued: lipgloss.Color("52"), // dark orange/red - StateMerged: lipgloss.Color("53"), // dark purple - StateClosed: lipgloss.Color("52"), // dark red + StateNew: lipgloss.Color("18"), // dark blue + StateOpen: lipgloss.Color("22"), // dark green + StateDraft: lipgloss.Color("238"), // dark gray + StateQueued: lipgloss.Color("58"), // dark brown + StateMerged: lipgloss.Color("53"), // dark purple + StateClosed: lipgloss.Color("52"), // dark red } // Label returns the uppercase badge text for a state (e.g. "NEW"). @@ -83,34 +83,26 @@ func RenderDot(s BranchState) string { return lipgloss.NewStyle().Foreground(s.Color()).Render(s.Dot()) } -// Shared submit-view styles. These are intentionally centralized so Step 1, -// Step 2, the editor, and the diff tab render with a consistent visual +// Shared submit-view styles. These are intentionally centralized so the left +// stack tree, the editor, and the chrome render with a consistent visual // language. var ( - // FocusAccent is the left accent bar that marks the focused row/branch. - FocusAccent = "▌" - - focusAccentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) // cyan - focusNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) - normalNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) - - // lockedStyle dims locked rows (~45% opacity feel). - lockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - - // Checkbox styles for Step 1. - checkboxCheckedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - checkboxUncheckedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - checkboxLockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - - // Panel borders for the Step 2 two-panel layout. + focusNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")).Bold(true) // cyan focused label + // headerBranchStyle renders the focused branch name in the right-panel card + // header in white (the left-panel cursor name stays cyan). + headerBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + + // rowShadeColor tints the focused (currently-viewed) branch row in the left + // timeline. A neutral cool gray (truecolor, so it doesn't pick up a warm tint + // from a themed 256-color palette) reading as a translucent-white highlight. + rowShadeColor = lipgloss.Color("#3b3e46") + + // Panel border shared by both panels (focus is shown on the active input + // field, not the panel frame). panelBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("8")). Padding(0, 1) - panelFocusedBorderStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("14")). - Padding(0, 1) // Section labels (e.g. STACK, EDITING, TITLE, DESCRIPTION). sectionLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Bold(true) @@ -120,12 +112,59 @@ var ( tabInactiveStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // Footer / status styles. - footerKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) - footerDescStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + footerKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + + // openLinkStyle renders the underlined white "↗ Open on GitHub" link (arrow + // included) in a locked PR's read-only card header; lockedTitleStyle renders + // that PR's title. + openLinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true).Underline(true) + lockedTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + + // Footer bottom-right actions: nextBranchStyle is the white "NEXT BRANCH" + // label; submitButtonStyle is the prominent solid-white "SUBMIT N PRs" button + // (dark text) shown on the last PR. + nextBranchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Bold(true) + submitButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("15")).Bold(true).Padding(0, 1) + // prNumberStyle renders a clickable existing-PR number as an underlined + // white link. + prNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Underline(true) + + // Tree spine + horizontal rules (dim chrome). + spineStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + ruleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // CREATE PR switch in the right-panel header. On: a green pill (matching the + // CREATE AS selected color) with a black square knob inset on the right. Off: + // the colors invert to a light-gray pill with a darker square inset on the + // left. The "CREATE PR" label uses the shared section-heading style. + switchOnStyle = lipgloss.NewStyle().Background(lipgloss.Color("2")) + switchOffStyle = lipgloss.NewStyle().Background(lipgloss.Color("245")) + switchOnKnob = lipgloss.Color("0") // black knob (matches CREATE AS selected text) + switchOffKnob = lipgloss.Color("236") // dark square on a lighter track + + // Segmented Ready/Draft control: the selected segment is filled green; the + // other is dim. Brackets/divider are dim chrome. + segOnStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("0")). + Background(lipgloss.Color("2")). + Bold(true). + Padding(0, 1) + segOffStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 1) + segFrameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // dimBodyStyle renders the skipped branch's body as muted, non-interactive + // chrome. + dimBodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + // descCursorStyle renders the block cursor overlaid on the scrollable + // description view. + descCursorStyle = lipgloss.NewStyle().Reverse(true) + + // Description scrollbar (track + thumb), drawn inside the box. + scrollTrackStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + scrollThumbStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")) // Callouts. calloutErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - readyTagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - editTagStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("14")) ) From cc603432d55e7f680816fe8b90b04d212c19e79b Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 19 Jun 2026 00:38:15 -0400 Subject: [PATCH 3/7] Wire the single-screen submit TUI into `gh stack submit` Launch the submit editor from `gh stack submit` in interactive terminals, collecting per-branch PR drafts and applying them in a single batch. In non-interactive terminals or with --auto, fall back to auto-generated titles and skip the editor. Update the README and CLI reference to describe the single-screen flow. --- README.md | 6 +- cmd/submit.go | 137 +++++++++++++++++++------ cmd/submit_tui_test.go | 83 +++++++++++++++ docs/src/content/docs/reference/cli.md | 11 +- 4 files changed, 201 insertions(+), 36 deletions(-) create mode 100644 cmd/submit_tui_test.go diff --git a/README.md b/README.md index 627a3c9..d008748 100644 --- a/README.md +++ b/README.md @@ -372,11 +372,13 @@ Creates a Stacked PR for every branch in the stack, pushing branches to the remo After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs will be added to the top of the stack. -When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely. +In an interactive terminal, `submit` opens a full-screen, mouse- and keyboard-driven editor on a single screen. Every branch without a PR is included by default — deselect any you don't want on the left panel (Ctrl+X). Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it. Draft each PR's title, description (with a markdown preview and `$EDITOR` escape), and choose ready-for-review or draft on the right, then submit them all at once with Ctrl+S. Pass `--auto` (or run in CI) to skip the editor and use auto-generated titles. + +In the editor, new PRs default to ready for review; flip any PR to draft with the ready ↔ draft toggle. With `--auto`, new PRs are created as drafts unless you pass `--open`. | Flag | Description | |------|-------------| -| `--auto` | Use auto-generated PR titles without prompting | +| `--auto` | Skip the editor and use auto-generated PR titles | | `--open` | Mark new and existing PRs as ready for review | | `--remote ` | Remote to push to (defaults to auto-detected remote) | diff --git a/cmd/submit.go b/cmd/submit.go index c788b2d..f5fb47d 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -6,6 +6,7 @@ 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" @@ -14,6 +15,7 @@ import ( "github.com/github/gh-stack/internal/modify" "github.com/github/gh-stack/internal/pr" "github.com/github/gh-stack/internal/stack" + "github.com/github/gh-stack/internal/tui/stackview" "github.com/github/gh-stack/internal/tui/submitview" "github.com/spf13/cobra" ) @@ -32,21 +34,27 @@ func SubmitCmd(cfg *config.Config) *cobra.Command { Short: "Create a stack of PRs on GitHub", Long: `Push all branches and create or update a stack of PRs on GitHub. +In an interactive terminal, a single-screen editor opens. Every branch without a +PR is included by default; deselect any you don't want with the checkbox or ^x, +and draft each PR's title, description, and draft state, then submit them all at +once with Ctrl+S. Pass --auto (or run in a non-interactive terminal) to skip the +editor and use auto-generated titles. + This command performs several steps: 1. Pushes all branches to the remote - 2. Creates new PRs for branches that don't have one + 2. Creates new PRs for the included branches 3. Updates base branches for existing PRs 4. Creates or updates the stack on GitHub -New PRs are created as drafts by default. Use --open to mark them as ready -for review.`, - Example: ` # Push and create/update PRs (prompts for PR titles) +In the editor, new PRs default to ready for review; toggle "Open as draft" per +PR. With --auto, new PRs are created as drafts unless you pass --open.`, + Example: ` # Push and create/update PRs (opens the interactive editor) $ gh stack submit - # Use auto-generated PR titles without prompting + # Skip the editor and use auto-generated PR titles $ gh stack submit --auto - # Mark all PRs as ready for review + # Mark new and existing PRs as ready for review $ gh stack submit --open`, RunE: func(cmd *cobra.Command, args []string) error { return runSubmit(cfg, opts) @@ -132,7 +140,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { } // Sync PR state to detect merged/queued PRs before pushing. - _ = syncStackPRs(cfg, s) + prDetails := syncStackPRs(cfg, s) // Resolve remote for pushing remote, err := pickRemote(cfg, currentBranch, opts.remote) @@ -179,15 +187,28 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { templateContent = pr.FindTemplate(repoRoot) } + // In an interactive terminal, open the TUI so the user can pick which new + // branches become PRs and draft each PR's title, description, and draft + // state. The drafts feed the create path below. On the --auto / + // non-interactive path drafts stays nil and ensurePR/createPR fall back to + // auto-generated titles and bodies (today's behavior). + var drafts map[string]*submitview.PRDraft + if cfg.IsInteractive() && !opts.auto { + collected, cancelled, tuiErr := collectPRDrafts(cfg, s, currentBranch, prDetails, templateContent) + if tuiErr != nil { + cfg.Errorf("failed to run the submit editor: %s", tuiErr) + return ErrSilent + } + if cancelled { + cfg.Printf("Submit cancelled — no branches were pushed") + return nil + } + drafts = collected + } + // Push each branch and create/update its PR in stack order (bottom to top). // Sequential pushing ensures each branch's base is up-to-date on the // remote before the next branch is pushed, preventing race conditions. - // - // drafts carries per-PR overrides from the interactive editor. It is nil on - // the --auto / non-interactive path, in which case ensurePR/createPR fall - // back to auto-generated titles and bodies (today's behavior). - var drafts map[string]*submitview.PRDraft - cfg.Printf("Pushing to %s...", remote) for i, b := range s.Branches { if s.Branches[i].IsMerged() || s.Branches[i].IsQueued() { @@ -229,6 +250,73 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { return nil } +// collectPRDrafts loads branch display data and runs the interactive submit TUI +// so the user can choose which new branches become PRs and draft each one. It +// returns the per-branch overrides, whether the user cancelled, and any error. +// When the stack contains no branches without a PR, it skips the TUI and +// returns nil drafts so the normal push/relink path runs. +func collectPRDrafts(cfg *config.Config, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) { + fmt.Fprintf(cfg.Err, "Loading stack...") + viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails) + fmt.Fprintf(cfg.Err, "\r\033[2K") + + // Reverse so index 0 = top of stack (matches the visual order). + reversed := make([]stackview.BranchNode, len(viewNodes)) + for i, n := range viewNodes { + reversed[len(viewNodes)-1-i] = n + } + nodes := submitview.NewSubmitNodes(reversed, templateContent) + + // Nothing to create — skip the TUI and run the normal push/relink path. + if submitview.CountNew(nodes) == 0 { + return nil, false, nil + } + + repoLabel := "" + if repo, err := cfg.Repo(); err == nil { + repoLabel = repo.Owner + "/" + repo.Name + } + + model := submitview.New(submitview.Options{ + Nodes: nodes, + Trunk: s.Trunk, + StackName: stackDisplayName(s), + RepoLabel: repoLabel, + Version: Version, + }) + + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseAllMotion()) + final, err := p.Run() + if err != nil { + return nil, false, fmt.Errorf("running submit TUI: %w", err) + } + + m, ok := final.(submitview.Model) + if !ok { + return nil, false, fmt.Errorf("unexpected model type %T", final) + } + if m.Cancelled() || !m.SubmitRequested() { + return nil, true, nil + } + return submitview.BuildDrafts(m.Nodes()), false, nil +} + +// stackDisplayName returns a human-readable name for the stack, used in the TUI +// header: the stack prefix, else the common branch-name prefix, else the +// bottom-most branch. +func stackDisplayName(s *stack.Stack) string { + if s.Prefix != "" { + return strings.TrimRight(s.Prefix, "/") + } + if p := submitview.CommonPrefix(s.BranchNames()); p != "" { + return strings.TrimRight(p, "/") + } + if len(s.Branches) > 0 { + return s.Branches[0].Branch + } + return s.Trunk.Branch +} + // ensurePR finds or creates a PR for the branch at index i, and updates // its base branch if needed. This is the single place where PR state is // reconciled during submit. @@ -330,27 +418,12 @@ func createPR(cfg *config.Config, client github.ClientOps, s *stack.Stack, i int body = generatePRBody(d.Body, "") isDraft = d.Draft } else { - // Auto / non-interactive default path. + // Auto / non-interactive default path: an auto-generated title and a + // body built from the branch's commits (the interactive title is + // drafted in the submit TUI instead). var commitBody string title, commitBody = defaultPRTitleBody(baseBranch, b.Branch) - originalTitle := title - if !opts.auto && cfg.IsInteractive() { - input, err := inputWithPrefill(cfg, fmt.Sprintf("Title for PR (branch %s):", b.Branch), title) - if err != nil { - if isInterruptError(err) { - return errInterrupt - } - // Non-interrupt error: keep the auto-generated title. - } else if input != "" { - title = input - } - } - - prBody := commitBody - if title != originalTitle && commitBody != "" { - prBody = originalTitle + "\n\n" + commitBody - } - body = generatePRBody(prBody, templateContent) + body = generatePRBody(commitBody, templateContent) } newPR, createErr := client.CreatePR(baseBranch, b.Branch, title, body, isDraft) diff --git a/cmd/submit_tui_test.go b/cmd/submit_tui_test.go new file mode 100644 index 0000000..7b3426e --- /dev/null +++ b/cmd/submit_tui_test.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "testing" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStackDisplayName(t *testing.T) { + tests := []struct { + name string + s *stack.Stack + want string + }{ + { + name: "uses stack prefix", + s: &stack.Stack{Prefix: "feat/", Trunk: stack.BranchRef{Branch: "main"}}, + want: "feat", + }, + { + name: "falls back to common branch prefix", + s: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "feat/auth/a"}, {Branch: "feat/auth/b"}}, + }, + want: "feat/auth", + }, + { + name: "single branch falls back to its name", + s: &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "solo"}}, + }, + want: "solo", + }, + { + name: "no branches falls back to trunk", + s: &stack.Stack{Trunk: stack.BranchRef{Branch: "main"}}, + want: "main", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, stackDisplayName(tt.s)) + }) + } +} + +// TestCollectPRDrafts_SkipsWhenNoNewBranches verifies the TUI is skipped (no +// program launched) when every branch already has a PR, returning nil drafts so +// the normal push/relink path runs. +func TestCollectPRDrafts_SkipsWhenNoNewBranches(t *testing.T) { + s := &stack.Stack{ + Trunk: stack.BranchRef{Branch: "main"}, + Branches: []stack.BranchRef{{Branch: "b1"}, {Branch: "b2"}}, + } + + mock := &git.MockOps{ + RootDirFn: func() (string, error) { return t.TempDir(), nil }, + IsAncestorFn: func(a, b string) (bool, error) { return true, nil }, + MergeBaseFn: func(a, b string) (string, error) { return a, nil }, + LogRangeFn: func(base, head string) ([]git.CommitInfo, error) { return []git.CommitInfo{{Subject: "c"}}, nil }, + DiffStatFilesFn: func(base, head string) ([]git.FileDiffStat, error) { return nil, nil }, + } + restore := git.SetOps(mock) + defer restore() + + cfg, _, _ := config.NewTestConfig() + prDetails := map[string]*github.PRDetails{ + "b1": {Number: 1, State: "OPEN"}, + "b2": {Number: 2, State: "OPEN"}, + } + + drafts, cancelled, err := collectPRDrafts(cfg, s, "b1", prDetails, "") + require.NoError(t, err) + assert.False(t, cancelled) + assert.Nil(t, drafts, "no NEW branches means the TUI is skipped and drafts are nil") +} diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index 3855fcb..ce2d268 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -269,11 +269,18 @@ gh stack submit [flags] Creates a Stacked PR for every branch in the stack, pushing branches to the remote. After creating PRs, `submit` automatically creates a **Stack** on GitHub to link the PRs together. If the stack already exists on GitHub (e.g., from a previous submit), new PRs are added to the existing stack. -When creating new PRs, you will be prompted to enter a title for each one. Press Enter to accept the default (branch name), or use `--auto` to skip prompting entirely. New PRs are created as **drafts by default**; use `--open` to create new PRs as ready for review and to mark existing PRs as ready for review. +In an interactive terminal, `submit` opens a full-screen editor on a single screen: + +- **Left panel** — every branch without a PR is **included by default**; deselect any you don't want to submit with Ctrl+X. Because each PR builds on the branch below it, deselecting a branch also deselects the ones stacked above it, and re-including a branch re-includes the ones below it that it depends on. Branches that already have a PR (open, draft, queued, or merged) are shown for context but are locked; edit those on the web. +- **Right panel** — for the focused branch, draft the title, description (pre-filled from your repo's PR template or commits, with a Glamour markdown preview and `$EDITOR` escape), and whether it opens ready for review or as a draft (a ready ↔ draft toggle). Focusing a locked branch shows a read-only card with a link to its PR (o to open in the browser). + +Press Ctrl+S to submit all included PRs at once. The editor supports both keyboard and mouse input. Pass `--auto` (or run in a non-interactive terminal, such as CI) to skip the editor and use auto-generated titles. + +In the editor, new PRs default to **ready for review**; flip any PR to **draft** with the ready ↔ draft toggle. With `--auto`, new PRs are created as **drafts** unless you pass `--open`. | Flag | Description | |------|-------------| -| `--auto` | Use auto-generated PR titles without prompting | +| `--auto` | Skip the editor and use auto-generated PR titles | | `--open` | Create new PRs as ready for review instead of drafts, and mark existing PRs as ready for review | | `--remote ` | Remote to push to (defaults to auto-detected remote) | From a24bf4f68ab7ae73b431a1e161c3daf1059c3484 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 26 Jun 2026 09:04:18 -0400 Subject: [PATCH 4/7] Use the API PR title/body for existing PRs and fix new-PR defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For existing PRs, the submit TUI showed a commit/template-derived draft instead of the pull request's real title and body. Fetch the actual title and body and render them in the read-only card: - open/draft/queued (tracked) and adopted-open PRs now carry title/body through the existing batch sync (added the fields to the GraphQL queries and PRDetails — no extra round trips), and - merged branches (which skip the live refresh) are filled in by a targeted enrichment step run only when the submit TUI opens. Also align the new-PR defaults with the non-TUI submit's defaultPRTitleBody: - Title: the commit subject only when the branch has exactly one commit, otherwise the humanized branch name (was: the oldest commit's subject even for multi-commit branches). - Description: the PR template, else the single commit's body, else empty (removed the bulleted commit-subject list for multi-commit branches). --- cmd/submit.go | 8 +++- cmd/submit_tui_test.go | 2 +- cmd/utils.go | 33 ++++++++++++++++ cmd/utils_test.go | 22 +++++++++++ internal/github/github.go | 12 ++++++ internal/tui/submitview/data.go | 58 ++++++++++++---------------- internal/tui/submitview/data_test.go | 29 ++++++++++---- 7 files changed, 120 insertions(+), 44 deletions(-) diff --git a/cmd/submit.go b/cmd/submit.go index f5fb47d..0a68cc9 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -194,7 +194,7 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // auto-generated titles and bodies (today's behavior). var drafts map[string]*submitview.PRDraft if cfg.IsInteractive() && !opts.auto { - collected, cancelled, tuiErr := collectPRDrafts(cfg, s, currentBranch, prDetails, templateContent) + collected, cancelled, tuiErr := collectPRDrafts(cfg, client, s, currentBranch, prDetails, templateContent) if tuiErr != nil { cfg.Errorf("failed to run the submit editor: %s", tuiErr) return ErrSilent @@ -255,7 +255,11 @@ func runSubmit(cfg *config.Config, opts *submitOptions) error { // returns the per-branch overrides, whether the user cancelled, and any error. // When the stack contains no branches without a PR, it skips the TUI and // returns nil drafts so the normal push/relink path runs. -func collectPRDrafts(cfg *config.Config, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) { +func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack, currentBranch string, prDetails map[string]*github.PRDetails, templateContent string) (map[string]*submitview.PRDraft, bool, error) { + // Fill in the real title/description for existing PRs that were synced + // without them (e.g. merged branches) so the read-only cards show API data. + enrichPRContent(client, prDetails) + fmt.Fprintf(cfg.Err, "Loading stack...") viewNodes := stackview.LoadBranchNodes(cfg, s, currentBranch, prDetails) fmt.Fprintf(cfg.Err, "\r\033[2K") diff --git a/cmd/submit_tui_test.go b/cmd/submit_tui_test.go index 7b3426e..e353e8e 100644 --- a/cmd/submit_tui_test.go +++ b/cmd/submit_tui_test.go @@ -76,7 +76,7 @@ func TestCollectPRDrafts_SkipsWhenNoNewBranches(t *testing.T) { "b2": {Number: 2, State: "OPEN"}, } - drafts, cancelled, err := collectPRDrafts(cfg, s, "b1", prDetails, "") + drafts, cancelled, err := collectPRDrafts(cfg, nil, s, "b1", prDetails, "") require.NoError(t, err) assert.False(t, cancelled) assert.Nil(t, drafts, "no NEW branches means the TUI is skipped and drafts are nil") diff --git a/cmd/utils.go b/cmd/utils.go index 2cdd816..488cffe 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -412,6 +412,8 @@ func syncStackPRs(cfg *config.Config, s *stack.Stack) map[string]*github.PRDetai Number: pr.Number, State: "OPEN", URL: pr.URL, + Title: pr.Title, + Body: pr.Body, IsDraft: pr.IsDraft, Merged: false, IsQueued: pr.IsQueued(), @@ -458,6 +460,8 @@ func prDetailsFromPR(pr *github.PullRequest) *github.PRDetails { Number: pr.Number, State: pr.State, URL: pr.URL, + Title: pr.Title, + Body: pr.Body, IsDraft: pr.IsDraft, Merged: pr.Merged, IsQueued: pr.IsQueued(), @@ -481,6 +485,35 @@ func prDetailsFromTracked(ref *stack.PullRequestRef) *github.PRDetails { } } +// enrichPRContent fills in the Title and Body of any existing PR whose details +// were built without them (e.g. merged branches, which skip the live refresh in +// syncStackPRs). It is used before the submit TUI renders an existing PR's +// read-only card so it shows the real PR title and description. PRs that already +// have a title (the common open/draft/queued case) are left untouched. +func enrichPRContent(client github.ClientOps, details map[string]*github.PRDetails) { + if client == nil { + return + } + var wg sync.WaitGroup + sem := make(chan struct{}, maxAPIConcurrency) + for _, d := range details { + if d == nil || d.Number == 0 || strings.TrimSpace(d.Title) != "" { + continue + } + wg.Add(1) + go func(d *github.PRDetails) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + if pr, err := client.FindPRByNumber(d.Number); err == nil && pr != nil { + d.Title = pr.Title + d.Body = pr.Body + } + }(d) + } + wg.Wait() +} + // syncStackPRsFromRemote uses the stack API to sync PR state. The remote // stack's PR list is the source of truth — PRs stay associated even if // closed. Returns the PRDetails map and true if sync succeeded, or nil and diff --git a/cmd/utils_test.go b/cmd/utils_test.go index 49ce5d9..75f38c4 100644 --- a/cmd/utils_test.go +++ b/cmd/utils_test.go @@ -810,3 +810,25 @@ func TestEnsureLocalTrunk_CreateFails(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "could not create local trunk branch main") } + +func TestEnrichPRContent(t *testing.T) { + calls := 0 + client := &github.MockClient{ + FindPRByNumberFn: func(number int) (*github.PullRequest, error) { + calls++ + return &github.PullRequest{Number: number, Title: "Fetched title", Body: "Fetched body"}, nil + }, + } + details := map[string]*github.PRDetails{ + "merged": {Number: 10, State: "MERGED"}, // missing title -> fetched + "open": {Number: 11, State: "OPEN", Title: "Has it"}, // already has a title -> skipped + "nonum": {Number: 0, State: "OPEN"}, // no number -> skipped + } + + enrichPRContent(client, details) + + assert.Equal(t, 1, calls, "only the title-less PR with a number is fetched") + assert.Equal(t, "Fetched title", details["merged"].Title) + assert.Equal(t, "Fetched body", details["merged"].Body) + assert.Equal(t, "Has it", details["open"].Title, "PRs that already have a title are untouched") +} diff --git a/internal/github/github.go b/internal/github/github.go index 6a5a79b..78d3370 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -29,6 +29,8 @@ type PullRequest struct { Number int `graphql:"number"` State string `graphql:"state"` URL string `graphql:"url"` + Title string `graphql:"title"` + Body string `graphql:"body"` HeadRefName string `graphql:"headRefName"` BaseRefName string `graphql:"baseRefName"` IsDraft bool `graphql:"isDraft"` @@ -101,6 +103,8 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { ID string `graphql:"id"` Number int `graphql:"number"` URL string `graphql:"url"` + Title string `graphql:"title"` + Body string `graphql:"body"` BaseRefName string `graphql:"baseRefName"` IsDraft bool `graphql:"isDraft"` MergeQueueEntry *MergeQueueEntry `graphql:"mergeQueueEntry"` @@ -130,6 +134,8 @@ func (c *Client) FindPRForBranch(branch string) (*PullRequest, error) { ID: n.ID, Number: n.Number, URL: n.URL, + Title: n.Title, + Body: n.Body, BaseRefName: n.BaseRefName, IsDraft: n.IsDraft, MergeQueueEntry: n.MergeQueueEntry, @@ -279,6 +285,8 @@ type PRDetails struct { Number int State string // OPEN, CLOSED, MERGED URL string + Title string + Body string IsDraft bool Merged bool IsQueued bool @@ -342,6 +350,8 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) { Number int `graphql:"number"` State string `graphql:"state"` URL string `graphql:"url"` + Title string `graphql:"title"` + Body string `graphql:"body"` HeadRefName string `graphql:"headRefName"` BaseRefName string `graphql:"baseRefName"` IsDraft bool `graphql:"isDraft"` @@ -371,6 +381,8 @@ func (c *Client) FindPRByNumber(number int) (*PullRequest, error) { Number: n.Number, State: n.State, URL: n.URL, + Title: n.Title, + Body: n.Body, HeadRefName: n.HeadRefName, BaseRefName: n.BaseRefName, IsDraft: n.IsDraft, diff --git a/internal/tui/submitview/data.go b/internal/tui/submitview/data.go index de3ef9c..6b2d46f 100644 --- a/internal/tui/submitview/data.go +++ b/internal/tui/submitview/data.go @@ -40,61 +40,53 @@ func DeriveState(node stackview.BranchNode) BranchState { return StateNew } -// PrefillTitle returns the default PR title for a node: the subject of the -// branch's first (oldest) commit when it has any commits, otherwise the -// humanized branch name. git.LogRange returns commits newest-first, so the -// oldest commit — the one that established the branch — is the last element. +// PrefillTitle returns the default PR title for a new branch: the subject of its +// single commit when the branch has exactly one commit, otherwise the humanized +// branch name. This mirrors the non-TUI submit's defaultPRTitleBody. func PrefillTitle(node stackview.BranchNode) string { - if n := len(node.Commits); n > 0 { - if subject := strings.TrimSpace(node.Commits[n-1].Subject); subject != "" { + if commits := node.Commits; len(commits) == 1 { + if subject := strings.TrimSpace(commits[0].Subject); subject != "" { return subject } } return humanize(node.Ref.Branch) } -// PrefillDescription returns the default PR description following the spec's -// priority order: the repo PR template if one exists, otherwise the single -// commit body, otherwise a bulleted list of commit subjects for multi-commit -// branches. The attribution footer is appended later, at submit time. +// PrefillDescription returns the default PR description for a new branch: the +// repo PR template if one exists, otherwise the body of the branch's single +// commit, otherwise empty. Multi-commit branches with no template get an empty +// description (no bulleted commit list). This mirrors the non-TUI submit. The +// attribution footer is appended later, at submit time. func PrefillDescription(node stackview.BranchNode, template string) string { if t := strings.TrimSpace(template); t != "" { return t } - - commits := node.Commits - switch { - case len(commits) == 1: + if commits := node.Commits; len(commits) == 1 { return strings.TrimSpace(commits[0].Body) - case len(commits) > 1: - var b strings.Builder - // List oldest commit first so the body reads like a changelog. - for i := len(commits) - 1; i >= 0; i-- { - subject := strings.TrimSpace(commits[i].Subject) - if subject == "" { - continue - } - b.WriteString("- ") - b.WriteString(subject) - b.WriteString("\n") - } - return strings.TrimSpace(b.String()) - default: - return "" } + return "" } // NewSubmitNodes builds the per-branch UI state for the submit TUI from loaded -// branch display data. NEW branches default to included; every node's title and -// description are prefilled. New PRs default to ready for review; the per-PR -// draft toggle starts off. The prefill snapshots are retained for edit -// detection. +// branch display data. NEW branches default to included; new PRs have their +// title and description prefilled from commits and the PR template, while +// existing PRs show their real title and description fetched from the API. New +// PRs default to ready for review; the per-PR draft toggle starts off. The +// prefill snapshots are retained for edit detection. func NewSubmitNodes(nodes []stackview.BranchNode, template string) []SubmitNode { out := make([]SubmitNode, len(nodes)) for i, n := range nodes { state := DeriveState(n) title := PrefillTitle(n) desc := PrefillDescription(n, template) + // Existing PRs render their real title/description from the API instead + // of the commit/template-derived draft used for new PRs. + if state != StateNew && n.PR != nil { + desc = n.PR.Body + if t := strings.TrimSpace(n.PR.Title); t != "" { + title = t + } + } out[i] = SubmitNode{ BranchNode: n, State: state, diff --git a/internal/tui/submitview/data_test.go b/internal/tui/submitview/data_test.go index d6919df..afb26dd 100644 --- a/internal/tui/submitview/data_test.go +++ b/internal/tui/submitview/data_test.go @@ -102,11 +102,11 @@ func TestPrefillTitle(t *testing.T) { assert.Equal(t, "Add auth middleware", PrefillTitle(n)) }) - t.Run("multiple commits use the first (oldest) commit subject", func(t *testing.T) { - // git.LogRange returns commits newest-first, so the last element is the - // oldest — the commit that established the branch. + t.Run("multiple commits humanize the branch name", func(t *testing.T) { + // Only a single-commit branch uses the commit subject; multi-commit + // branches default to the humanized branch name (matches non-TUI submit). n := node("feat/auth-middleware", commit("Polish middleware", ""), commit("Add auth middleware", "")) - assert.Equal(t, "Add auth middleware", PrefillTitle(n)) + assert.Equal(t, "feat/auth middleware", PrefillTitle(n)) }) t.Run("zero commits humanize branch name", func(t *testing.T) { @@ -131,11 +131,10 @@ func TestPrefillDescription(t *testing.T) { assert.Equal(t, "Detailed body\nsecond line", PrefillDescription(n, "")) }) - t.Run("multi commit lists subjects oldest first", func(t *testing.T) { - // LogRange returns newest first; the body should read oldest first. + t.Run("multi commit with no template is empty", func(t *testing.T) { + // No bulleted commit list — multi-commit branches default to empty. n := node("feat/a", commit("newest", ""), commit("middle", ""), commit("oldest", "")) - got := PrefillDescription(n, "") - assert.Equal(t, "- oldest\n- middle\n- newest", got) + assert.Equal(t, "", PrefillDescription(n, "")) }) t.Run("no commits no template is empty", func(t *testing.T) { @@ -167,6 +166,20 @@ func TestNewSubmitNodes(t *testing.T) { assert.False(t, got[1].Included) } +func TestNewSubmitNodes_ExistingPRUsesAPIContent(t *testing.T) { + // An existing PR shows its real title/body from the API, not the + // commit-subject / template prefill used for new PRs. + nodes := []stackview.BranchNode{ + withPR(node("feat/b", commit("commit subject", "commit body")), + &ghapi.PRDetails{Number: 2, State: "OPEN", Title: "Real PR title", Body: "Real PR body"}), + } + got := NewSubmitNodes(nodes, "## Template") + assert.Len(t, got, 1) + assert.Equal(t, StateOpen, got[0].State) + assert.Equal(t, "Real PR title", got[0].Title, "existing PR uses the API title") + assert.Equal(t, "Real PR body", got[0].Description, "existing PR uses the API body, not the template") +} + func TestCounts(t *testing.T) { nodes := []SubmitNode{ {State: StateNew, Included: true}, From e7d9173ddd854d9ac0916c56f0775407564a55e2 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 26 Jun 2026 10:09:13 -0400 Subject: [PATCH 5/7] Stop mouse wheel from leaking escape characters into form fields Scrolling the mouse wheel while a title or description field was focused could insert stray characters such as "[<65;54;51M" into the field. The submit TUI ran the Bubble Tea program with WithMouseAllMotion (mode 1003), which reports an event on every pointer move. During a wheel scroll that floods the input stream, and under that volume Bubble Tea splits an SGR mouse escape sequence ("\x1b[ 2 { + burst = append(burst, runeMsg(seq[2:cut], false)) + } + burst = append(burst, runeMsg(seq[cut:], false)) + return burst +} + +func TestConsumeLeakedMouseKey_SwallowsSplitSequences(t *testing.T) { + for _, seq := range []string{"\x1b[<65;54;51M", "\x1b[<64;10;20m", "\x1b[<0;1;1M"} { + for cut := 2; cut < len(seq); cut++ { + t.Run(fmt.Sprintf("%q@%d", seq, cut), func(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, fieldTitle, m.focusedField) + before := m.titleInput.Value() + + for _, msg := range splitSGRBurst(seq, cut) { + updated, _ := m.Update(msg) + m = updated.(Model) + } + + assert.Equal(t, before, m.titleInput.Value(), "no fragment should reach the title field") + assert.False(t, m.mouseLeakActive, "the sequence resets once its terminator is consumed") + + // A normal keystroke afterwards must still register. + m = sendKey(t, m, runeKey('x')) + assert.Equal(t, before+"x", m.titleInput.Value(), "typing works after a swallowed sequence") + }) + } + } +} + +func TestConsumeLeakedMouseKey_SwallowsSingleRunTail(t *testing.T) { + // When the parser consumes "\x1b" as a lone Escape, the rest of the body can + // arrive as one run. + m := testModel(t, newNodes()) + before := m.titleInput.Value() + m = sendKey(t, m, runeMsg("[<65;54;51M", false)) + assert.Equal(t, before, m.titleInput.Value(), "a whole leaked body in one run is dropped") +} + +func TestConsumeLeakedMouseKey_PreservesRealTyping(t *testing.T) { + m := testModel(t, newNodes()) + before := m.titleInput.Value() + + // Characters from the mouse alphabet typed normally must pass through. Each + // real keystroke is a single-rune message, never a complete SGR body. + for _, r := range []rune{'<', '3', ';', '5', 'M', 'm'} { + m = sendKey(t, m, runeKey(r)) + } + assert.Equal(t, before+"<3;5Mm", m.titleInput.Value(), "ordinary characters are never filtered") +} + +func TestConsumeLeakedMouseKey_StrayAltBracketDoesNotEatNextKey(t *testing.T) { + m := testModel(t, newNodes()) + before := m.titleInput.Value() + + // A lone Alt+"[" opens a sequence, but the following key is not a mouse body, + // so it must still be handled (here: typed into the field). + m = sendKey(t, m, runeMsg("[", true)) + require.True(t, m.mouseLeakActive) + m = sendKey(t, m, runeKey('z')) + assert.False(t, m.mouseLeakActive, "a non-body rune ends the sequence") + assert.Equal(t, before+"z", m.titleInput.Value(), "the following real key is not eaten") +} + +func TestConsumeLeakedMouseKey_BracketedPasteIsNotFiltered(t *testing.T) { + m := testModel(t, newNodes()) + before := m.titleInput.Value() + + // A genuine paste that happens to look like a mouse body is preserved. + paste := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("<65;54;51M"), Paste: true} + updated, _ := m.Update(paste) + m = updated.(Model) + assert.Equal(t, before+"<65;54;51M", m.titleInput.Value(), "pasted content is never filtered") +} + +func TestConsumeLeakedMouseKey_DescriptionField(t *testing.T) { + m := testModel(t, newNodes()) + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) // focus description + require.Equal(t, fieldDescription, m.focusedField) + before := m.descArea.Value() + + for _, msg := range splitSGRBurst("\x1b[<65;54;51M", 10) { + updated, _ := m.Update(msg) + m = updated.(Model) + } + assert.Equal(t, before, m.descArea.Value(), "no fragment should reach the description field") +} diff --git a/internal/tui/submitview/screen_test.go b/internal/tui/submitview/screen_test.go index 7018680..638fcce 100644 --- a/internal/tui/submitview/screen_test.go +++ b/internal/tui/submitview/screen_test.go @@ -605,6 +605,47 @@ func TestMouse_WheelScrollsDescriptionViewport(t *testing.T) { assert.False(t, m.descScrollPinned, "typing returns to the cursor-following view") } +// TestMouse_WheelDoesNotEnterFieldText guards against the regression where +// scrolling the mouse wheel leaked escape-sequence bytes as text into the +// focused title/description field. handleMouse must consume every wheel event +// (returning without forwarding it to the text inputs), so the field contents +// never change regardless of which panel the pointer is over. +func TestMouse_WheelDoesNotEnterFieldText(t *testing.T) { + m := testModel(t, newNodes()) + require.Equal(t, fieldTitle, m.focusedField) + + leftW, _ := m.panelWidths() + leftX := leftW / 2 // over the left timeline + rightX := leftW + 5 // over the right editor + titleLine, _, _, _, _ := m.rightZones() + y := m.panelTopRow() + titleLine + + wheel := func(m Model) Model { + t.Helper() + for _, x := range []int{leftX, rightX} { + for _, b := range []tea.MouseButton{tea.MouseButtonWheelUp, tea.MouseButtonWheelDown} { + u, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: b, X: x, Y: y}) + m = u.(Model) + } + } + return m + } + + // Title focused: wheeling over either panel must not alter the title. + titleBefore := m.nodes[m.cursor].Title + m = wheel(m) + assert.Equal(t, titleBefore, m.nodes[m.cursor].Title, "wheel must not modify the focused title") + assert.Equal(t, titleBefore, m.titleInput.Value(), "wheel must not modify the title input") + + // Description focused: same guarantee. + m = sendKey(t, m, tea.KeyMsg{Type: tea.KeyTab}) + require.Equal(t, fieldDescription, m.focusedField) + descBefore := m.nodes[m.cursor].Description + m = wheel(m) + assert.Equal(t, descBefore, m.nodes[m.cursor].Description, "wheel must not modify the focused description") + assert.Equal(t, descBefore, m.descArea.Value(), "wheel must not modify the description input") +} + func TestMouse_WheelScrollBoundedByContent(t *testing.T) { // The over-scroll bug only appears with a real color profile (the textarea // pads blank rows with styled spaces), so force one here. From 1cc2acea6f71629d1984ede77dfb4ce0a8441035 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 26 Jun 2026 12:56:42 -0400 Subject: [PATCH 6/7] Re-enable mouse tracking after the external editor closes Opening the description in $EDITOR with ^e and then quitting left the mouse unresponsive: clicks and wheel scrolling stopped working while keyboard navigation still did. The editor is launched with tea.ExecProcess, which releases the terminal before running the command and calls Bubble Tea's RestoreTerminal when it returns. RestoreTerminal re-enables the alt-screen, bracketed paste, and focus reporting, but it does not re-enable mouse tracking. The editor (e.g. vim) disables mouse reporting on exit, so once control returns to the TUI the terminal no longer emits mouse events. Re-arm mouse mode when the editor-finished message arrives by batching tea.EnableMouseCellMotion with the handler's command. That re-enables cell-motion and SGR mouse reporting, matching the WithMouseCellMotion option the program starts with, on every editor-return path (success or error). --- internal/tui/submitview/model.go | 8 +++- internal/tui/submitview/preview_test.go | 50 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/tui/submitview/model.go b/internal/tui/submitview/model.go index 75a2ce7..9ada401 100644 --- a/internal/tui/submitview/model.go +++ b/internal/tui/submitview/model.go @@ -214,7 +214,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleMouse(msg) case editorFinishedMsg: - return m.handleEditorFinished(msg) + updated, cmd := m.handleEditorFinished(msg) + // After tea.ExecProcess runs the external editor, Bubble Tea's + // RestoreTerminal re-enables the alt-screen, bracketed paste, and focus + // reporting but NOT mouse tracking, so the terminal stops emitting mouse + // events once the editor closes. Re-enable cell-motion mouse mode (which + // also re-arms SGR mode) to match the program's startup options. + return updated, tea.Batch(cmd, tea.EnableMouseCellMotion) } return m, nil diff --git a/internal/tui/submitview/preview_test.go b/internal/tui/submitview/preview_test.go index 3fd65d4..77c1bb5 100644 --- a/internal/tui/submitview/preview_test.go +++ b/internal/tui/submitview/preview_test.go @@ -2,6 +2,7 @@ package submitview import ( "os" + "reflect" "testing" tea "github.com/charmbracelet/bubbletea" @@ -66,6 +67,55 @@ func TestHandleEditorFinished_UpdatesDescription(t *testing.T) { assert.True(t, os.IsNotExist(statErr)) } +// cmdMessages flattens the message(s) a command (possibly a tea.Batch) emits. +func cmdMessages(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + switch msg := cmd().(type) { + case tea.BatchMsg: + var out []tea.Msg + for _, c := range msg { + out = append(out, cmdMessages(c)...) + } + return out + default: + return []tea.Msg{msg} + } +} + +func containsType(msgs []tea.Msg, want tea.Msg) bool { + wt := reflect.TypeOf(want) + for _, m := range msgs { + if reflect.TypeOf(m) == wt { + return true + } + } + return false +} + +func TestEditorFinished_ReenablesMouse(t *testing.T) { + // Bubble Tea's RestoreTerminal (run after the external editor exits) does not + // re-enable mouse tracking, so the editor-finished handler must re-arm it or + // the mouse stops working after the editor closes. + want := tea.EnableMouseCellMotion() + + t.Run("success", func(t *testing.T) { + m := testModel(t, newNodes()) + f, err := os.CreateTemp(t.TempDir(), "ed-*.md") + require.NoError(t, err) + require.NoError(t, f.Close()) + _, cmd := m.Update(editorFinishedMsg{path: f.Name()}) + assert.True(t, containsType(cmdMessages(cmd), want), "mouse tracking is re-enabled after a clean editor exit") + }) + + t.Run("editor error", func(t *testing.T) { + m := testModel(t, newNodes()) + _, cmd := m.Update(editorFinishedMsg{path: "/no/such/file", err: assert.AnError}) + assert.True(t, containsType(cmdMessages(cmd), want), "mouse tracking is re-enabled even when the editor errored") + }) +} + func TestRenderMarkdown(t *testing.T) { assert.Contains(t, renderMarkdown("", 40), "no description") // Glamour styles each word as a separate ANSI span, so assert on the words From 3d3081efcf901ba2cbef726d1f753d8c11f9059e Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Fri, 26 Jun 2026 15:20:42 -0400 Subject: [PATCH 7/7] dead code cleanup --- cmd/submit.go | 22 ++-------- cmd/submit_tui_test.go | 40 ------------------ internal/tui/submitview/data.go | 59 +-------------------------- internal/tui/submitview/data_test.go | 26 ------------ internal/tui/submitview/model.go | 18 -------- internal/tui/submitview/model_test.go | 14 ------- internal/tui/submitview/mouse.go | 8 ---- internal/tui/submitview/styles.go | 3 +- internal/tui/submitview/types.go | 24 +++++------ 9 files changed, 17 insertions(+), 197 deletions(-) diff --git a/cmd/submit.go b/cmd/submit.go index 1f13d82..59ed811 100644 --- a/cmd/submit.go +++ b/cmd/submit.go @@ -46,8 +46,9 @@ This command performs several steps: 3. Updates base branches for existing PRs 4. Creates or updates the stack on GitHub -In the editor, new PRs default to ready for review; toggle "Open as draft" per -PR. With --auto, new PRs are created as drafts unless you pass --open.`, +In the editor, new PRs default to ready for review; switch any to draft with the +"CREATE AS" toggle. With --auto, new PRs are created as drafts unless you pass +--open.`, Example: ` # Push and create/update PRs (opens the interactive editor) $ gh stack submit @@ -284,7 +285,6 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack model := submitview.New(submitview.Options{ Nodes: nodes, Trunk: s.Trunk, - StackName: stackDisplayName(s), RepoLabel: repoLabel, Version: Version, }) @@ -310,22 +310,6 @@ func collectPRDrafts(cfg *config.Config, client github.ClientOps, s *stack.Stack return submitview.BuildDrafts(m.Nodes()), false, nil } -// stackDisplayName returns a human-readable name for the stack, used in the TUI -// header: the stack prefix, else the common branch-name prefix, else the -// bottom-most branch. -func stackDisplayName(s *stack.Stack) string { - if s.Prefix != "" { - return strings.TrimRight(s.Prefix, "/") - } - if p := submitview.CommonPrefix(s.BranchNames()); p != "" { - return strings.TrimRight(p, "/") - } - if len(s.Branches) > 0 { - return s.Branches[0].Branch - } - return s.Trunk.Branch -} - // ensurePR finds or creates a PR for the branch at index i, and updates // its base branch if needed. This is the single place where PR state is // reconciled during submit. diff --git a/cmd/submit_tui_test.go b/cmd/submit_tui_test.go index e353e8e..9cbac9f 100644 --- a/cmd/submit_tui_test.go +++ b/cmd/submit_tui_test.go @@ -11,46 +11,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestStackDisplayName(t *testing.T) { - tests := []struct { - name string - s *stack.Stack - want string - }{ - { - name: "uses stack prefix", - s: &stack.Stack{Prefix: "feat/", Trunk: stack.BranchRef{Branch: "main"}}, - want: "feat", - }, - { - name: "falls back to common branch prefix", - s: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "feat/auth/a"}, {Branch: "feat/auth/b"}}, - }, - want: "feat/auth", - }, - { - name: "single branch falls back to its name", - s: &stack.Stack{ - Trunk: stack.BranchRef{Branch: "main"}, - Branches: []stack.BranchRef{{Branch: "solo"}}, - }, - want: "solo", - }, - { - name: "no branches falls back to trunk", - s: &stack.Stack{Trunk: stack.BranchRef{Branch: "main"}}, - want: "main", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, stackDisplayName(tt.s)) - }) - } -} - // TestCollectPRDrafts_SkipsWhenNoNewBranches verifies the TUI is skipped (no // program launched) when every branch already has a PR, returning nil drafts so // the normal push/relink path runs. diff --git a/internal/tui/submitview/data.go b/internal/tui/submitview/data.go index 6b2d46f..8db39f1 100644 --- a/internal/tui/submitview/data.go +++ b/internal/tui/submitview/data.go @@ -124,7 +124,7 @@ func CountSelected(nodes []SubmitNode) int { } // HasClosed reports whether any branch in the list has a closed PR, which blocks -// the stack and triggers the Step 1 callout. +// the stack and triggers the closed-branch callout. func HasClosed(nodes []SubmitNode) bool { for _, node := range nodes { if node.State == StateClosed { @@ -145,63 +145,6 @@ func ClosedBranches(nodes []SubmitNode) []string { return names } -// CommonPrefix returns the longest shared slash-delimited path prefix across the -// given branch names, including a trailing slash. It returns "" when fewer than -// two names are given or there is no shared prefix. This is used to render the -// stack map with short branch names. -func CommonPrefix(names []string) string { - if len(names) < 2 { - return "" - } - - // Split each name into slash-delimited segments and find the longest run of - // leading segments common to all names. - segs := make([][]string, len(names)) - minLen := -1 - for i, n := range names { - segs[i] = strings.Split(n, "/") - // The last segment is the leaf name; only path segments before it can - // be part of a shared prefix. - pathLen := len(segs[i]) - 1 - if minLen == -1 || pathLen < minLen { - minLen = pathLen - } - } - if minLen <= 0 { - return "" - } - - common := 0 - for i := 0; i < minLen; i++ { - seg := segs[0][i] - same := true - for j := 1; j < len(segs); j++ { - if segs[j][i] != seg { - same = false - break - } - } - if !same { - break - } - common++ - } - if common == 0 { - return "" - } - return strings.Join(segs[0][:common], "/") + "/" -} - -// Shortname strips prefix from branch when present, returning the remainder. If -// branch does not start with prefix (or prefix is empty), branch is returned -// unchanged. -func Shortname(branch, prefix string) string { - if prefix == "" { - return branch - } - return strings.TrimPrefix(branch, prefix) -} - // humanize replaces hyphens and underscores with spaces. It mirrors the helper // used by the submit command so auto-generated titles match across paths. func humanize(s string) string { diff --git a/internal/tui/submitview/data_test.go b/internal/tui/submitview/data_test.go index afb26dd..ef364fc 100644 --- a/internal/tui/submitview/data_test.go +++ b/internal/tui/submitview/data_test.go @@ -199,29 +199,3 @@ func TestClosedBranches(t *testing.T) { } assert.Equal(t, []string{"feat/legacy"}, ClosedBranches(nodes)) } - -func TestCommonPrefix(t *testing.T) { - tests := []struct { - name string - names []string - want string - }{ - {"shared two-segment prefix", []string{"feat/auth/a", "feat/auth/b", "feat/auth/c"}, "feat/auth/"}, - {"shared one-segment prefix", []string{"feat/a", "feat/b"}, "feat/"}, - {"no shared prefix", []string{"feat/a", "fix/b"}, ""}, - {"single name has no prefix", []string{"feat/a"}, ""}, - {"empty list", nil, ""}, - {"flat names share nothing", []string{"a", "b"}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, CommonPrefix(tt.names)) - }) - } -} - -func TestShortname(t *testing.T) { - assert.Equal(t, "middleware", Shortname("feat/auth/middleware", "feat/auth/")) - assert.Equal(t, "feat/auth/middleware", Shortname("feat/auth/middleware", "")) - assert.Equal(t, "other/x", Shortname("other/x", "feat/auth/")) -} diff --git a/internal/tui/submitview/model.go b/internal/tui/submitview/model.go index 9ada401..028fe56 100644 --- a/internal/tui/submitview/model.go +++ b/internal/tui/submitview/model.go @@ -42,8 +42,6 @@ type Options struct { Nodes []SubmitNode // Trunk is the stack's trunk branch, shown for context. Trunk stack.BranchRef - // StackName is the human-readable stack name shown in the header. - StackName string // RepoLabel is the "owner/repo" string shown in the header. RepoLabel string // Version is the CLI version string. @@ -54,14 +52,9 @@ type Options struct { type Model struct { nodes []SubmitNode trunk stack.BranchRef - stackName string repoLabel string version string - // prefix is the common slash-delimited branch-name prefix, used to render - // short branch names in the stack map. - prefix string - cursor int // index into nodes (the focused branch) width, height int @@ -85,9 +78,6 @@ type Model struct { statusMessage string statusIsError bool - // hoverRow is the node index currently under the mouse pointer, or -1. - hoverRow int - // leftScroll is the first visible row offset of the left stack timeline when // its content is taller than the panel. leftScroll int @@ -115,11 +105,6 @@ type Model struct { // opens immediately with the first branch focused (preferring the first NEW // branch) and the title field ready for editing. func New(opts Options) Model { - branchNames := make([]string, len(opts.Nodes)) - for i, n := range opts.Nodes { - branchNames[i] = n.Ref.Branch - } - // Start on the bottom-most NEW branch (closest to trunk) — the first PR // created, in stack order. Nodes are ordered top (index 0) to bottom, so the // bottom-most NEW branch is the highest-indexed one. @@ -144,12 +129,9 @@ func New(opts Options) Model { m := Model{ nodes: opts.Nodes, trunk: opts.Trunk, - stackName: opts.StackName, repoLabel: opts.RepoLabel, version: opts.Version, - prefix: CommonPrefix(branchNames), cursor: cursor, - hoverRow: -1, titleInput: ti, descArea: ta, diff --git a/internal/tui/submitview/model_test.go b/internal/tui/submitview/model_test.go index 68b25ff..90d932e 100644 --- a/internal/tui/submitview/model_test.go +++ b/internal/tui/submitview/model_test.go @@ -52,7 +52,6 @@ func testModel(t *testing.T, nodes []SubmitNode) Model { m := New(Options{ Nodes: nodes, Trunk: stack.BranchRef{Branch: "main"}, - StackName: "feat/auth", RepoLabel: "myorg/myrepo", Version: "1.0.0", }) @@ -88,11 +87,6 @@ func TestNew_AllNewIncludedByDefault(t *testing.T) { assert.True(t, m.nodes[1].Included) } -func TestNew_ComputesPrefix(t *testing.T) { - m := testModel(t, newNodes()) - assert.Equal(t, "feat/auth/", m.prefix) -} - // --- navigation --- func TestNavigation_MovesAcrossAllBranches(t *testing.T) { @@ -359,14 +353,6 @@ func TestMouse_ClickCheckboxToggles(t *testing.T) { assert.False(t, m.nodes[0].Included) } -func TestMouse_HoverTracksRow(t *testing.T) { - m := testModel(t, newNodes()) - y, _ := leftBranchNode(m, 2) - updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionMotion, X: 6, Y: y}) - m = updated.(Model) - assert.Equal(t, 2, m.hoverRow) -} - // leftBranchNode returns the screen Y of branch idx's node line and the X of its // right-edge checkbox, using the live left-panel timeline layout (scroll-aware). func leftBranchNode(m Model, idx int) (y, checkboxX int) { diff --git a/internal/tui/submitview/mouse.go b/internal/tui/submitview/mouse.go index 62aeac2..5346399 100644 --- a/internal/tui/submitview/mouse.go +++ b/internal/tui/submitview/mouse.go @@ -14,9 +14,6 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } switch msg.Action { - case tea.MouseActionMotion: - m.updateHover(msg.X, msg.Y) - return m, nil case tea.MouseActionPress: leftW, _ := m.panelWidths() overLeft := msg.X >= 0 && msg.X < leftW @@ -299,8 +296,3 @@ func (m *Model) positionDescCursor(visRow, col int) { } m.descArea.SetCursor(m.descArea.LineInfo().StartColumn + col) } - -// updateHover tracks the branch row under the pointer for hover styling. -func (m *Model) updateHover(x, y int) { - m.hoverRow = m.branchRowAt(x, y) -} diff --git a/internal/tui/submitview/styles.go b/internal/tui/submitview/styles.go index 9aa1dcd..a691c38 100644 --- a/internal/tui/submitview/styles.go +++ b/internal/tui/submitview/styles.go @@ -46,8 +46,7 @@ func (s BranchState) Label() string { // Color returns the foreground color associated with a state. func (s BranchState) Color() lipgloss.Color { return stateColors[s] } -// Dot returns the compact legend glyph for a state, used in the Step 2 stack -// map and legend. +// Dot returns the compact status glyph for a state. func (s BranchState) Dot() string { switch s { case StateNew: diff --git a/internal/tui/submitview/types.go b/internal/tui/submitview/types.go index 07a79d5..1576fcb 100644 --- a/internal/tui/submitview/types.go +++ b/internal/tui/submitview/types.go @@ -1,8 +1,8 @@ -// Package submitview implements the interactive two-step TUI used by -// `gh stack submit` to create a stack of pull requests. Step 1 selects which -// branches without a PR should become PRs; Step 2 is a two-panel editor for -// drafting each PR's title, description, and draft state before a single -// batch submit. +// Package submitview implements the interactive single-screen TUI used by +// `gh stack submit` to create a stack of pull requests. A left-hand timeline +// lists the stack and lets the user choose which branches without a PR should +// become PRs; the right-hand editor drafts each PR's title, description, and +// ready/draft state before a single batch submit. // // The package builds on the shared Charm components in internal/tui/shared and // reuses the branch display data loaded by internal/tui/stackview. The submit @@ -15,13 +15,13 @@ import ( ) // BranchState classifies a branch by the status of its pull request. The state -// determines whether a branch is selectable in Step 1, editable in Step 2, and -// which badge color it renders with. +// determines whether a branch is selectable and editable in the TUI, and which +// badge color it renders with. type BranchState int const ( // StateNew is a branch with no PR yet. It is the only interactive state: - // selectable in Step 1 (default on) and editable in Step 2. + // selectable (default on) and editable. StateNew BranchState = iota // StateOpen is a branch with an open (non-draft) PR. Locked, shown for context. StateOpen @@ -36,12 +36,12 @@ const ( StateClosed ) -// Selectable reports whether a branch in this state can be toggled in Step 1. -// Only NEW branches are selectable. +// Selectable reports whether a branch in this state can be toggled for +// inclusion. Only NEW branches are selectable. func (s BranchState) Selectable() bool { return s == StateNew } -// Editable reports whether a branch in this state opens the editor in Step 2. -// Only NEW branches are editable. +// Editable reports whether a branch in this state opens the editor. Only NEW +// branches are editable. func (s BranchState) Editable() bool { return s == StateNew } // Locked reports whether a branch is shown for context only (open, draft,