856 lines
26 KiB
Go
856 lines
26 KiB
Go
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.prEditGeneration++
|
|
m.prEditField = prEditBodyField
|
|
modal := m.editorMode == "vim"
|
|
m.prEditEditors[prEditTitleField] = newTextEditor(m.details.Title, modal)
|
|
m.prEditEditors[prEditBaseField] = newTextEditor(m.details.BaseRef, modal)
|
|
m.prEditEditors[prEditReviewersField] = newTextEditor(
|
|
strings.Join(m.details.RequestedReviewers, ", "), modal,
|
|
)
|
|
m.prEditEditors[prEditAssigneesField] = newTextEditor(
|
|
strings.Join(m.details.Assignees, ", "), modal,
|
|
)
|
|
m.prEditEditors[prEditBodyField] = newTextEditor(
|
|
normalizeLineEndings(m.details.Body),
|
|
modal,
|
|
)
|
|
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, generation := m.details.Owner, m.details.Repository, m.prEditGeneration
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
branches, err := service.ListBranches(ctx, owner, repo)
|
|
return branchesLoadedMsg{
|
|
generation: generation, 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, generation := m.details.Owner, m.details.Repository, m.prEditGeneration
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
users, err := service.ListRepositoryUsers(ctx, owner, repo)
|
|
return repositoryUsersLoadedMsg{
|
|
generation: generation, owner: owner, repo: repo, users: users, err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m App) pullRequestUpdateUnavailable() string {
|
|
if m.loading && m.mutations == nil {
|
|
return "pull request update unavailable while PR data is refreshing"
|
|
}
|
|
if m.details.FromCache && m.mutations == nil {
|
|
return "offline mutation queue is unavailable"
|
|
}
|
|
if m.mutations != nil && m.mutations.loadErr != nil {
|
|
return "mutation queue is unavailable: " + m.mutations.loadErr.Error()
|
|
}
|
|
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
|
|
}
|
|
if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField &&
|
|
(!m.prEditEditors[m.prEditField].Modal ||
|
|
m.prEditEditors[m.prEditField].Mode == textEditorInsert) {
|
|
if m.startNextReviewer() {
|
|
m.ensurePREditCursorVisible()
|
|
return m, m.queuePREditDraft()
|
|
}
|
|
// GitHub usernames cannot contain spaces. Ignore a space until the
|
|
// current entry is an exact eligible reviewer.
|
|
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":
|
|
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.prEditDisplayEditor(m.prEditField, m.prEditEditorWidth())
|
|
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 {
|
|
if m.mutations != nil {
|
|
operation := mutationOperation{
|
|
Kind: mutationPREdit, Owner: m.details.Owner, Repository: m.details.Repository,
|
|
Number: m.details.Number, PRID: m.details.ID, Viewer: m.details.ViewerLogin,
|
|
Original: m.prEditOriginal, Update: m.prEditMetadata(),
|
|
Permissions: m.details.Permissions,
|
|
}
|
|
if m.editingMutationID != "" {
|
|
if existing, ok := m.mutations.get(m.editingMutationID); ok {
|
|
operation.ID, operation.EnqueuedAt = existing.ID, existing.EnqueuedAt
|
|
return m.replaceMutation(operation)
|
|
}
|
|
}
|
|
return m.enqueueMutation(operation)
|
|
}
|
|
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.editingMutationID = ""
|
|
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.prEditDisplayEditor(field, max(1, width-4)), 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
|
|
if field == prEditReviewersField {
|
|
label += " (pending requests editable)"
|
|
}
|
|
prefix := " "
|
|
if active {
|
|
prefix = "▶ "
|
|
}
|
|
labelLine := dimStyle.Render(prefix + label)
|
|
if active {
|
|
labelLine = titleStyle.Render(prefix + label)
|
|
}
|
|
textWidth := max(1, width-4)
|
|
rendered := renderTextEditor(m.prEditDisplayEditor(field, textWidth), 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) prEditDisplayEditor(field, width int) textEditor {
|
|
editor := m.prEditEditors[field]
|
|
if field != prEditReviewersField {
|
|
return editor
|
|
}
|
|
editor, editableStyles := m.prEditEligibleReviewerDisplay(editor)
|
|
prefix, protectedStyles := m.prEditReadOnlyReviewerPrefix(width)
|
|
if prefix == "" {
|
|
editor.protectedStyles = editableStyles
|
|
return editor
|
|
}
|
|
offset := len([]rune(prefix))
|
|
editor.Text = prefix + editor.Text
|
|
editor.Cursor += offset
|
|
editor.visualAnchor += offset
|
|
editor.protectedPrefix = offset
|
|
editor.protectedStyles = append(protectedStyles, shiftedEditorStyles(editableStyles, offset)...)
|
|
return editor
|
|
}
|
|
|
|
func (m App) prEditEligibleReviewerDisplay(editor textEditor) (textEditor, []editorProtectedStyle) {
|
|
type eligibleToken struct {
|
|
start, end int
|
|
login string
|
|
insertAt bool
|
|
}
|
|
eligible := make(map[string]string, len(m.prEditUsers))
|
|
for _, user := range m.prEditUsers {
|
|
if user.CanReview && !strings.EqualFold(user.Login, m.details.Author) {
|
|
eligible[strings.ToLower(user.Login)] = user.Login
|
|
}
|
|
}
|
|
runes := []rune(editor.Text)
|
|
var tokens []eligibleToken
|
|
for segmentStart := 0; segmentStart <= len(runes); {
|
|
segmentEnd := segmentStart
|
|
for segmentEnd < len(runes) && runes[segmentEnd] != ',' {
|
|
segmentEnd++
|
|
}
|
|
start, end := segmentStart, segmentEnd
|
|
for start < end && (runes[start] == ' ' || runes[start] == '\t') {
|
|
start++
|
|
}
|
|
for end > start && (runes[end-1] == ' ' || runes[end-1] == '\t') {
|
|
end--
|
|
}
|
|
hasAt := start < end && runes[start] == '@'
|
|
loginStart := start
|
|
if hasAt {
|
|
loginStart++
|
|
}
|
|
login := string(runes[loginStart:end])
|
|
if canonical, ok := eligible[strings.ToLower(login)]; ok && login != "" {
|
|
tokens = append(tokens, eligibleToken{
|
|
start: start, end: end, login: canonical, insertAt: !hasAt,
|
|
})
|
|
}
|
|
if segmentEnd == len(runes) {
|
|
break
|
|
}
|
|
segmentStart = segmentEnd + 1
|
|
}
|
|
if len(tokens) == 0 {
|
|
return editor, nil
|
|
}
|
|
|
|
insertions := make(map[int]bool)
|
|
for _, token := range tokens {
|
|
if token.insertAt {
|
|
insertions[token.start] = true
|
|
}
|
|
}
|
|
displayRunes := make([]rune, 0, len(runes)+len(insertions))
|
|
for index, value := range runes {
|
|
if insertions[index] {
|
|
displayRunes = append(displayRunes, '@')
|
|
}
|
|
displayRunes = append(displayRunes, value)
|
|
}
|
|
if insertions[len(runes)] {
|
|
displayRunes = append(displayRunes, '@')
|
|
}
|
|
mappedPosition := func(position int) int {
|
|
mapped := position
|
|
for insertion := range insertions {
|
|
if insertion <= position {
|
|
mapped++
|
|
}
|
|
}
|
|
return mapped
|
|
}
|
|
var styles []editorProtectedStyle
|
|
for _, token := range tokens {
|
|
start := mappedPosition(token.start)
|
|
if token.insertAt {
|
|
start--
|
|
}
|
|
styles = append(styles, editorProtectedStyle{
|
|
start: start,
|
|
end: start + 1 + len([]rune(token.login)),
|
|
color: string(authorColor(token.login)),
|
|
})
|
|
}
|
|
editor.Text = string(displayRunes)
|
|
editor.Cursor = mappedPosition(editor.Cursor)
|
|
editor.visualAnchor = mappedPosition(editor.visualAnchor)
|
|
return editor, styles
|
|
}
|
|
|
|
func shiftedEditorStyles(styles []editorProtectedStyle, offset int) []editorProtectedStyle {
|
|
shifted := make([]editorProtectedStyle, len(styles))
|
|
for index, style := range styles {
|
|
style.start += offset
|
|
style.end += offset
|
|
shifted[index] = style
|
|
}
|
|
return shifted
|
|
}
|
|
|
|
func (m App) prEditReadOnlyReviewerPrefix(width int) (string, []editorProtectedStyle) {
|
|
editable := parseLoginList(m.prEditEditors[prEditReviewersField].Text)
|
|
editableSet := make(map[string]bool, len(editable))
|
|
for _, login := range editable {
|
|
editableSet[strings.ToLower(login)] = true
|
|
}
|
|
requestedSet := make(map[string]bool, len(m.details.RequestedReviewers))
|
|
for _, login := range m.details.RequestedReviewers {
|
|
requestedSet[strings.ToLower(login)] = true
|
|
}
|
|
var tokens []string
|
|
var readOnlyReviewers []Reviewer
|
|
for _, reviewer := range m.details.Reviewers {
|
|
key := strings.ToLower(reviewer.Login)
|
|
if editableSet[key] ||
|
|
(requestedSet[key] && reviewer.State == "REVIEW_REQUESTED") {
|
|
continue
|
|
}
|
|
state := strings.ToLower(strings.ReplaceAll(reviewer.State, "_", " "))
|
|
if state == "" {
|
|
state = "reviewed"
|
|
}
|
|
tokens = append(tokens, "[@"+reviewer.Login+" · "+state+"]")
|
|
readOnlyReviewers = append(readOnlyReviewers, reviewer)
|
|
}
|
|
if len(tokens) == 0 {
|
|
return "", nil
|
|
}
|
|
width = max(1, width)
|
|
var prefix strings.Builder
|
|
var styles []editorProtectedStyle
|
|
lineWidth := 0
|
|
runeOffset := 0
|
|
for index, token := range tokens {
|
|
tokenWidth := ansi.StringWidth(token)
|
|
if lineWidth > 0 && lineWidth+1+tokenWidth > width {
|
|
prefix.WriteByte('\n')
|
|
lineWidth = 0
|
|
runeOffset++
|
|
}
|
|
if lineWidth > 0 {
|
|
prefix.WriteByte(' ')
|
|
lineWidth++
|
|
runeOffset++
|
|
}
|
|
prefix.WriteString(token)
|
|
login := readOnlyReviewers[index].Login
|
|
styles = append(styles, editorProtectedStyle{
|
|
start: runeOffset + 1,
|
|
end: runeOffset + 2 + len([]rune(login)),
|
|
color: string(darkenColor(authorColor(login))),
|
|
})
|
|
lineWidth += tokenWidth
|
|
runeOffset += len([]rune(token))
|
|
}
|
|
if lineWidth+2 >= width {
|
|
prefix.WriteByte('\n')
|
|
} else {
|
|
prefix.WriteString(" ")
|
|
}
|
|
return prefix.String(), styles
|
|
}
|
|
|
|
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))
|
|
}
|