Make dashboard updateable
This commit is contained in:
472
pr_editor.go
Normal file
472
pr_editor.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
prEditTitleField = iota
|
||||
prEditBaseField
|
||||
prEditBodyField
|
||||
prEditFieldCount
|
||||
)
|
||||
|
||||
func (m *App) startPREdit() tea.Cmd {
|
||||
if reason := m.pullRequestUpdateUnavailable(); reason != "" {
|
||||
m.err = errors.New(reason)
|
||||
return nil
|
||||
}
|
||||
m.writeMode = writePREdit
|
||||
m.prEditField = prEditBodyField
|
||||
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, false)
|
||||
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, false)
|
||||
m.prEditEditors[prEditBodyField] = newTextEditor(
|
||||
normalizeLineEndings(m.details.Body),
|
||||
m.editorMode == "vim",
|
||||
)
|
||||
m.prEditEditors[prEditBodyField].highlightMarkdown = true
|
||||
for index := range m.prEditEditors {
|
||||
m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil
|
||||
}
|
||||
m.prEditOriginal = m.currentPRMetadata()
|
||||
m.prEditBranches = nil
|
||||
m.prEditBranchesLoading = false
|
||||
m.prEditBranchesError = ""
|
||||
m.prEditBranchIndex = 0
|
||||
m.scroll = 0
|
||||
m.err = m.prEditEditors[m.prEditField].err
|
||||
m.prEditEditors[m.prEditField].err = nil
|
||||
m.ensurePREditCursorVisible()
|
||||
return m.loadPREditBranches()
|
||||
}
|
||||
|
||||
func (m *App) loadPREditBranches() tea.Cmd {
|
||||
service, ok := m.service.(GitHubBranchService)
|
||||
if !ok {
|
||||
m.prEditBranchesError = "configured GitHub service cannot list branches"
|
||||
return nil
|
||||
}
|
||||
m.prEditBranchesLoading = true
|
||||
owner, repo := m.details.Owner, m.details.Repository
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
branches, err := service.ListBranches(ctx, owner, repo)
|
||||
return branchesLoadedMsg{owner: owner, repo: repo, branches: branches, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) pullRequestUpdateUnavailable() string {
|
||||
if m.loading {
|
||||
return "pull request update unavailable while PR data is refreshing"
|
||||
}
|
||||
if m.details.FromCache {
|
||||
return "pull request update unavailable from an offline cached snapshot"
|
||||
}
|
||||
if _, ok := m.service.(GitHubPullRequestWriteService); !ok {
|
||||
return "configured GitHub service does not support pull request updates"
|
||||
}
|
||||
if m.details.ID == "" {
|
||||
return "pull request details are not loaded"
|
||||
}
|
||||
if !m.details.Permissions.CanUpdatePR {
|
||||
return "GitHub did not grant update permission for this pull request"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := key.String()
|
||||
editorWidth := m.prEditEditorWidth()
|
||||
if m.writeMode == writePREditConfirm {
|
||||
switch k {
|
||||
case "y":
|
||||
if reason := m.pullRequestUpdateUnavailable(); reason != "" {
|
||||
m.writeMode = writePREdit
|
||||
m.err = errors.New(reason)
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
}
|
||||
if m.prEditIsStale() {
|
||||
m.writeMode = writePREdit
|
||||
m.err = errors.New("pull request metadata changed while editing; cancel and reopen the editor")
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
}
|
||||
m.writeMode = writePREditBusy
|
||||
return m, m.submitPREdit()
|
||||
case "n", "esc":
|
||||
m.writeMode = writePREdit
|
||||
m.ensurePREditCursorVisible()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch k {
|
||||
case "ctrl+s":
|
||||
if err := m.validatePREdit(); err != nil {
|
||||
m.err = err
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
} else if m.prEditIsStale() {
|
||||
m.err = errors.New("pull request metadata changed while editing; cancel and reopen the editor")
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
} else {
|
||||
m.writeMode = writePREditConfirm
|
||||
m.err = nil
|
||||
return m, nil
|
||||
}
|
||||
case "tab":
|
||||
if m.prEditField != prEditBaseField || !m.completeBranchSuggestion() {
|
||||
m.movePREditField(1)
|
||||
}
|
||||
case "shift+tab":
|
||||
m.movePREditField(-1)
|
||||
case "ctrl+n":
|
||||
if m.prEditField == prEditBaseField {
|
||||
m.moveBranchSuggestion(1)
|
||||
}
|
||||
case "ctrl+p":
|
||||
if m.prEditField == prEditBaseField {
|
||||
m.moveBranchSuggestion(-1)
|
||||
}
|
||||
case "ctrl+d", "ctrl+u":
|
||||
if m.prEditField == prEditBodyField {
|
||||
direction := 1
|
||||
if k == "ctrl+u" {
|
||||
direction = -1
|
||||
}
|
||||
delta := direction * max(1, m.dashboardViewportHeight()/2)
|
||||
m.prEditEditors[m.prEditField].movePage(delta, editorWidth)
|
||||
m.scroll = clamp(m.scroll+delta, 0, m.dashboardMaxScroll())
|
||||
}
|
||||
case "enter":
|
||||
if m.prEditField == prEditBaseField && m.completeBranchSuggestion() {
|
||||
break
|
||||
}
|
||||
if m.prEditField != prEditBodyField {
|
||||
m.movePREditField(1)
|
||||
} else {
|
||||
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
|
||||
}
|
||||
case "up":
|
||||
if m.prEditField != prEditBodyField {
|
||||
m.movePREditField(-1)
|
||||
} else {
|
||||
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
|
||||
}
|
||||
case "down":
|
||||
if m.prEditField != prEditBodyField {
|
||||
m.movePREditField(1)
|
||||
} else {
|
||||
m.prEditEditors[m.prEditField].handleKeyAtWidth(key, true, editorWidth)
|
||||
}
|
||||
case "esc":
|
||||
editor := &m.prEditEditors[m.prEditField]
|
||||
if editor.Modal && editor.Mode != textEditorNormal {
|
||||
editor.handleKeyAtWidth(key, m.prEditField == prEditBodyField, editorWidth)
|
||||
} else {
|
||||
m.writeMode = writeNone
|
||||
m.clearPREdit()
|
||||
m.err = nil
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
}
|
||||
default:
|
||||
editor := &m.prEditEditors[m.prEditField]
|
||||
before := editor.Text
|
||||
editor.handleKeyAtWidth(key, m.prEditField == prEditBodyField, editorWidth)
|
||||
if m.prEditField != prEditBodyField {
|
||||
editor.Text = normalizeSingleLine(editor.Text)
|
||||
editor.Cursor = clamp(editor.Cursor, 0, len([]rune(editor.Text)))
|
||||
}
|
||||
if m.prEditField == prEditBaseField && editor.Text != before {
|
||||
m.prEditBranchIndex = 0
|
||||
}
|
||||
}
|
||||
m.err = nil
|
||||
m.ensurePREditCursorVisible()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m App) prEditEditorWidth() int {
|
||||
return max(1, max(10, m.width-2)-4)
|
||||
}
|
||||
|
||||
func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
|
||||
if m.cursorOutput == nil {
|
||||
return
|
||||
}
|
||||
editor := m.prEditEditors[m.prEditField]
|
||||
if editor.Mode != textEditorInsert {
|
||||
return
|
||||
}
|
||||
_, cursorLine := m.dashboardEditLayout()
|
||||
screenRow := cursorLine - scroll
|
||||
if screenRow < 0 || screenRow >= viewportHeight {
|
||||
return
|
||||
}
|
||||
_, column := editorCursorVisualPosition(editor, m.prEditEditorWidth())
|
||||
// Rows and columns are one-based. Each editor row has a two-cell "│ "
|
||||
// context rail before its text.
|
||||
m.cursorOutput.SetCursor(true, column+3, screenRow+1)
|
||||
}
|
||||
|
||||
func (m App) submitPREdit() tea.Cmd {
|
||||
writer := m.service.(GitHubPullRequestWriteService)
|
||||
id := m.details.ID
|
||||
update := m.prEditMetadata()
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
metadata, err := writer.UpdatePullRequest(ctx, id, update)
|
||||
return pullRequestUpdatedMsg{metadata: metadata, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) validatePREdit() error {
|
||||
update := m.prEditMetadata()
|
||||
if update.Title == "" {
|
||||
return errors.New("pull request title cannot be empty")
|
||||
}
|
||||
if update.BaseRef == "" {
|
||||
return errors.New("target branch cannot be empty")
|
||||
}
|
||||
if len(m.prEditBranches) > 0 {
|
||||
found := false
|
||||
for _, branch := range m.prEditBranches {
|
||||
if branch.Name == update.BaseRef {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("target branch %q is not an available repository branch", update.BaseRef)
|
||||
}
|
||||
}
|
||||
if samePRMetadata(update, m.prEditOriginal) {
|
||||
return errors.New("title, target branch, and description are unchanged")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m App) prEditIsStale() bool {
|
||||
return !samePRMetadata(m.currentPRMetadata(), m.prEditOriginal)
|
||||
}
|
||||
|
||||
func (m App) currentPRMetadata() PullRequestMetadata {
|
||||
return PullRequestMetadata{
|
||||
Title: m.details.Title, Body: m.details.Body, BaseRef: m.details.BaseRef,
|
||||
Mergeable: m.details.Mergeable, MergeState: m.details.MergeState,
|
||||
UpdatedAt: m.details.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) prEditMetadata() PullRequestMetadata {
|
||||
body := m.prEditEditors[prEditBodyField].Text
|
||||
if body == normalizeLineEndings(m.prEditOriginal.Body) {
|
||||
// Opening the editor must not turn mixed or CRLF line endings into an
|
||||
// apparent edit. Preserve the remote body exactly until its content is
|
||||
// actually changed.
|
||||
body = m.prEditOriginal.Body
|
||||
}
|
||||
return PullRequestMetadata{
|
||||
Title: strings.TrimSpace(m.prEditEditors[prEditTitleField].Text),
|
||||
Body: body,
|
||||
BaseRef: strings.TrimSpace(m.prEditEditors[prEditBaseField].Text),
|
||||
}
|
||||
}
|
||||
|
||||
func samePRMetadata(left, right PullRequestMetadata) bool {
|
||||
return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef
|
||||
}
|
||||
|
||||
func (m *App) clearPREdit() {
|
||||
m.prEditField = 0
|
||||
m.prEditEditors = [3]textEditor{}
|
||||
m.prEditOriginal = PullRequestMetadata{}
|
||||
m.prEditBranches = nil
|
||||
m.prEditBranchesLoading = false
|
||||
m.prEditBranchesError = ""
|
||||
m.prEditBranchIndex = 0
|
||||
}
|
||||
|
||||
func (m *App) movePREditField(delta int) {
|
||||
m.prEditField = (m.prEditField + delta + prEditFieldCount) % prEditFieldCount
|
||||
}
|
||||
|
||||
func textLineStart(value string, cursor int) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
for cursor > 0 && runes[cursor-1] != '\n' {
|
||||
cursor--
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func textLineEnd(value string, cursor int) int {
|
||||
runes := []rune(value)
|
||||
cursor = clamp(cursor, 0, len(runes))
|
||||
for cursor < len(runes) && runes[cursor] != '\n' {
|
||||
cursor++
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
func moveTextCursorLine(value string, cursor, delta int) int {
|
||||
start := textLineStart(value, cursor)
|
||||
column := cursor - start
|
||||
if delta < 0 {
|
||||
if start == 0 {
|
||||
return cursor
|
||||
}
|
||||
previousEnd := start - 1
|
||||
previousStart := textLineStart(value, previousEnd)
|
||||
return min(previousStart+column, previousEnd)
|
||||
}
|
||||
end := textLineEnd(value, cursor)
|
||||
if end == len([]rune(value)) {
|
||||
return cursor
|
||||
}
|
||||
nextStart := end + 1
|
||||
nextEnd := textLineEnd(value, nextStart)
|
||||
return min(nextStart+column, nextEnd)
|
||||
}
|
||||
|
||||
func (m App) dashboardEditLines() []string {
|
||||
lines, _ := m.dashboardEditLayout()
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m App) dashboardEditLayout() ([]string, int) {
|
||||
width := max(10, m.width-2)
|
||||
cursorLine := 0
|
||||
lines := []string{
|
||||
titleStyle.Render(fmt.Sprintf("%s #%d", m.details.RepoWithOwner, m.details.Number)) +
|
||||
" " + warnStyle.Render("EDITING"),
|
||||
"",
|
||||
titleStyle.Render("Edit pull request"),
|
||||
dimStyle.Render("Raw Markdown is preserved in the description."),
|
||||
}
|
||||
if m.err != nil {
|
||||
errorWidth := max(1, width-2)
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(m.err.Error(), errorWidth, ""), errorWidth, false)
|
||||
lines = append(lines, "")
|
||||
for _, line := range strings.Split(wrapped, "\n") {
|
||||
lines = append(lines, badStyle.Render(line))
|
||||
}
|
||||
}
|
||||
appendField := func(label string, field int) {
|
||||
lines = append(lines, "")
|
||||
start := len(lines)
|
||||
lines = append(lines, m.prEditFieldLines(label, field, width)...)
|
||||
if m.prEditField == field {
|
||||
cursorLine = start + 1 + editorCursorVisualLine(m.prEditEditors[field], max(1, width-4))
|
||||
}
|
||||
}
|
||||
appendField("title", prEditTitleField)
|
||||
appendField("target branch", prEditBaseField)
|
||||
appendField("description", prEditBodyField)
|
||||
help := "tab/shift-tab field • ctrl-s review • esc normal/cancel"
|
||||
if m.prEditEditors[prEditBodyField].Modal {
|
||||
help += " • i/a/s insert • v/V select • y copy • p paste • hjkl/wWbBeE move • ctrl-d/u page • fFtT + ; find"
|
||||
} else {
|
||||
help += " • arrows/home/end move • ctrl-d/u page • enter newline"
|
||||
}
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(help, width, ""), width, false)
|
||||
lines = append(lines, "")
|
||||
for _, line := range strings.Split(wrapped, "\n") {
|
||||
lines = append(lines, dimStyle.Render(line))
|
||||
}
|
||||
return lines, cursorLine
|
||||
}
|
||||
|
||||
func (m App) prEditFieldLines(label string, field, width int) []string {
|
||||
active := m.prEditField == field
|
||||
editor := m.prEditEditors[field]
|
||||
prefix := " "
|
||||
if active {
|
||||
prefix = "▶ "
|
||||
}
|
||||
mode := editor.modeLabel()
|
||||
if mode != "" {
|
||||
label += " [" + mode + "]"
|
||||
}
|
||||
labelLine := dimStyle.Render(prefix + label)
|
||||
if active {
|
||||
labelLine = titleStyle.Render(prefix + label)
|
||||
}
|
||||
textWidth := max(1, width-4)
|
||||
rendered := renderTextEditor(editor, textWidth, active)
|
||||
lines := []string{labelLine}
|
||||
for _, line := range rendered {
|
||||
if line.active {
|
||||
// Style the rail and text as one row. Nesting the cursor or rail
|
||||
// style inside a background style emits resets that can erase the
|
||||
// remainder of wrapped terminal rows.
|
||||
lines = append(lines, editorLineStyle.Render("│ "+line.text))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, dimStyle.Render("│ ")+line.text)
|
||||
}
|
||||
if active && field == prEditBaseField {
|
||||
lines = append(lines, m.branchCompletionLines(max(1, width-2))...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m *App) ensurePREditCursorVisible() {
|
||||
if m.writeMode != writePREdit {
|
||||
return
|
||||
}
|
||||
lines, cursorLine := m.dashboardEditLayout()
|
||||
height := m.dashboardViewportHeight()
|
||||
if m.prEditField == prEditTitleField && cursorLine < height {
|
||||
// The title is the first editable field. Returning to it should also
|
||||
// restore the dashboard/editor heading instead of pinning the title's
|
||||
// text row to the top and clipping its label.
|
||||
m.scroll = 0
|
||||
} else if contextTop := max(0, cursorLine-2); contextTop < m.scroll {
|
||||
// Keep the active field label and its rail visible above the cursor.
|
||||
m.scroll = contextTop
|
||||
} else if cursorLine >= m.scroll+height {
|
||||
m.scroll = cursorLine - height + 1
|
||||
}
|
||||
m.scroll = clamp(m.scroll, 0, max(0, len(lines)-height))
|
||||
}
|
||||
|
||||
func (m App) prEditConfirmationLines(width int) []string {
|
||||
update := m.prEditMetadata()
|
||||
lines := []string{titleStyle.Render("Update this pull request?"), ""}
|
||||
if update.Title != m.prEditOriginal.Title {
|
||||
lines = append(lines,
|
||||
dimStyle.Render("title"),
|
||||
ansi.Truncate(m.prEditOriginal.Title, width, "…"),
|
||||
"→ "+ansi.Truncate(update.Title, max(1, width-2), "…"),
|
||||
"",
|
||||
)
|
||||
}
|
||||
if update.BaseRef != m.prEditOriginal.BaseRef {
|
||||
lines = append(lines,
|
||||
dimStyle.Render("target branch"),
|
||||
m.prEditOriginal.BaseRef+" → "+update.BaseRef,
|
||||
"",
|
||||
)
|
||||
}
|
||||
if update.Body != m.prEditOriginal.Body {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"description changed • %d → %d characters",
|
||||
len([]rune(m.prEditOriginal.Body)), len([]rune(update.Body)),
|
||||
), "")
|
||||
}
|
||||
lines = append(lines, warnStyle.Render("y submit • n/esc continue editing"))
|
||||
return lines
|
||||
}
|
||||
Reference in New Issue
Block a user