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 m.prEditOriginal = m.currentPRMetadata() m.restorePREditDraft() for index := range m.prEditEditors { m.prEditEditors[index].hardwareCursor = m.cursorOutput != nil m.prEditEditors[index].keys = m.keybindings } 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 := m.keybindings.canonicalPREditKey( key.String(), m.prEditField, m.writeMode == writePREditConfirm, ) if m.prEditField != prEditBodyField && key.Type != tea.KeyRunes && key.Type != tea.KeySpace { switch { case keyMatches(key.String(), m.keybindings.Navigation.Up): k = "up" case keyMatches(key.String(), m.keybindings.Navigation.Down): k = "down" } } 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, m.queuePREditDraft() } 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) 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(fmt.Sprintf( "%s submit • %s continue editing", primaryKeyLabel(m.keybindings.General.Confirm), primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), ))) return lines }