package main import ( "context" "errors" "fmt" "slices" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" ) const ( prEditTitleField = iota prEditBaseField prEditReviewersField prEditAssigneesField 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[prEditReviewersField] = newTextEditor( strings.Join(m.details.RequestedReviewers, ", "), false, ) m.prEditEditors[prEditAssigneesField] = newTextEditor( strings.Join(m.details.Assignees, ", "), 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.prEditUsers = nil m.prEditUsersLoading = false m.prEditUsersError = "" m.prEditUserIndex = 0 m.scroll = 0 m.err = m.prEditEditors[m.prEditField].err m.prEditEditors[m.prEditField].err = nil m.ensurePREditCursorVisible() return tea.Batch(m.loadPREditBranches(), m.loadPREditUsers()) } 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) loadPREditUsers() tea.Cmd { service, ok := m.service.(GitHubRepositoryPeopleService) if !ok { m.prEditUsersError = "configured GitHub service cannot list repository users" return nil } m.prEditUsersLoading = true owner, repo := m.details.Owner, m.details.Repository return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() users, err := service.ListRepositoryUsers(ctx, owner, repo) return repositoryUsersLoadedMsg{owner: owner, repo: repo, users: users, 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 _, ok := m.service.(GitHubPullRequestPeopleWriteService); !ok { return "configured GitHub service does not support reviewer and assignee 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() case "down": m.helpScroll = min(m.helpScroll+1, m.prEditConfirmationMaxScroll()) case "up": m.helpScroll = max(0, m.helpScroll-1) case "ctrl+d": m.helpScroll = min( m.helpScroll+max(1, m.height/2), m.prEditConfirmationMaxScroll(), ) case "ctrl+u": m.helpScroll = max(0, m.helpScroll-max(1, m.height/2)) case "g": m.helpScroll = 0 case "G": m.helpScroll = m.prEditConfirmationMaxScroll() } 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.helpScroll = 0 m.err = nil return m, nil } case "tab": completed := m.prEditField == prEditBaseField && m.completeBranchSuggestion() if isPREditPeopleField(m.prEditField) { completed = m.completeUserSuggestion() } if !completed { m.movePREditField(1) } case "shift+tab": m.movePREditField(-1) case "ctrl+n": if m.prEditField == prEditBaseField { m.moveBranchSuggestion(1) } else if isPREditPeopleField(m.prEditField) { m.moveUserSuggestion(1) } case "ctrl+p": if m.prEditField == prEditBaseField { m.moveBranchSuggestion(-1) } else if isPREditPeopleField(m.prEditField) { m.moveUserSuggestion(-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 isPREditPeopleField(m.prEditField) && m.completeUserSuggestion() { 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 } if isPREditPeopleField(m.prEditField) && editor.Text != before { m.prEditUserIndex = 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, m.contentTop+screenRow+1) } func (m App) submitPREdit() tea.Cmd { writer := m.service.(GitHubPullRequestWriteService) peopleWriter := m.service.(GitHubPullRequestPeopleWriteService) id := m.details.ID update := m.prEditMetadata() owner, repo, number := m.details.Owner, m.details.Repository, m.details.Number peopleUpdate := PullRequestPeopleUpdate{ CurrentReviewers: slices.Clone(m.details.RequestedReviewers), CurrentAssignees: slices.Clone(m.details.Assignees), Reviewers: slices.Clone(update.Reviewers), Assignees: slices.Clone(update.Assignees), } return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() result := pullRequestUpdatedMsg{} peopleChanged := !slices.Equal(update.Reviewers, m.prEditOriginal.Reviewers) || !slices.Equal(update.Assignees, m.prEditOriginal.Assignees) if peopleChanged { result.people, result.err = peopleWriter.UpdatePullRequestPeople( ctx, owner, repo, number, peopleUpdate, ) if result.err != nil { result.peopleSaved = !equalLoginSets(result.people.Reviewers, peopleUpdate.CurrentReviewers) || !equalLoginSets(result.people.Assignees, peopleUpdate.CurrentAssignees) return result } result.peopleSaved = true } if !samePRMetadataCore(update, m.prEditOriginal) { result.metadata, result.err = writer.UpdatePullRequest(ctx, id, update) if result.err != nil { return result } } else { result.metadata = m.prEditOriginal } result.metadata.Reviewers = slices.Clone(update.Reviewers) result.metadata.Assignees = slices.Clone(update.Assignees) return result } } 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 err := m.validatePREditUsers(update); err != nil { return err } if samePRMetadata(update, m.prEditOriginal) { return errors.New("pull request fields 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, Reviewers: normalizedLogins(m.details.RequestedReviewers), Assignees: normalizedLogins(m.details.Assignees), 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), Reviewers: parseLoginList(m.prEditEditors[prEditReviewersField].Text), Assignees: parseLoginList(m.prEditEditors[prEditAssigneesField].Text), } } func samePRMetadata(left, right PullRequestMetadata) bool { return samePRMetadataCore(left, right) && slices.Equal(left.Reviewers, right.Reviewers) && slices.Equal(left.Assignees, right.Assignees) } func samePRMetadataCore(left, right PullRequestMetadata) bool { return left.Title == right.Title && left.Body == right.Body && left.BaseRef == right.BaseRef } func (m *App) applyPREditPeople(people PullRequestPeople) { oldRequested := make(map[string]bool, len(m.details.RequestedReviewers)) for _, login := range m.details.RequestedReviewers { oldRequested[strings.ToLower(login)] = true } desired := make(map[string]bool, len(people.Reviewers)) for _, login := range people.Reviewers { desired[strings.ToLower(login)] = true } filtered := m.details.Reviewers[:0] known := make(map[string]bool) for _, reviewer := range m.details.Reviewers { key := strings.ToLower(reviewer.Login) if oldRequested[key] && reviewer.State == "REVIEW_REQUESTED" && !desired[key] { continue } filtered = append(filtered, reviewer) known[key] = true } for _, login := range people.Reviewers { if !known[strings.ToLower(login)] { filtered = append(filtered, Reviewer{Login: login, State: "REVIEW_REQUESTED"}) } } sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i].Login) < strings.ToLower(filtered[j].Login) }) m.details.Reviewers = filtered m.details.RequestedReviewers = slices.Clone(people.Reviewers) m.details.Assignees = slices.Clone(people.Assignees) } func (m *App) clearPREdit() { m.prEditField = 0 m.prEditEditors = [prEditFieldCount]textEditor{} m.prEditOriginal = PullRequestMetadata{} m.prEditBranches = nil m.prEditBranchesLoading = false m.prEditBranchesError = "" m.prEditBranchIndex = 0 m.prEditUsers = nil m.prEditUsersLoading = false m.prEditUsersError = "" m.prEditUserIndex = 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("reviewers", prEditReviewersField) appendField("assignees", prEditAssigneesField) 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))...) } if active && isPREditPeopleField(field) { lines = append(lines, m.userCompletionLines(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)), ), "") } if !slices.Equal(update.Reviewers, m.prEditOriginal.Reviewers) { lines = append(lines, dimStyle.Render("reviewers"), loginChangeSummary(m.prEditOriginal.Reviewers, update.Reviewers), "", ) } if !slices.Equal(update.Assignees, m.prEditOriginal.Assignees) { lines = append(lines, dimStyle.Render("assignees"), loginChangeSummary(m.prEditOriginal.Assignees, update.Assignees), "", ) } 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 } func (m App) prEditConfirmationMaxScroll() int { return max(0, len(m.prEditConfirmationLines(max(1, min(74, m.width-6))))-max(3, m.height-4)) }