fix: reviewers not correctly shown on dashboard edit screen
This commit is contained in:
21
README.md
21
README.md
@@ -200,11 +200,22 @@ Normal, Insert, and Visual modes, word/find motions, deletion, system clipboard
|
|||||||
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
|
yank/paste, and soft-wrap-aware movement. Set `editing.mode = "standard"` for a
|
||||||
non-modal editor. Target-branch, reviewer, and assignee completion use
|
non-modal editor. Target-branch, reviewer, and assignee completion use
|
||||||
`ctrl+n` and `ctrl+p`; reviewer and assignee fields accept comma-separated
|
`ctrl+n` and `ctrl+p`; reviewer and assignee fields accept comma-separated
|
||||||
GitHub usernames. Current reviewers and assignees are prefilled and marked in
|
GitHub usernames. Pending individual review requests and assignees are
|
||||||
completion results. Reviewer suggestions prioritize recent contributors using
|
prefilled and marked in completion results. Reviewers who already submitted a
|
||||||
the latest 100 commits on the repository's default branch; this bounded window
|
review, and requested teams, appear first as protected subdued tokens in the
|
||||||
is also shown in the editor. Every change is shown in the existing confirmation
|
reviewer field. Their handles retain a darker version of their deterministic
|
||||||
screen before GitHub is updated.
|
user color, while their brackets and review state use the theme's dim color.
|
||||||
|
GitHub only permits changing pending review requests.
|
||||||
|
Protected reviewers cannot receive cursor focus or be deleted, and are excluded
|
||||||
|
from reviewer completion. Newly entered names gain a visual `@` prefix and
|
||||||
|
their deterministic user color as soon as they exactly match an eligible
|
||||||
|
reviewer. At that point the suggestions reset to the remaining eligible users;
|
||||||
|
pressing Space commits the current reviewer and starts the next entry. Reviewers
|
||||||
|
already present in the field are excluded from those suggestions. Reviewer
|
||||||
|
suggestions prioritize recent contributors using the latest 100 commits on the
|
||||||
|
repository's default branch; this bounded window is also shown in the editor.
|
||||||
|
Every change is shown in the existing confirmation screen before GitHub is
|
||||||
|
updated.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
202
pr_editor.go
202
pr_editor.go
@@ -168,6 +168,15 @@ func (m App) updatePREditInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
if key.Type == tea.KeySpace && m.prEditField == prEditReviewersField {
|
||||||
|
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 {
|
switch k {
|
||||||
case "ctrl+s":
|
case "ctrl+s":
|
||||||
@@ -280,7 +289,7 @@ func (m App) positionPREditHardwareCursor(scroll, viewportHeight int) {
|
|||||||
if m.cursorOutput == nil {
|
if m.cursorOutput == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editor := m.prEditEditors[m.prEditField]
|
editor := m.prEditDisplayEditor(m.prEditField, m.prEditEditorWidth())
|
||||||
if editor.Mode != textEditorInsert {
|
if editor.Mode != textEditorInsert {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -524,7 +533,9 @@ func (m App) dashboardEditLayout() ([]string, int) {
|
|||||||
start := len(lines)
|
start := len(lines)
|
||||||
lines = append(lines, m.prEditFieldLines(label, field, width)...)
|
lines = append(lines, m.prEditFieldLines(label, field, width)...)
|
||||||
if m.prEditField == field {
|
if m.prEditField == field {
|
||||||
cursorLine = start + 1 + editorCursorVisualLine(m.prEditEditors[field], max(1, width-4))
|
cursorLine = start + 1 + editorCursorVisualLine(
|
||||||
|
m.prEditDisplayEditor(field, max(1, width-4)), max(1, width-4),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appendField("title", prEditTitleField)
|
appendField("title", prEditTitleField)
|
||||||
@@ -538,6 +549,9 @@ func (m App) dashboardEditLayout() ([]string, int) {
|
|||||||
func (m App) prEditFieldLines(label string, field, width int) []string {
|
func (m App) prEditFieldLines(label string, field, width int) []string {
|
||||||
active := m.prEditField == field
|
active := m.prEditField == field
|
||||||
editor := m.prEditEditors[field]
|
editor := m.prEditEditors[field]
|
||||||
|
if field == prEditReviewersField {
|
||||||
|
label += " (pending requests editable)"
|
||||||
|
}
|
||||||
prefix := " "
|
prefix := " "
|
||||||
if active {
|
if active {
|
||||||
prefix = "▶ "
|
prefix = "▶ "
|
||||||
@@ -551,7 +565,7 @@ func (m App) prEditFieldLines(label string, field, width int) []string {
|
|||||||
labelLine = titleStyle.Render(prefix + label)
|
labelLine = titleStyle.Render(prefix + label)
|
||||||
}
|
}
|
||||||
textWidth := max(1, width-4)
|
textWidth := max(1, width-4)
|
||||||
rendered := renderTextEditor(editor, textWidth, active)
|
rendered := renderTextEditor(m.prEditDisplayEditor(field, textWidth), textWidth, active)
|
||||||
lines := []string{labelLine}
|
lines := []string{labelLine}
|
||||||
for _, line := range rendered {
|
for _, line := range rendered {
|
||||||
if line.active {
|
if line.active {
|
||||||
@@ -572,6 +586,188 @@ func (m App) prEditFieldLines(label string, field, width int) []string {
|
|||||||
return lines
|
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() {
|
func (m *App) ensurePREditCursorVisible() {
|
||||||
if m.writeMode != writePREdit {
|
if m.writeMode != writePREdit {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ type textFind struct {
|
|||||||
valid bool
|
valid bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type editorProtectedStyle struct {
|
||||||
|
start, end int
|
||||||
|
color string
|
||||||
|
}
|
||||||
|
|
||||||
// textEditor owns buffer and motion state independently of any particular
|
// textEditor owns buffer and motion state independently of any particular
|
||||||
// screen. Inputs can opt into modal behavior without duplicating cursor logic.
|
// screen. Inputs can opt into modal behavior without duplicating cursor logic.
|
||||||
type textEditor struct {
|
type textEditor struct {
|
||||||
@@ -41,6 +46,8 @@ type textEditor struct {
|
|||||||
err error
|
err error
|
||||||
hardwareCursor bool
|
hardwareCursor bool
|
||||||
highlightMarkdown bool
|
highlightMarkdown bool
|
||||||
|
protectedPrefix int
|
||||||
|
protectedStyles []editorProtectedStyle
|
||||||
keys KeyBindings
|
keys KeyBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,7 +845,7 @@ func renderTextEditor(editor textEditor, width int, active bool) []editorRendere
|
|||||||
rendered := renderEditorVisualLine(
|
rendered := renderEditorVisualLine(
|
||||||
line, cursor, editor.Mode, selectionStart, selectionEnd,
|
line, cursor, editor.Mode, selectionStart, selectionEnd,
|
||||||
active && hasSelection, active && onVisualLine, editor.hardwareCursor,
|
active && hasSelection, active && onVisualLine, editor.hardwareCursor,
|
||||||
markdownStyles, width,
|
markdownStyles, width, editor.protectedPrefix, editor.protectedStyles,
|
||||||
)
|
)
|
||||||
if active && onVisualLine {
|
if active && onVisualLine {
|
||||||
rendered = pad(rendered, width)
|
rendered = pad(rendered, width)
|
||||||
@@ -908,7 +915,8 @@ func renderEditorVisualLine(
|
|||||||
selectionStart, selectionEnd int,
|
selectionStart, selectionEnd int,
|
||||||
hasSelection, showCursor, hardwareCursor bool,
|
hasSelection, showCursor, hardwareCursor bool,
|
||||||
markdownStyles []editorMarkdownStyle,
|
markdownStyles []editorMarkdownStyle,
|
||||||
width int,
|
width, protectedPrefix int,
|
||||||
|
protectedStyles []editorProtectedStyle,
|
||||||
) string {
|
) string {
|
||||||
const (
|
const (
|
||||||
reverseStart = "\x1b[7m"
|
reverseStart = "\x1b[7m"
|
||||||
@@ -919,9 +927,32 @@ func renderEditorVisualLine(
|
|||||||
runes := []rune(line.text)
|
runes := []rune(line.text)
|
||||||
var rendered strings.Builder
|
var rendered strings.Builder
|
||||||
selected := false
|
selected := false
|
||||||
|
protectedColor := ""
|
||||||
markdownStyle := editorMarkdownPlain
|
markdownStyle := editorMarkdownPlain
|
||||||
for offset, value := range runes {
|
for offset, value := range runes {
|
||||||
position := line.start + offset
|
position := line.start + offset
|
||||||
|
nextProtectedColor := ""
|
||||||
|
if position < protectedPrefix {
|
||||||
|
nextProtectedColor = editorMarkdownTheme.Dim
|
||||||
|
}
|
||||||
|
for _, style := range protectedStyles {
|
||||||
|
if position >= style.start && position < style.end {
|
||||||
|
nextProtectedColor = style.color
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nextProtectedColor != protectedColor {
|
||||||
|
if colorEnabled && nextProtectedColor != "" {
|
||||||
|
rendered.WriteString(foregroundSequence(nextProtectedColor))
|
||||||
|
} else if colorEnabled && protectedColor != "" {
|
||||||
|
if showCursor {
|
||||||
|
rendered.WriteString(foregroundSequence(editorMarkdownTheme.EditorForeground))
|
||||||
|
} else {
|
||||||
|
rendered.WriteString("\x1b[39m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protectedColor = nextProtectedColor
|
||||||
|
}
|
||||||
nextMarkdownStyle := editorMarkdownPlain
|
nextMarkdownStyle := editorMarkdownPlain
|
||||||
if position < len(markdownStyles) {
|
if position < len(markdownStyles) {
|
||||||
nextMarkdownStyle = markdownStyles[position]
|
nextMarkdownStyle = markdownStyles[position]
|
||||||
@@ -979,6 +1010,13 @@ func renderEditorVisualLine(
|
|||||||
if markdownStyle != editorMarkdownPlain {
|
if markdownStyle != editorMarkdownPlain {
|
||||||
rendered.WriteString(editorMarkdownStyleEnd(showCursor))
|
rendered.WriteString(editorMarkdownStyleEnd(showCursor))
|
||||||
}
|
}
|
||||||
|
if protectedColor != "" && colorEnabled {
|
||||||
|
if showCursor {
|
||||||
|
rendered.WriteString(foregroundSequence(editorMarkdownTheme.EditorForeground))
|
||||||
|
} else {
|
||||||
|
rendered.WriteString("\x1b[39m")
|
||||||
|
}
|
||||||
|
}
|
||||||
if showCursor && cursor == line.end {
|
if showCursor && cursor == line.end {
|
||||||
switch mode {
|
switch mode {
|
||||||
case textEditorInsert:
|
case textEditorInsert:
|
||||||
|
|||||||
12
theme.go
12
theme.go
@@ -231,6 +231,18 @@ func foregroundSequence(color string) string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func darkenColor(color lipgloss.Color) lipgloss.Color {
|
||||||
|
value, err := strconv.ParseUint(strings.TrimPrefix(string(color), "#"), 16, 24)
|
||||||
|
if err != nil {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
const numerator, denominator = uint64(3), uint64(4)
|
||||||
|
red := ((value >> 16) & 0xff) * numerator / denominator
|
||||||
|
green := ((value >> 8) & 0xff) * numerator / denominator
|
||||||
|
blue := (value & 0xff) * numerator / denominator
|
||||||
|
return lipgloss.Color(fmt.Sprintf("#%02X%02X%02X", red, green, blue))
|
||||||
|
}
|
||||||
|
|
||||||
func builtinThemePalettes() map[string]themePalette {
|
func builtinThemePalettes() map[string]themePalette {
|
||||||
dark := palette(
|
dark := palette(
|
||||||
"dark", "#F0B72F", "#777777", "#D7DAE8", "#FFFFFF", "#3B4261",
|
"dark", "#F0B72F", "#777777", "#D7DAE8", "#FFFFFF", "#3B4261",
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ func TestNoColorThemeDisablesSyntaxColors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDarkenColorRetainsHue(t *testing.T) {
|
||||||
|
if got := darkenColor(lipgloss.Color("#4080C0")); got != lipgloss.Color("#306090") {
|
||||||
|
t.Fatalf("darkened color = %q, want #306090", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuiltinThemesApply(t *testing.T) {
|
func TestBuiltinThemesApply(t *testing.T) {
|
||||||
defer applyTheme("dark")
|
defer applyTheme("dark")
|
||||||
names := []string{
|
names := []string{
|
||||||
|
|||||||
@@ -61,10 +61,47 @@ func selectedLoginPrefix(value string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) reviewerInputContext(value string) (
|
||||||
|
prefix, query string,
|
||||||
|
selected []string,
|
||||||
|
currentComplete bool,
|
||||||
|
) {
|
||||||
|
prefix = selectedLoginPrefix(value)
|
||||||
|
query = currentLoginQuery(value)
|
||||||
|
selected = parseLoginList(prefix)
|
||||||
|
if canonical, ok := m.eligibleReviewerLogin(query); ok {
|
||||||
|
selected = normalizedLogins(append(selected, canonical))
|
||||||
|
query = ""
|
||||||
|
currentComplete = true
|
||||||
|
}
|
||||||
|
return prefix, query, selected, currentComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) eligibleReviewerLogin(value string) (string, bool) {
|
||||||
|
value = strings.TrimSpace(strings.TrimPrefix(value, "@"))
|
||||||
|
if value == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
for _, user := range m.prEditUsers {
|
||||||
|
if user.CanReview &&
|
||||||
|
!strings.EqualFold(user.Login, m.details.Author) &&
|
||||||
|
strings.EqualFold(user.Login, value) {
|
||||||
|
return user.Login, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) userSuggestions() []userSuggestion {
|
func (m App) userSuggestions() []userSuggestion {
|
||||||
field := m.prEditField
|
field := m.prEditField
|
||||||
query := strings.ToLower(currentLoginQuery(m.prEditEditors[field].Text))
|
query := strings.ToLower(currentLoginQuery(m.prEditEditors[field].Text))
|
||||||
selected := parseLoginList(selectedLoginPrefix(m.prEditEditors[field].Text))
|
selected := parseLoginList(selectedLoginPrefix(m.prEditEditors[field].Text))
|
||||||
|
if field == prEditReviewersField {
|
||||||
|
_, reviewerQuery, reviewerSelected, _ :=
|
||||||
|
m.reviewerInputContext(m.prEditEditors[field].Text)
|
||||||
|
query = strings.ToLower(reviewerQuery)
|
||||||
|
selected = reviewerSelected
|
||||||
|
}
|
||||||
selectedSet := make(map[string]bool, len(selected))
|
selectedSet := make(map[string]bool, len(selected))
|
||||||
for _, login := range selected {
|
for _, login := range selected {
|
||||||
selectedSet[strings.ToLower(login)] = true
|
selectedSet[strings.ToLower(login)] = true
|
||||||
@@ -78,10 +115,18 @@ func (m App) userSuggestions() []userSuggestion {
|
|||||||
for _, login := range current {
|
for _, login := range current {
|
||||||
currentSet[strings.ToLower(login)] = true
|
currentSet[strings.ToLower(login)] = true
|
||||||
}
|
}
|
||||||
|
existingReviewers := make(map[string]bool, len(m.details.Reviewers))
|
||||||
|
if field == prEditReviewersField {
|
||||||
|
for _, reviewer := range m.details.Reviewers {
|
||||||
|
existingReviewers[strings.ToLower(reviewer.Login)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, user := range m.prEditUsers {
|
for _, user := range m.prEditUsers {
|
||||||
if field == prEditReviewersField {
|
if field == prEditReviewersField {
|
||||||
if !user.CanReview || strings.EqualFold(user.Login, m.details.Author) {
|
if !user.CanReview ||
|
||||||
|
strings.EqualFold(user.Login, m.details.Author) ||
|
||||||
|
existingReviewers[strings.ToLower(user.Login)] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else if !user.CanAssign {
|
} else if !user.CanAssign {
|
||||||
@@ -197,7 +242,14 @@ func (m *App) completeUserSuggestion() bool {
|
|||||||
index := clamp(m.prEditUserIndex, 0, len(suggestions)-1)
|
index := clamp(m.prEditUserIndex, 0, len(suggestions)-1)
|
||||||
login := suggestions[index].user.Login
|
login := suggestions[index].user.Login
|
||||||
editor := &m.prEditEditors[m.prEditField]
|
editor := &m.prEditEditors[m.prEditField]
|
||||||
completed := selectedLoginPrefix(editor.Text) + login
|
prefix := selectedLoginPrefix(editor.Text)
|
||||||
|
if m.prEditField == prEditReviewersField {
|
||||||
|
_, _, _, currentComplete := m.reviewerInputContext(editor.Text)
|
||||||
|
if currentComplete {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completed := prefix + login
|
||||||
if editor.Text == completed {
|
if editor.Text == completed {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -208,6 +260,25 @@ func (m *App) completeUserSuggestion() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *App) startNextReviewer() bool {
|
||||||
|
if m.prEditField != prEditReviewersField {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
editor := &m.prEditEditors[prEditReviewersField]
|
||||||
|
if editor.Cursor != len([]rune(editor.Text)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, _, _, currentComplete := m.reviewerInputContext(editor.Text)
|
||||||
|
if !currentComplete {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
editor.Text = strings.TrimRight(editor.Text, " \t") + ", "
|
||||||
|
editor.Cursor = len([]rune(editor.Text))
|
||||||
|
m.prEditUserIndex = 0
|
||||||
|
m.err = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) userCompletionLines(width int) []string {
|
func (m App) userCompletionLines(width int) []string {
|
||||||
width = max(1, width)
|
width = max(1, width)
|
||||||
if m.prEditUsersLoading {
|
if m.prEditUsersLoading {
|
||||||
@@ -232,9 +303,14 @@ func (m App) userCompletionLines(width int) []string {
|
|||||||
" ranked by latest 100 default-branch commits",
|
" ranked by latest 100 default-branch commits",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
separatorHelp := "comma separates users"
|
||||||
|
if m.prEditField == prEditReviewersField {
|
||||||
|
separatorHelp = "space starts next reviewer"
|
||||||
|
}
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
dimStyle.Render(fmt.Sprintf(
|
dimStyle.Render(fmt.Sprintf(
|
||||||
" comma separates users • %s choose • %s complete",
|
" %s • %s choose • %s complete",
|
||||||
|
separatorHelp,
|
||||||
primaryCombinedKeyLabel(
|
primaryCombinedKeyLabel(
|
||||||
m.keybindings.Input.PreviousCompletion,
|
m.keybindings.Input.PreviousCompletion,
|
||||||
m.keybindings.Input.NextCompletion,
|
m.keybindings.Input.NextCompletion,
|
||||||
|
|||||||
@@ -34,11 +34,101 @@ func TestReviewerCompletionSupportsMultipleEligibleUsers(t *testing.T) {
|
|||||||
m.prEditEditors[prEditReviewersField].Text != "alice, bob" {
|
m.prEditEditors[prEditReviewersField].Text != "alice, bob" {
|
||||||
t.Fatalf("completed reviewers = %q", m.prEditEditors[prEditReviewersField].Text)
|
t.Fatalf("completed reviewers = %q", m.prEditEditors[prEditReviewersField].Text)
|
||||||
}
|
}
|
||||||
|
displayEditor := m.prEditDisplayEditor(prEditReviewersField, 76)
|
||||||
|
if displayEditor.Text != "@alice, @bob" {
|
||||||
|
t.Fatalf("completed reviewer display = %q", displayEditor.Text)
|
||||||
|
}
|
||||||
|
if displayEditor.Cursor != len([]rune(displayEditor.Text)) {
|
||||||
|
t.Fatalf("completed reviewer display cursor = %d", displayEditor.Cursor)
|
||||||
|
}
|
||||||
view := ansi.Strip(strings.Join(
|
view := ansi.Strip(strings.Join(
|
||||||
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
||||||
))
|
))
|
||||||
if !strings.Contains(view, "comma separates users") {
|
if !strings.Contains(view, "@alice, @bob") ||
|
||||||
t.Fatalf("reviewer completion help missing:\n%s", view)
|
!strings.Contains(view, "no matching eligible users") {
|
||||||
|
t.Fatalf("completed reviewer field is inconsistent:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewerInputCommitsMultipleEligibleUsersWithSpace(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.writeMode = writePREdit
|
||||||
|
m.prEditField = prEditReviewersField
|
||||||
|
m.details = PRDetails{PullRequest: PullRequest{Author: "author"}}
|
||||||
|
m.prEditUsers = []RepositoryUser{
|
||||||
|
{Login: "alice", CanReview: true},
|
||||||
|
{Login: "bob", CanReview: true},
|
||||||
|
{Login: "carol", CanReview: true},
|
||||||
|
}
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("bob", false)
|
||||||
|
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) != 2 ||
|
||||||
|
suggestions[0].user.Login != "alice" ||
|
||||||
|
suggestions[1].user.Login != "carol" {
|
||||||
|
t.Fatalf("suggestions after complete reviewer = %#v", suggestions)
|
||||||
|
}
|
||||||
|
view := ansi.Strip(strings.Join(
|
||||||
|
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
||||||
|
))
|
||||||
|
if !strings.Contains(view, "space starts next reviewer") {
|
||||||
|
t.Fatalf("multi-reviewer completion help missing:\n%s", view)
|
||||||
|
}
|
||||||
|
updated, _ := m.updatePREditInput(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
m = updated.(App)
|
||||||
|
if got := m.prEditEditors[prEditReviewersField].Text; got != "bob, " {
|
||||||
|
t.Fatalf("space after complete reviewer produced %q", got)
|
||||||
|
}
|
||||||
|
if !m.completeUserSuggestion() {
|
||||||
|
t.Fatal("next reviewer suggestion was not completed")
|
||||||
|
}
|
||||||
|
if got := m.prEditEditors[prEditReviewersField].Text; got != "bob, alice" {
|
||||||
|
t.Fatalf("multiple reviewer input = %q", got)
|
||||||
|
}
|
||||||
|
suggestions = m.userSuggestions()
|
||||||
|
if len(suggestions) != 1 || suggestions[0].user.Login != "carol" {
|
||||||
|
t.Fatalf("already selected reviewers remained in suggestions: %#v", suggestions)
|
||||||
|
}
|
||||||
|
display := m.prEditDisplayEditor(prEditReviewersField, 76)
|
||||||
|
if display.Text != "@bob, @alice" {
|
||||||
|
t.Fatalf("multiple reviewer display = %q", display.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("bo", false)
|
||||||
|
updated, _ = m.updatePREditInput(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
|
m = updated.(App)
|
||||||
|
if got := m.prEditEditors[prEditReviewersField].Text; got != "bo" {
|
||||||
|
t.Fatalf("space after incomplete reviewer produced %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewerDisplayColorsOnlyCompleteEligibleNames(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.details = PRDetails{PullRequest: PullRequest{Author: "author"}}
|
||||||
|
m.prEditUsers = []RepositoryUser{
|
||||||
|
{Login: "bob", CanReview: true},
|
||||||
|
{Login: "assignee-only", CanAssign: true},
|
||||||
|
{Login: "author", CanReview: true},
|
||||||
|
}
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("bo, assignee-only, author", false)
|
||||||
|
|
||||||
|
display := m.prEditDisplayEditor(prEditReviewersField, 76)
|
||||||
|
if display.Text != "bo, assignee-only, author" || len(display.protectedStyles) != 0 {
|
||||||
|
t.Fatalf("partial or ineligible reviewers were decorated: %#v", display)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("bob, @BOB", false)
|
||||||
|
display = m.prEditDisplayEditor(prEditReviewersField, 76)
|
||||||
|
if display.Text != "@bob, @BOB" {
|
||||||
|
t.Fatalf("eligible reviewer display = %q", display.Text)
|
||||||
|
}
|
||||||
|
if len(display.protectedStyles) != 2 {
|
||||||
|
t.Fatalf("eligible reviewer styles = %#v", display.protectedStyles)
|
||||||
|
}
|
||||||
|
for _, style := range display.protectedStyles {
|
||||||
|
if style.color != string(authorColor("bob")) {
|
||||||
|
t.Fatalf("eligible reviewer color = %q, want normal author color", style.color)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +176,111 @@ func TestPREditStartsWithCurrentRequestedReviewers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPREditShowsCompletedAndTeamReviewersReadOnly(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.loading = false
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
ID: "pr", Owner: "o", Repository: "r", Title: "Title",
|
||||||
|
},
|
||||||
|
BaseRef: "main",
|
||||||
|
RequestedReviewers: []string{"pending-user", "rerequested-user"},
|
||||||
|
Reviewers: []Reviewer{
|
||||||
|
{Login: "approved-user", State: "APPROVED"},
|
||||||
|
{Login: "backend-team", State: "REVIEW_REQUESTED"},
|
||||||
|
{Login: "commented-user", State: "COMMENTED"},
|
||||||
|
{Login: "pending-user", State: "REVIEW_REQUESTED"},
|
||||||
|
{Login: "rerequested-user", State: "APPROVED"},
|
||||||
|
},
|
||||||
|
Permissions: ViewerPermissions{CanUpdatePR: true, CanAssign: true},
|
||||||
|
}
|
||||||
|
m.startPREdit()
|
||||||
|
|
||||||
|
if got := m.prEditEditors[prEditReviewersField].Text; got != "pending-user, rerequested-user" {
|
||||||
|
t.Fatalf("editable reviewer requests = %q", got)
|
||||||
|
}
|
||||||
|
view := ansi.Strip(strings.Join(
|
||||||
|
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
||||||
|
))
|
||||||
|
for _, expected := range []string{
|
||||||
|
"pending requests editable",
|
||||||
|
"[@approved-user · approved]",
|
||||||
|
"[@backend-team · review requested]",
|
||||||
|
"[@commented-user · commented]",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(view, expected) {
|
||||||
|
t.Fatalf("reviewer field does not show %q:\n%s", expected, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, editable := range []string{"pending-user", "rerequested-user"} {
|
||||||
|
if strings.Contains(view, "[@"+editable) {
|
||||||
|
t.Fatalf("pending request @%s was rendered as a protected token:\n%s", editable, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
displayEditor := m.prEditDisplayEditor(prEditReviewersField, 76)
|
||||||
|
if len(displayEditor.protectedStyles) != 3 {
|
||||||
|
t.Fatalf("protected reviewer styles = %#v", displayEditor.protectedStyles)
|
||||||
|
}
|
||||||
|
firstStyle := displayEditor.protectedStyles[0]
|
||||||
|
displayRunes := []rune(displayEditor.Text)
|
||||||
|
if got := string(displayRunes[firstStyle.start:firstStyle.end]); got != "@approved-user" {
|
||||||
|
t.Fatalf("first protected author span = %q", got)
|
||||||
|
}
|
||||||
|
if firstStyle.color != string(darkenColor(authorColor("approved-user"))) {
|
||||||
|
t.Fatalf("protected author color = %q, want darkened deterministic color", firstStyle.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("pending-user", false)
|
||||||
|
view = ansi.Strip(strings.Join(
|
||||||
|
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
||||||
|
))
|
||||||
|
if !strings.Contains(view, "[@rerequested-user · approved]") {
|
||||||
|
t.Fatalf("removed re-review request did not retain its submitted review read-only:\n%s", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("", false)
|
||||||
|
for range 20 {
|
||||||
|
m.prEditEditors[prEditReviewersField].handleKey(
|
||||||
|
tea.KeyMsg{Type: tea.KeyBackspace}, false,
|
||||||
|
)
|
||||||
|
m.prEditEditors[prEditReviewersField].handleKey(
|
||||||
|
tea.KeyMsg{Type: tea.KeyDelete}, false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if m.prEditEditors[prEditReviewersField].Text != "" ||
|
||||||
|
!strings.Contains(
|
||||||
|
ansi.Strip(strings.Join(
|
||||||
|
m.prEditFieldLines("reviewers", prEditReviewersField, 80), "\n",
|
||||||
|
)),
|
||||||
|
"[@approved-user · approved]",
|
||||||
|
) {
|
||||||
|
t.Fatal("editing the reviewer field modified a protected reviewer token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewerSuggestionsExcludeExistingReviewers(t *testing.T) {
|
||||||
|
m := NewApp(&recordingPRService{}, "o", "r", false, 50, time.Second)
|
||||||
|
m.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{Author: "author"},
|
||||||
|
Reviewers: []Reviewer{
|
||||||
|
{Login: "approved-user", State: "APPROVED"},
|
||||||
|
{Login: "commented-user", State: "COMMENTED"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m.prEditField = prEditReviewersField
|
||||||
|
m.prEditEditors[prEditReviewersField] = newTextEditor("", false)
|
||||||
|
m.prEditUsers = []RepositoryUser{
|
||||||
|
{Login: "approved-user", CanReview: true},
|
||||||
|
{Login: "commented-user", CanReview: true},
|
||||||
|
{Login: "new-user", CanReview: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions := m.userSuggestions()
|
||||||
|
if len(suggestions) != 1 || suggestions[0].user.Login != "new-user" {
|
||||||
|
t.Fatalf("reviewer suggestions include existing reviewers: %#v", suggestions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPeopleOnlyPREditSkipsCoreMetadataMutation(t *testing.T) {
|
func TestPeopleOnlyPREditSkipsCoreMetadataMutation(t *testing.T) {
|
||||||
service := &recordingPRService{}
|
service := &recordingPRService{}
|
||||||
m := NewApp(service, "o", "r", false, 50, time.Second)
|
m := NewApp(service, "o", "r", false, 50, time.Second)
|
||||||
|
|||||||
Reference in New Issue
Block a user