Add resolve and reply support
This commit is contained in:
362
tui.go
362
tui.go
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"slices"
|
||||
@@ -31,6 +32,30 @@ const (
|
||||
|
||||
type tickMsg time.Time
|
||||
type pathTickMsg time.Time
|
||||
|
||||
type writeMode int
|
||||
|
||||
const (
|
||||
writeNone writeMode = iota
|
||||
writeReply
|
||||
writeReplyConfirm
|
||||
writeReplyBusy
|
||||
writeResolveConfirm
|
||||
writeResolveBusy
|
||||
)
|
||||
|
||||
type threadResolvedMsg struct {
|
||||
threadID string
|
||||
thread ReviewThread
|
||||
err error
|
||||
}
|
||||
|
||||
type threadRepliedMsg struct {
|
||||
threadID string
|
||||
comment ReviewComment
|
||||
err error
|
||||
}
|
||||
|
||||
type prsLoadedMsg struct {
|
||||
prs []PullRequest
|
||||
err error
|
||||
@@ -71,6 +96,10 @@ type App struct {
|
||||
searchOrigin int
|
||||
helpVisible bool
|
||||
helpScroll int
|
||||
writeMode writeMode
|
||||
writeThreadID string
|
||||
replyDraft string
|
||||
resolveTarget bool
|
||||
|
||||
foldResolved bool
|
||||
threadListWidthPercent int
|
||||
@@ -220,6 +249,134 @@ func (m App) loadLiveDetails(pr PullRequest) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *App) startReply() {
|
||||
thread := m.selectedThread()
|
||||
if reason := m.writeActionUnavailable("reply", thread); reason != "" {
|
||||
m.err = errors.New(reason)
|
||||
return
|
||||
}
|
||||
m.writeMode, m.writeThreadID, m.replyDraft, m.err = writeReply, thread.ID, "", nil
|
||||
m.folded[thread.ID] = false
|
||||
m.focus = threadDetailPane
|
||||
m.scroll = m.detailMaxScroll()
|
||||
}
|
||||
|
||||
func (m *App) startResolveToggle() {
|
||||
thread := m.selectedThread()
|
||||
if reason := m.writeActionUnavailable("resolve", thread); reason != "" {
|
||||
m.err = errors.New(reason)
|
||||
return
|
||||
}
|
||||
m.writeMode, m.writeThreadID, m.resolveTarget, m.err =
|
||||
writeResolveConfirm, thread.ID, !thread.IsResolved, nil
|
||||
}
|
||||
|
||||
func (m App) writeActionUnavailable(action string, thread *ReviewThread) string {
|
||||
if m.loading {
|
||||
return "write action unavailable while PR data is refreshing"
|
||||
}
|
||||
if m.details.FromCache {
|
||||
return "write action unavailable from an offline cached snapshot"
|
||||
}
|
||||
if _, ok := m.service.(GitHubWriteService); !ok {
|
||||
return "configured GitHub service does not support write actions"
|
||||
}
|
||||
if thread == nil {
|
||||
return "select a review thread first"
|
||||
}
|
||||
switch action {
|
||||
case "reply":
|
||||
if !thread.ViewerCanReply {
|
||||
return "GitHub did not grant reply permission for this thread"
|
||||
}
|
||||
case "resolve":
|
||||
if thread.IsResolved && !thread.ViewerCanUnresolve {
|
||||
return "GitHub did not grant unresolve permission for this thread"
|
||||
}
|
||||
if !thread.IsResolved && !thread.ViewerCanResolve {
|
||||
return "GitHub did not grant resolve permission for this thread"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
k := key.String()
|
||||
if k == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
switch m.writeMode {
|
||||
case writeReply:
|
||||
switch k {
|
||||
case "esc":
|
||||
m.writeMode, m.replyDraft, m.writeThreadID = writeNone, "", ""
|
||||
m.scroll = min(m.scroll, m.detailMaxScroll())
|
||||
case "ctrl+s":
|
||||
if strings.TrimSpace(m.replyDraft) == "" {
|
||||
m.err = errors.New("reply cannot be empty")
|
||||
} else {
|
||||
m.writeMode, m.err = writeReplyConfirm, nil
|
||||
}
|
||||
case "enter":
|
||||
m.replyDraft += "\n"
|
||||
case "backspace":
|
||||
runes := []rune(m.replyDraft)
|
||||
if len(runes) > 0 {
|
||||
m.replyDraft = string(runes[:len(runes)-1])
|
||||
}
|
||||
m.err = nil
|
||||
default:
|
||||
if key.Type == tea.KeyRunes || key.Type == tea.KeySpace {
|
||||
m.replyDraft += string(key.Runes)
|
||||
m.err = nil
|
||||
}
|
||||
}
|
||||
if m.writeMode == writeReply {
|
||||
m.scroll = m.detailMaxScroll()
|
||||
}
|
||||
case writeReplyConfirm:
|
||||
switch k {
|
||||
case "y":
|
||||
m.writeMode = writeReplyBusy
|
||||
return m, m.submitReply()
|
||||
case "n", "esc":
|
||||
m.writeMode = writeReply
|
||||
m.scroll = m.detailMaxScroll()
|
||||
}
|
||||
case writeResolveConfirm:
|
||||
switch k {
|
||||
case "y":
|
||||
m.writeMode = writeResolveBusy
|
||||
return m, m.submitResolution()
|
||||
case "n", "esc":
|
||||
m.writeMode, m.writeThreadID = writeNone, ""
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m App) submitReply() tea.Cmd {
|
||||
writer := m.service.(GitHubWriteService)
|
||||
threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n")
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
comment, err := writer.ReplyToThread(ctx, threadID, body)
|
||||
return threadRepliedMsg{threadID: threadID, comment: comment, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) submitResolution() tea.Cmd {
|
||||
writer := m.service.(GitHubWriteService)
|
||||
threadID, resolved := m.writeThreadID, m.resolveTarget
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
thread, err := writer.SetThreadResolved(ctx, threadID, resolved)
|
||||
return threadResolvedMsg{threadID: threadID, thread: thread, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
@@ -328,6 +485,53 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
m.lastRefresh = time.Now()
|
||||
}
|
||||
case threadResolvedMsg:
|
||||
m.writeMode = writeNone
|
||||
if msg.err != nil {
|
||||
m.err = fmt.Errorf("change thread resolution: %w", msg.err)
|
||||
return m, nil
|
||||
}
|
||||
selected := ""
|
||||
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
|
||||
selected = m.details.Threads[m.threadIndex].ID
|
||||
}
|
||||
for index := range m.details.Threads {
|
||||
if m.details.Threads[index].ID != msg.threadID {
|
||||
continue
|
||||
}
|
||||
thread := &m.details.Threads[index]
|
||||
thread.IsResolved = msg.thread.IsResolved
|
||||
thread.IsOutdated = msg.thread.IsOutdated
|
||||
thread.ViewerCanResolve = msg.thread.ViewerCanResolve
|
||||
thread.ViewerCanUnresolve = msg.thread.ViewerCanUnresolve
|
||||
thread.ViewerCanReply = msg.thread.ViewerCanReply
|
||||
m.folded[thread.ID] = thread.IsResolved && m.foldResolved
|
||||
break
|
||||
}
|
||||
sortReviewThreads(m.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
|
||||
m.threadIndex = indexThread(m.details.Threads, selected)
|
||||
m.writeThreadID = ""
|
||||
m.err = nil
|
||||
m.lastRefresh = time.Now()
|
||||
case threadRepliedMsg:
|
||||
if msg.err != nil {
|
||||
m.writeMode = writeReply
|
||||
m.err = fmt.Errorf("reply to review thread: %w", msg.err)
|
||||
m.scroll = m.detailMaxScroll()
|
||||
return m, nil
|
||||
}
|
||||
m.writeMode = writeNone
|
||||
for index := range m.details.Threads {
|
||||
if m.details.Threads[index].ID == msg.threadID {
|
||||
m.details.Threads[index].Comments = append(m.details.Threads[index].Comments, msg.comment)
|
||||
m.threadIndex = index
|
||||
m.markCurrentThreadRead()
|
||||
break
|
||||
}
|
||||
}
|
||||
m.replyDraft, m.writeThreadID = "", ""
|
||||
m.err = nil
|
||||
m.lastRefresh = time.Now()
|
||||
}
|
||||
|
||||
key, ok := msg.(tea.KeyMsg)
|
||||
@@ -335,6 +539,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
k := key.String()
|
||||
if m.writeMode != writeNone {
|
||||
return m.updateWriteInput(key)
|
||||
}
|
||||
if m.helpVisible {
|
||||
switch k {
|
||||
case "ctrl+c":
|
||||
@@ -409,6 +616,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
switch k {
|
||||
case "c":
|
||||
if m.screen == threadScreen {
|
||||
m.startReply()
|
||||
}
|
||||
case "R":
|
||||
if m.screen == threadScreen {
|
||||
m.startResolveToggle()
|
||||
}
|
||||
case "F":
|
||||
if m.screen == threadScreen {
|
||||
m.searchQuery = ""
|
||||
@@ -933,6 +1148,9 @@ func (m App) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading…"
|
||||
}
|
||||
if m.writeMode != writeNone && m.writeMode != writeReply {
|
||||
return m.viewWritePopup()
|
||||
}
|
||||
if m.helpVisible {
|
||||
return m.viewHelp()
|
||||
}
|
||||
@@ -945,6 +1163,73 @@ func (m App) View() string {
|
||||
return m.viewThreads()
|
||||
}
|
||||
|
||||
func (m App) viewWritePopup() string {
|
||||
width := max(20, min(76, m.width-4))
|
||||
thread := m.threadByID(m.writeThreadID)
|
||||
location := "selected thread"
|
||||
if thread != nil {
|
||||
location = fmt.Sprintf("%s:%d", thread.Path, thread.Line)
|
||||
}
|
||||
var lines []string
|
||||
switch m.writeMode {
|
||||
case writeReply:
|
||||
lines = []string{
|
||||
titleStyle.Render("Reply to " + location),
|
||||
"",
|
||||
}
|
||||
draft := m.replyDraft + "█"
|
||||
for _, sourceLine := range strings.Split(draft, "\n") {
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, width-2, ""), width-2, false)
|
||||
lines = append(lines, strings.Split(wrapped, "\n")...)
|
||||
}
|
||||
if m.err != nil {
|
||||
lines = append(lines, "", badStyle.Render(m.err.Error()))
|
||||
}
|
||||
lines = append(lines, "", dimStyle.Render("enter newline • ctrl-s review • esc cancel"))
|
||||
case writeReplyConfirm:
|
||||
lines = []string{titleStyle.Render("Submit this reply to " + location + "?"), ""}
|
||||
lines = append(lines, renderCommentMarkdown(m.replyDraft, width-2)...)
|
||||
lines = append(lines, "", warnStyle.Render("y submit • n/esc continue editing"))
|
||||
case writeReplyBusy:
|
||||
lines = []string{titleStyle.Render("Submitting reply…"), "", dimStyle.Render(location)}
|
||||
case writeResolveConfirm:
|
||||
action := "resolve"
|
||||
if !m.resolveTarget {
|
||||
action = "unresolve"
|
||||
}
|
||||
lines = []string{
|
||||
titleStyle.Render(strings.ToUpper(action[:1]) + action[1:] + " " + location + "?"),
|
||||
"",
|
||||
warnStyle.Render("y confirm • n/esc cancel"),
|
||||
}
|
||||
case writeResolveBusy:
|
||||
action := "Resolving"
|
||||
if !m.resolveTarget {
|
||||
action = "Unresolving"
|
||||
}
|
||||
lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)}
|
||||
}
|
||||
maxLines := max(3, m.height-4)
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[len(lines)-maxLines:]
|
||||
}
|
||||
for index := range lines {
|
||||
lines[index] = ansi.Truncate(lines[index], width, "")
|
||||
}
|
||||
popup := paneStyle(true).Width(width).Render(strings.Join(lines, "\n"))
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, popup)
|
||||
}
|
||||
|
||||
func (m App) threadByID(id string) *ReviewThread {
|
||||
for index := range m.details.Threads {
|
||||
if m.details.Threads[index].ID == id {
|
||||
thread := m.details.Threads[index]
|
||||
return &thread
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type helpBinding struct {
|
||||
key string
|
||||
action string
|
||||
@@ -999,18 +1284,13 @@ func (m App) helpBindings() []helpBinding {
|
||||
{"/", "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
|
||||
{"F", "Clear the active thread filter and show every thread"},
|
||||
{"n / N", "Next / previous new update"},
|
||||
{"c", "Compose a reply to the selected thread"},
|
||||
{"R", "Resolve or unresolve the selected thread"},
|
||||
{"d", "Open pull request dashboard"},
|
||||
{"enter / za", "Fold / expand thread"},
|
||||
{"b / esc", backAction},
|
||||
{"r", "Refresh now"},
|
||||
}
|
||||
for _, item := range writeCapabilities(m.details, m.selectedThread()) {
|
||||
state := item.reason
|
||||
if item.authorized {
|
||||
state = "ready; " + item.reason
|
||||
}
|
||||
bindings = append(bindings, helpBinding{"write", item.name + ": " + state})
|
||||
}
|
||||
bindings = append(bindings,
|
||||
helpBinding{"?", "Close this help"},
|
||||
helpBinding{"q / ctrl-c", "Quit"},
|
||||
@@ -1477,7 +1757,7 @@ func applicableRulesetCount(rulesets []Ruleset) int {
|
||||
|
||||
type writeCapability struct {
|
||||
name, reason string
|
||||
authorized bool
|
||||
enabled bool
|
||||
}
|
||||
|
||||
func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||||
@@ -1491,32 +1771,41 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||||
}
|
||||
threadReason := "select a review thread"
|
||||
canReply, canResolve := false, false
|
||||
resolveName := "resolve thread"
|
||||
if thread != nil {
|
||||
canReply = thread.ViewerCanReply
|
||||
canResolve = thread.ViewerCanResolve || thread.ViewerCanUnresolve
|
||||
if thread.IsResolved {
|
||||
resolveName = "unresolve thread"
|
||||
canResolve = thread.ViewerCanUnresolve
|
||||
} else {
|
||||
canResolve = thread.ViewerCanResolve
|
||||
}
|
||||
threadReason = "GitHub did not grant permission for this thread"
|
||||
}
|
||||
return []writeCapability{
|
||||
capability("reply", canReply, threadReason),
|
||||
capability("resolve / unresolve", canResolve, threadReason),
|
||||
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission"),
|
||||
capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission"),
|
||||
capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR"),
|
||||
capability("reply", canReply, threadReason, true),
|
||||
capability(resolveName, canResolve, threadReason, true),
|
||||
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
|
||||
capability("update branch", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", false),
|
||||
capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR", false),
|
||||
}
|
||||
}
|
||||
|
||||
func capability(name string, allowed bool, denied string) writeCapability {
|
||||
if allowed {
|
||||
return writeCapability{name: name, authorized: true, reason: "authorized; write UI not implemented"}
|
||||
func capability(name string, allowed bool, denied string, implemented bool) writeCapability {
|
||||
if !allowed {
|
||||
return writeCapability{name: name, reason: denied}
|
||||
}
|
||||
return writeCapability{name: name, reason: denied}
|
||||
if !implemented {
|
||||
return writeCapability{name: name, reason: "write action not implemented yet"}
|
||||
}
|
||||
return writeCapability{name: name, enabled: true, reason: "available"}
|
||||
}
|
||||
|
||||
func writeCapabilityLines(pr PRDetails, thread *ReviewThread) []string {
|
||||
var lines []string
|
||||
for _, item := range writeCapabilities(pr, thread) {
|
||||
marker := badStyle.Render("disabled")
|
||||
if item.authorized {
|
||||
if item.enabled {
|
||||
marker = okStyle.Render("ready")
|
||||
}
|
||||
lines = append(lines, pad(item.name, 21)+" "+marker+" "+dimStyle.Render(item.reason))
|
||||
@@ -1585,9 +1874,11 @@ func (m App) viewThreads() string {
|
||||
right := m.threadDetail(rightWidth, contentHeight)
|
||||
body = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
|
||||
}
|
||||
help := "? keys • h/l focus • j/k move/scroll • n/N new • d dashboard • b back • q quit"
|
||||
help := "? keys • h/l focus • j/k move/scroll • c reply • R resolve • d dashboard • b back • q quit"
|
||||
if m.searching {
|
||||
help = "path words • status:open • author:name • updated:true • enter apply • esc cancel"
|
||||
} else if m.writeMode == writeReply {
|
||||
help = "reply inline • enter newline • ctrl-s review • esc cancel"
|
||||
}
|
||||
return m.frame(append(top, body), help)
|
||||
}
|
||||
@@ -1769,6 +2060,37 @@ func (m App) detailLines(width int) []detailLine {
|
||||
lines = append(lines, detailLine{}, detailLine{text: warnStyle.Render("Showing the first 100 comments in this thread.")})
|
||||
}
|
||||
}
|
||||
if m.writeMode == writeReply && m.writeThreadID == thread.ID {
|
||||
lines = append(lines, m.inlineReplyLines(width)...)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func (m App) inlineReplyLines(width int) []detailLine {
|
||||
rail := warnStyle.Render("│ ")
|
||||
lines := []detailLine{
|
||||
{},
|
||||
{rail: rail, anchor: "reply:header", text: titleStyle.Render("Reply draft")},
|
||||
}
|
||||
draft := m.replyDraft + "█"
|
||||
textWidth := max(1, width-4)
|
||||
lineIndex := 0
|
||||
for _, sourceLine := range strings.Split(draft, "\n") {
|
||||
wrapped := ansi.Hardwrap(ansi.Wordwrap(sourceLine, textWidth, ""), textWidth, false)
|
||||
for _, part := range strings.Split(wrapped, "\n") {
|
||||
lines = append(lines, detailLine{
|
||||
rail: rail, anchor: fmt.Sprintf("reply:body:%d", lineIndex), text: part,
|
||||
})
|
||||
lineIndex++
|
||||
}
|
||||
}
|
||||
if m.err != nil {
|
||||
lines = append(lines, detailLine{rail: rail, text: badStyle.Render(m.err.Error())})
|
||||
}
|
||||
lines = append(lines, detailLine{
|
||||
rail: rail,
|
||||
text: dimStyle.Render("enter newline • ctrl-s review • esc cancel"),
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user