mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
95f04b8fed
* pull in remote updates during sync * cancel aborts operation * switch to nearest surviving branch * simplified options to remote delete * stack existing PRs from submit TUI * docs updates * address review comments
358 lines
11 KiB
Go
358 lines
11 KiB
Go
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.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
|
|
}
|
|
|
|
// leftStackButtonHit reports whether (x,y) lands on the bottom-left "STACK N PRs"
|
|
// button, when it is shown. The button occupies the last reserved row of the
|
|
// left panel (one blank separator row sits above it).
|
|
func (m Model) leftStackButtonHit(x, y int) bool {
|
|
if !m.canStackExistingPRs() {
|
|
return false
|
|
}
|
|
leftW, _ := m.panelWidths()
|
|
if x < 0 || x >= leftW {
|
|
return false
|
|
}
|
|
if y-m.panelTopRow() != m.leftVisibleHeight()+1 {
|
|
return false
|
|
}
|
|
// The button is rendered inside the panel's one-cell left border, so the
|
|
// hit target starts at screen column 1, not 0.
|
|
return x >= 1 && x < 1+lipgloss.Width(m.renderStackButton(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()
|
|
|
|
// Bottom-left STACK button: link the existing open PRs into a stack.
|
|
if m.leftStackButtonHit(x, y) {
|
|
m.saveEditor()
|
|
return m.requestSubmit()
|
|
}
|
|
|
|
// 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()
|
|
titleH := m.titleAreaHeight()
|
|
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+titleH:
|
|
cmd := m.focusField(fieldTitle)
|
|
// Clicking inside the title box content rows positions the cursor at the
|
|
// clicked row and column (the border rows just focus the field).
|
|
if rel >= titleLine && rel < titleLine+titleH {
|
|
m.positionTitleCursor(rel-titleLine, x-(leftW+5))
|
|
}
|
|
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. The title box grows
|
|
// with its wrapped content, so the description and footer offsets shift down by
|
|
// the title's extra rows.
|
|
func (m Model) rightZones() (titleLine, descLabel, descTop, descBot, draftLine int) {
|
|
titleH := m.titleAreaHeight()
|
|
descH := m.descAreaHeight()
|
|
titleLine = 4 // first title box content row (header 0, rule 1, label 2, box top 3)
|
|
descLabel = 5 + titleH
|
|
descTop = 6 + titleH // description box top border
|
|
descBot = 7 + titleH + descH
|
|
draftLine = 8 + titleH + 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)
|
|
}
|
|
|
|
// positionTitleCursor moves the title's cursor to the clicked visual row and
|
|
// column, where col is the offset from the title box's text start. Like
|
|
// positionDescCursor it resets to the top of the (single logical, soft-wrapped)
|
|
// line and walks down by visual row, so it is exact when the title is not
|
|
// scrolled (the common case) and approximate otherwise.
|
|
func (m *Model) positionTitleCursor(visRow, col int) {
|
|
if visRow < 0 {
|
|
visRow = 0
|
|
}
|
|
if col < 0 {
|
|
col = 0
|
|
}
|
|
for guard := 0; guard < 1000; guard++ {
|
|
row, off := m.titleArea.Line(), m.titleArea.LineInfo().RowOffset
|
|
m.titleArea.CursorUp()
|
|
if m.titleArea.Line() == row && m.titleArea.LineInfo().RowOffset == off {
|
|
break // reached the top-left of the buffer
|
|
}
|
|
}
|
|
for i := 0; i < visRow; i++ {
|
|
m.titleArea.CursorDown()
|
|
}
|
|
m.titleArea.SetCursor(m.titleArea.LineInfo().StartColumn + col)
|
|
}
|