Compare commits
2 Commits
28e418abd6
...
e045bd39b2
| Author | SHA1 | Date | |
|---|---|---|---|
| e045bd39b2 | |||
| bcad515698 |
35
README.md
35
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
|
||||||
|
|
||||||
@@ -304,13 +315,13 @@ showing every repeated `COMMENTED` event.
|
|||||||
`viewer_label = "login"` shows your GitHub username like every other author.
|
`viewer_label = "login"` shows your GitHub username like every other author.
|
||||||
Set it to `"you"` to replace your username with `@you` throughout the UI.
|
Set it to `"you"` to replace your username with `@you` throughout the UI.
|
||||||
|
|
||||||
Difflet is disabled by default. Set `mascot = true` to keep it visible to the
|
Difflet is disabled by default. Set `mascot = true` to show it on the pull
|
||||||
right next to the active view's naturally sized header, separated by a
|
request picker, dashboard, and thread screens. On the dashboard it is centered
|
||||||
small gap. On normal terminal widths Difflet is centered horizontally and the
|
beside the first metadata rows so it does not add whitespace below the pull
|
||||||
header uses the space to its left. When centering would make the header too
|
request title. Editor and popup views hide it to preserve their full usable
|
||||||
narrow, Difflet falls back to a small right-edge inset. Header information
|
height. On other supported screens Difflet sits to the right of the naturally
|
||||||
wraps when the combined header and mascot do not fit. The layout adds only the
|
sized header. Header information wraps when the combined header and mascot do
|
||||||
vertical rows required to display the four-line mascot.
|
not fit.
|
||||||
`mascot_animated` controls brief loading, blink, success, and error motion
|
`mascot_animated` controls brief loading, blink, success, and error motion
|
||||||
independently from `mascot_expressive`, which permits stronger emotional
|
independently from `mascot_expressive`, which permits stronger emotional
|
||||||
faces. Disabling animation leaves the appropriate final state visible.
|
faces. Disabling animation leaves the appropriate final state visible.
|
||||||
|
|||||||
167
difflet_test.go
167
difflet_test.go
@@ -196,6 +196,173 @@ func TestDiffletDisabledPreservesViewExactly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiffletDashboardIsCenteredBesideMetadata(t *testing.T) {
|
||||||
|
app := NewAppWithSettings(
|
||||||
|
&recordingService{}, "owner", "repository", false, 10, 10,
|
||||||
|
AppSettings{Mascot: true},
|
||||||
|
)
|
||||||
|
app.screen = dashboardScreen
|
||||||
|
app.loading = false
|
||||||
|
app.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
RepoWithOwner: "owner/repository", Number: 42,
|
||||||
|
Title: "A useful title", Author: "alice",
|
||||||
|
},
|
||||||
|
HeadRef: "feature", BaseRef: "main",
|
||||||
|
}
|
||||||
|
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||||
|
app = updated.(App)
|
||||||
|
|
||||||
|
rendered := strings.Split(app.View(), "\n")
|
||||||
|
headerHeight := len(app.dashboardHeaderLines())
|
||||||
|
if strings.TrimSpace(ansi.Strip(rendered[headerHeight])) == "" {
|
||||||
|
t.Fatal("dashboard left a blank row between its title and metadata")
|
||||||
|
}
|
||||||
|
for row, mascotLine := range (DiffletFrame{Expression: DiffletIdle}).lines() {
|
||||||
|
plain := ansi.Strip(rendered[headerHeight+row])
|
||||||
|
mascotText := strings.TrimRight(mascotLine, " ")
|
||||||
|
mascotIndex := strings.Index(plain, mascotText)
|
||||||
|
left := -1
|
||||||
|
if mascotIndex >= 0 {
|
||||||
|
left = lipgloss.Width(plain[:mascotIndex])
|
||||||
|
}
|
||||||
|
if left != (app.width-diffletWidth)/2 {
|
||||||
|
t.Fatalf("row %d mascot starts at %d, want centered position %d: %q",
|
||||||
|
row, left, (app.width-diffletWidth)/2, plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for row, label := range []string{"author", "branches", "review"} {
|
||||||
|
if !strings.Contains(ansi.Strip(rendered[headerHeight+row]), label) {
|
||||||
|
t.Fatalf("dashboard row %d does not place %q beside mascot: %q",
|
||||||
|
row, label, ansi.Strip(rendered[headerHeight+row]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffletIsHiddenInEditorAndPopups(t *testing.T) {
|
||||||
|
app := NewAppWithSettings(
|
||||||
|
&recordingService{}, "owner", "repository", false, 10, 10,
|
||||||
|
AppSettings{Mascot: true},
|
||||||
|
)
|
||||||
|
app.screen = dashboardScreen
|
||||||
|
app.loading = false
|
||||||
|
app.details = PRDetails{PullRequest: PullRequest{
|
||||||
|
RepoWithOwner: "owner/repository", Number: 42, Title: "Title",
|
||||||
|
}}
|
||||||
|
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||||
|
app = updated.(App)
|
||||||
|
|
||||||
|
for _, mode := range []writeMode{writePREdit, writeReplyConfirm} {
|
||||||
|
app.writeMode = mode
|
||||||
|
if got, want := app.View(), app.viewContent(); got != want {
|
||||||
|
t.Fatalf("write mode %d changed by enabled mascot:\ngot:\n%q\nwant:\n%q",
|
||||||
|
mode, got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(ansi.Strip(app.View()), "▄███████▄") {
|
||||||
|
t.Fatalf("write mode %d displayed the mascot", mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.writeMode = writeNone
|
||||||
|
app.helpVisible = true
|
||||||
|
if got, want := app.View(), app.viewContent(); got != want {
|
||||||
|
t.Fatalf("help popup changed by enabled mascot:\ngot:\n%q\nwant:\n%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiffletEnabledEditorCanRevealLastDescriptionRow(t *testing.T) {
|
||||||
|
app := NewAppWithSettings(
|
||||||
|
&recordingPRService{}, "owner", "repository", false, 10, 10,
|
||||||
|
AppSettings{Mascot: true},
|
||||||
|
)
|
||||||
|
app.screen = dashboardScreen
|
||||||
|
app.loading = false
|
||||||
|
app.width, app.height = 50, 12
|
||||||
|
app.details = PRDetails{
|
||||||
|
PullRequest: PullRequest{
|
||||||
|
ID: "pr", RepoWithOwner: "owner/repository", Number: 42,
|
||||||
|
Title: "Title",
|
||||||
|
},
|
||||||
|
BaseRef: "main",
|
||||||
|
Body: strings.Repeat("description row\n", 20) + "LAST DESCRIPTION ROW",
|
||||||
|
Permissions: ViewerPermissions{
|
||||||
|
CanUpdatePR: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app.startPREdit()
|
||||||
|
app.prEditEditors[prEditBodyField].Cursor =
|
||||||
|
len([]rune(app.prEditEditors[prEditBodyField].Text))
|
||||||
|
app.ensurePREditCursorVisible()
|
||||||
|
|
||||||
|
rendered := ansi.Strip(app.View())
|
||||||
|
if !strings.Contains(rendered, "LAST DESCRIPTION ROW") {
|
||||||
|
t.Fatalf("last description row is outside the editor viewport:\n%s", rendered)
|
||||||
|
}
|
||||||
|
if strings.Contains(rendered, "▄███████▄") {
|
||||||
|
t.Fatal("editor displayed the mascot instead of using its full height")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardDiffletRemainsCenteredAtNarrowWidths(t *testing.T) {
|
||||||
|
mascot := (DiffletFrame{Expression: DiffletIdle}).lines()
|
||||||
|
metadata := []string{"author", "branches", "review", "checks"}
|
||||||
|
for width := diffletWidth; width < 20; width++ {
|
||||||
|
rendered := renderDashboardMetadataWithDifflet(metadata, mascot, width)
|
||||||
|
for row, mascotLine := range mascot {
|
||||||
|
mascotText := strings.TrimRight(mascotLine, " ")
|
||||||
|
var mascotRow string
|
||||||
|
for _, line := range rendered {
|
||||||
|
if strings.Contains(line, mascotText) {
|
||||||
|
mascotRow = line
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index := strings.Index(mascotRow, mascotText)
|
||||||
|
left := -1
|
||||||
|
if index >= 0 {
|
||||||
|
left = lipgloss.Width(mascotRow[:index])
|
||||||
|
}
|
||||||
|
if want := max(0, (width-diffletWidth)/2); left != want {
|
||||||
|
t.Fatalf("width %d row %d mascot starts at %d, want %d: %q",
|
||||||
|
width, row, left, want, mascotRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardLoadingDiffletIsCentered(t *testing.T) {
|
||||||
|
app := NewAppWithSettings(
|
||||||
|
&recordingService{}, "owner", "repository", false, 10, 10,
|
||||||
|
AppSettings{Mascot: true},
|
||||||
|
)
|
||||||
|
app.screen = dashboardScreen
|
||||||
|
app.loading = true
|
||||||
|
app.details = PRDetails{PullRequest: PullRequest{
|
||||||
|
RepoWithOwner: "owner/repository", Number: 42, Title: "Title",
|
||||||
|
}}
|
||||||
|
updated, _ := app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||||
|
app = updated.(App)
|
||||||
|
|
||||||
|
rendered := strings.Split(app.View(), "\n")
|
||||||
|
mascotLine := strings.TrimRight((DiffletFrame{Expression: DiffletIdle}).lines()[0], " ")
|
||||||
|
for row, line := range rendered {
|
||||||
|
plain := ansi.Strip(line)
|
||||||
|
index := strings.Index(plain, mascotLine)
|
||||||
|
if index < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if left := lipgloss.Width(plain[:index]); left != (app.width-diffletWidth)/2 {
|
||||||
|
t.Fatalf("loading mascot starts at %d, want %d: %q",
|
||||||
|
left, (app.width-diffletWidth)/2, plain)
|
||||||
|
}
|
||||||
|
if row != len(app.dashboardHeaderLines()) {
|
||||||
|
t.Fatalf("loading mascot begins on row %d, want %d",
|
||||||
|
row, len(app.dashboardHeaderLines()))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Fatal("loading dashboard did not display the mascot")
|
||||||
|
}
|
||||||
|
|
||||||
func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) {
|
func TestDiffletHeaderMeasurementMatchesRenderedHeader(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
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{
|
||||||
|
|||||||
91
tui.go
91
tui.go
@@ -1874,7 +1874,7 @@ func (m App) dashboardViewportHeight() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m App) dashboardMaxScroll() int {
|
func (m App) dashboardMaxScroll() int {
|
||||||
return max(0, len(m.dashboardLines())-m.dashboardViewportHeight())
|
return max(0, len(m.dashboardDisplayLines())-m.dashboardViewportHeight())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m App) detailPaneSize() (int, int) {
|
func (m App) detailPaneSize() (int, int) {
|
||||||
@@ -1924,6 +1924,12 @@ func (m App) View() string {
|
|||||||
if len(mascot) != diffletHeight {
|
if len(mascot) != diffletHeight {
|
||||||
return m.viewContent()
|
return m.viewContent()
|
||||||
}
|
}
|
||||||
|
if m.diffletHiddenForCurrentView() {
|
||||||
|
return m.viewContent()
|
||||||
|
}
|
||||||
|
if m.screen == dashboardScreen {
|
||||||
|
return m.viewDashboardWithLines(m.dashboardLinesWithDifflet(mascot))
|
||||||
|
}
|
||||||
gap := diffletGap
|
gap := diffletGap
|
||||||
headerWidth := diffletHeaderWidth(m.width)
|
headerWidth := diffletHeaderWidth(m.width)
|
||||||
if headerWidth < 1 {
|
if headerWidth < 1 {
|
||||||
@@ -1959,11 +1965,15 @@ func (m App) View() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m App) diffletHeaderLineCount() (int, bool) {
|
func (m App) diffletHiddenForCurrentView() bool {
|
||||||
if m.helpVisible ||
|
return m.helpVisible ||
|
||||||
(m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
|
(m.aiMode != aiNone && m.aiMode != aiDiscussion) ||
|
||||||
(m.writeMode != writeNone && m.writeMode != writeReply && m.writeMode != writePREdit) ||
|
(m.writeMode != writeNone && m.writeMode != writeReply) ||
|
||||||
m.screen == healthScreen {
|
m.screen == healthScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) diffletHeaderLineCount() (int, bool) {
|
||||||
|
if m.diffletHiddenForCurrentView() {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
@@ -1973,9 +1983,6 @@ func (m App) diffletHeaderLineCount() (int, bool) {
|
|||||||
if m.scroll > 0 {
|
if m.scroll > 0 {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
if m.writeMode == writePREdit {
|
|
||||||
return 1, true
|
|
||||||
}
|
|
||||||
return len(m.dashboardHeaderLines()), true
|
return len(m.dashboardHeaderLines()), true
|
||||||
case threadScreen:
|
case threadScreen:
|
||||||
return len(m.threadTopLines()), true
|
return len(m.threadTopLines()), true
|
||||||
@@ -2563,7 +2570,10 @@ func groupedPRRows(prs []PullRequest, selected int) ([]prListRow, int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m App) viewDashboard() string {
|
func (m App) viewDashboard() string {
|
||||||
lines := m.dashboardLines()
|
return m.viewDashboardWithLines(m.dashboardLines())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) viewDashboardWithLines(lines []string) string {
|
||||||
viewportHeight := m.dashboardViewportHeight()
|
viewportHeight := m.dashboardViewportHeight()
|
||||||
maxScroll := max(0, len(lines)-viewportHeight)
|
maxScroll := max(0, len(lines)-viewportHeight)
|
||||||
scroll := min(m.scroll, maxScroll)
|
scroll := min(m.scroll, maxScroll)
|
||||||
@@ -2594,6 +2604,69 @@ func (m App) viewDashboard() string {
|
|||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m App) dashboardDisplayLines() []string {
|
||||||
|
mascot := m.difflet.frameLines()
|
||||||
|
if len(mascot) == diffletHeight && !m.diffletHiddenForCurrentView() {
|
||||||
|
return m.dashboardLinesWithDifflet(mascot)
|
||||||
|
}
|
||||||
|
return m.dashboardLines()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m App) dashboardLinesWithDifflet(mascot []string) []string {
|
||||||
|
lines := m.dashboardLines()
|
||||||
|
headerLines := len(m.dashboardHeaderLines())
|
||||||
|
metadataStart := headerLines + 1
|
||||||
|
if headerLines >= len(lines) ||
|
||||||
|
strings.TrimSpace(ansi.Strip(lines[headerLines])) != "" {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
if len(lines) < metadataStart+diffletHeight {
|
||||||
|
result := append([]string(nil), lines[:headerLines]...)
|
||||||
|
result = append(result, centeredDiffletLines(mascot, m.width)...)
|
||||||
|
return append(result, lines[metadataStart:]...)
|
||||||
|
}
|
||||||
|
band := renderDashboardMetadataWithDifflet(
|
||||||
|
lines[metadataStart:metadataStart+diffletHeight],
|
||||||
|
mascot,
|
||||||
|
m.width,
|
||||||
|
)
|
||||||
|
result := append([]string(nil), lines[:headerLines]...)
|
||||||
|
result = append(result, band...)
|
||||||
|
return append(result, lines[metadataStart+diffletHeight:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDashboardMetadataWithDifflet(metadata, mascot []string, width int) []string {
|
||||||
|
mascotLeft := max(0, (width-diffletWidth)/2)
|
||||||
|
if width <= diffletWidth || mascotLeft < diffletGap {
|
||||||
|
centered := centeredDiffletLines(mascot, width)
|
||||||
|
return append(centered, metadata...)
|
||||||
|
}
|
||||||
|
metadataWidth := max(1, mascotLeft-diffletGap)
|
||||||
|
height := max(len(metadata), len(mascot))
|
||||||
|
rendered := make([]string, 0, height)
|
||||||
|
for row := range height {
|
||||||
|
left, right := "", ""
|
||||||
|
if row < len(metadata) {
|
||||||
|
left = ansi.Truncate(metadata[row], metadataWidth, "…")
|
||||||
|
}
|
||||||
|
if row < len(mascot) {
|
||||||
|
right = mascot[row]
|
||||||
|
}
|
||||||
|
rendered = append(rendered, pad(left, mascotLeft)+right)
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
func centeredDiffletLines(mascot []string, width int) []string {
|
||||||
|
centered := make([]string, 0, len(mascot))
|
||||||
|
for _, line := range mascot {
|
||||||
|
centered = append(centered, lipgloss.PlaceHorizontal(
|
||||||
|
width, lipgloss.Center, line,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return centered
|
||||||
|
}
|
||||||
|
|
||||||
func (m App) viewHealth() string {
|
func (m App) viewHealth() string {
|
||||||
lines := m.healthLines()
|
lines := m.healthLines()
|
||||||
viewportHeight := m.healthViewportHeight()
|
viewportHeight := m.healthViewportHeight()
|
||||||
|
|||||||
@@ -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