mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
f880f0d469
* Support addressing a stack by its stack number checkout now interprets a bare integer as a stack number first (the identifier shown in the github.com stack UI), falling back to a locally tracked PR number, then a PR number discovered from GitHub, then a branch name. A new checkoutStackByNumber resolves the stack via GetStack and checks out its top-most unmerged branch; the reconcile/import logic is shared with the PR-number path. unstack gains an optional <stack-number> positional argument to unstack a specific locally tracked stack instead of the current one. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 * Surface the stack number in output and TUIs Show the human-facing stack number wherever it is known: - Append a "(stack #N)" label to submit, link, checkout, and unstack success messages. - Add a "Stack #N" header line to the view command (short and static) and the stackview TUI header. - Add a "Stack #N" info line to the submit TUI header when submitting an already-created stack. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 * Update docs and agent instructions for the new API - cli.md: document checkout/unstack by stack number and drop the "PATs are not supported" note (any gh-authenticated user can now run stack operations). - quick-start.md: drop the PAT-not-supported note. - AGENTS.md / copilot-instructions.md: ClientOps is now 13 methods over the public Stacks REST API; remove the TokenForHostFn test hook; note the stack file's id/number identity. - SKILL.md: add checkout/unstack-by-stack-number quick references. Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740 * address review comments
307 lines
8.8 KiB
Go
307 lines
8.8 KiB
Go
package submitview
|
|
|
|
import (
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
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
|
|
// RepoLabel is the "owner/repo" string shown in the header.
|
|
RepoLabel string
|
|
// Version is the CLI version string.
|
|
Version string
|
|
// CanCreateStack reports that the local stack has no remote stack object yet
|
|
// but one could be created (stacked PRs are available on the repo). When
|
|
// true, and once the user has deselected all new PRs, the TUI offers a
|
|
// "STACK N PRs" action to link the existing open PRs into a stack.
|
|
CanCreateStack bool
|
|
// StackNumber is the human-facing stack number shown in the header. Zero
|
|
// when the local stack has not yet been created on GitHub.
|
|
StackNumber int
|
|
}
|
|
|
|
// Model is the Bubble Tea model backing the interactive `gh stack submit` TUI.
|
|
type Model struct {
|
|
nodes []SubmitNode
|
|
trunk stack.BranchRef
|
|
repoLabel string
|
|
version string
|
|
|
|
// canCreateStack mirrors Options.CanCreateStack: the local stack has no
|
|
// remote stack object yet, but one could be created.
|
|
canCreateStack bool
|
|
|
|
// stackNumber is the human-facing stack number shown in the header (0 when
|
|
// the stack has not been created on GitHub yet).
|
|
stackNumber int
|
|
|
|
cursor int // index into nodes (the focused branch)
|
|
|
|
width, height int
|
|
|
|
// Editor state for the focused branch.
|
|
titleArea textarea.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
|
|
|
|
// 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)
|
|
|
|
// mouseLeak tracks an in-progress terminal mouse escape sequence that the
|
|
// Bubble Tea input parser split across reads and surfaced as key runes. See
|
|
// consumeLeakedMouseKey for details.
|
|
mouseLeakActive bool
|
|
mouseLeakBuf 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 {
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
// The title is a single logical line shown soft-wrapped: its box grows in
|
|
// height as the text wraps (capped, with the description shrinking to fit).
|
|
// It is a textarea (not a single-line input) so the cursor can move between
|
|
// wrapped rows. Newlines are blocked in updateScreen so the value stays a
|
|
// single line.
|
|
tia := textarea.New()
|
|
tia.Prompt = ""
|
|
tia.ShowLineNumbers = false
|
|
tia.CharLimit = 256
|
|
tia.FocusedStyle.CursorLine = lipgloss.NewStyle()
|
|
|
|
m := Model{
|
|
nodes: opts.Nodes,
|
|
trunk: opts.Trunk,
|
|
repoLabel: opts.RepoLabel,
|
|
version: opts.Version,
|
|
cursor: cursor,
|
|
|
|
canCreateStack: opts.CanCreateStack,
|
|
stackNumber: opts.StackNumber,
|
|
|
|
titleArea: tia,
|
|
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 textarea.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:
|
|
// Drop fragments of a terminal mouse escape sequence that the input
|
|
// parser split across reads and leaked as key runes; otherwise they get
|
|
// typed into the focused title/description field while scrolling.
|
|
if m.consumeLeakedMouseKey(msg) {
|
|
return m, nil
|
|
}
|
|
|
|
// 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:
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// canStackExistingPRs reports whether the "STACK N PRs" action should be offered:
|
|
// the local stack has no remote stack object yet, the user has deselected every
|
|
// new PR, and there are at least two existing open PRs to link into a stack.
|
|
func (m Model) canStackExistingPRs() bool {
|
|
return m.canCreateStack &&
|
|
CountSelected(m.nodes) == 0 &&
|
|
CountOpenPRs(m.nodes) >= 2
|
|
}
|
|
|
|
// 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{}
|