feat: queued mutations while reloading or offline
This commit is contained in:
213
tui.go
213
tui.go
@@ -52,6 +52,7 @@ const (
|
||||
writeAutoMergeBusy
|
||||
writeMergeNowConfirm
|
||||
writeMergeNowBusy
|
||||
writeQueueBlocked
|
||||
)
|
||||
|
||||
type threadResolvedMsg struct {
|
||||
@@ -209,6 +210,12 @@ type App struct {
|
||||
aiSpinner int
|
||||
aiEvents <-chan tea.Msg
|
||||
difflet diffletModel
|
||||
mutations *mutationQueueStore
|
||||
mutationReplayBusy bool
|
||||
blockedMutation *mutationOperation
|
||||
blockedMutationDetails PRDetails
|
||||
blockedMutationChoice int
|
||||
editingMutationID string
|
||||
}
|
||||
|
||||
type AppSettings struct {
|
||||
@@ -225,6 +232,7 @@ type AppSettings struct {
|
||||
KeyBindings KeyBindings
|
||||
ReadState *readStateStore
|
||||
Drafts *draftStore
|
||||
Mutations *mutationQueueStore
|
||||
AI *AIController
|
||||
AIStore *AIStore
|
||||
Mascot bool
|
||||
@@ -281,6 +289,7 @@ func NewAppWithSettings(
|
||||
keybindings: settings.KeyBindings,
|
||||
readState: state,
|
||||
drafts: settings.Drafts,
|
||||
mutations: settings.Mutations,
|
||||
knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
|
||||
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
|
||||
unreadComments: make(map[string]bool), newThreads: make(map[string]bool),
|
||||
@@ -567,8 +576,11 @@ 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 m.details.FromCache && m.mutations == nil {
|
||||
return "offline mutation queue is unavailable"
|
||||
}
|
||||
if m.mutations != nil && m.mutations.loadErr != nil {
|
||||
return "mutation queue is unavailable: " + m.mutations.loadErr.Error()
|
||||
}
|
||||
if _, ok := m.service.(GitHubWriteService); !ok {
|
||||
return "configured GitHub service does not support write actions"
|
||||
@@ -596,6 +608,21 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
k := m.keybindings.canonicalWriteKey(key.String())
|
||||
switch m.writeMode {
|
||||
case writeQueueBlocked:
|
||||
choices := blockedMutationChoices(*m.blockedMutation)
|
||||
switch {
|
||||
case keyMatches(key.String(), m.keybindings.Navigation.Down):
|
||||
m.blockedMutationChoice = min(m.blockedMutationChoice+1, len(choices)-1)
|
||||
case keyMatches(key.String(), m.keybindings.Navigation.Up):
|
||||
m.blockedMutationChoice = max(0, m.blockedMutationChoice-1)
|
||||
case keyMatches(key.String(), m.keybindings.General.Confirm),
|
||||
keyMatches(key.String(), m.keybindings.Views.Open):
|
||||
return m, m.resolveBlockedMutation(m.blockedMutationChoice)
|
||||
case keyMatches(key.String(), m.keybindings.Input.Cancel),
|
||||
keyMatches(key.String(), m.keybindings.General.Reject):
|
||||
m.writeMode, m.blockedMutation, m.err = writeNone, nil, nil
|
||||
}
|
||||
return m, nil
|
||||
case writeReply:
|
||||
switch k {
|
||||
case "esc":
|
||||
@@ -663,6 +690,19 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m App) submitReply() tea.Cmd {
|
||||
if m.mutations != nil {
|
||||
thread := m.threadByID(m.writeThreadID)
|
||||
operation := mutationOperation{
|
||||
Kind: mutationReply, Owner: m.details.Owner, Repository: m.details.Repository,
|
||||
Number: m.details.Number, PRID: m.details.ID, ThreadID: m.writeThreadID,
|
||||
Body: strings.TrimRight(m.replyDraft, "\n"), Viewer: m.details.ViewerLogin,
|
||||
Permissions: m.details.Permissions,
|
||||
}
|
||||
if thread != nil {
|
||||
operation.ThreadCanReply = thread.ViewerCanReply
|
||||
}
|
||||
return m.enqueueMutation(operation)
|
||||
}
|
||||
writer := m.service.(GitHubWriteService)
|
||||
threadID, body := m.writeThreadID, strings.TrimRight(m.replyDraft, "\n")
|
||||
return func() tea.Msg {
|
||||
@@ -683,6 +723,20 @@ func (m App) submitResolution() tea.Cmd {
|
||||
return threadResolvedMsg{threadID: threadID, thread: thread, err: err}
|
||||
}
|
||||
}
|
||||
if m.mutations != nil {
|
||||
thread := m.threadByID(m.writeThreadID)
|
||||
operation := mutationOperation{
|
||||
Kind: mutationResolution, Owner: m.details.Owner, Repository: m.details.Repository,
|
||||
Number: m.details.Number, PRID: m.details.ID, ThreadID: m.writeThreadID,
|
||||
Resolved: m.resolveTarget, Viewer: m.details.ViewerLogin,
|
||||
Permissions: m.details.Permissions,
|
||||
}
|
||||
if thread != nil {
|
||||
operation.ThreadCanResolve = thread.ViewerCanResolve
|
||||
operation.ThreadCanUnresolve = thread.ViewerCanUnresolve
|
||||
}
|
||||
return m.enqueueMutation(operation)
|
||||
}
|
||||
writer := m.service.(GitHubWriteService)
|
||||
threadID, resolved := m.writeThreadID, m.resolveTarget
|
||||
return func() tea.Msg {
|
||||
@@ -781,7 +835,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if len(m.prs) > 0 && m.prIndex < len(m.prs) {
|
||||
selected = m.prs[m.prIndex].ID
|
||||
}
|
||||
m.prs = msg.prs
|
||||
m.prs = m.projectQueuedPullRequests(msg.prs)
|
||||
sort.SliceStable(m.prs, func(i, j int) bool {
|
||||
left, right := strings.ToLower(m.prs[i].RepoWithOwner), strings.ToLower(m.prs[j].RepoWithOwner)
|
||||
if left != right {
|
||||
@@ -797,9 +851,9 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.lastRefresh = time.Now()
|
||||
}
|
||||
if len(m.prs) == 0 {
|
||||
return m, m.difflet.setState(diffletSleeping)
|
||||
return m, tea.Batch(m.difflet.setState(diffletSleeping), m.startMutationReplay())
|
||||
}
|
||||
return m, m.difflet.setState(diffletIdle)
|
||||
return m, tea.Batch(m.difflet.setState(diffletIdle), m.startMutationReplay())
|
||||
case detailsLoadedMsg:
|
||||
if m.requests != nil && !m.requests.current(msg.requestID) {
|
||||
return m, nil
|
||||
@@ -844,6 +898,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
m.trackThreadUpdates(msg.details)
|
||||
msg.details = m.projectQueuedMutations(msg.details)
|
||||
sortReviewThreads(msg.details.Threads, m.threadStatusOrder, m.threadWithinStatus)
|
||||
m.details = msg.details
|
||||
m.err = nil
|
||||
@@ -883,9 +938,12 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if _, ok := m.service.(GitHubEnrichmentService); ok {
|
||||
m.secondaryLoading = true
|
||||
diffletCommand = m.difflet.setState(diffletLoading)
|
||||
return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand)
|
||||
return m, tea.Batch(m.loadDetailsEnrichment(msg.details), diffletCommand, m.startMutationReplay())
|
||||
}
|
||||
}
|
||||
if !msg.cached {
|
||||
return m, tea.Batch(diffletCommand, m.startMutationReplay())
|
||||
}
|
||||
return m, diffletCommand
|
||||
case detailsEnrichedMsg:
|
||||
if m.requests != nil && !m.requests.current(msg.requestID) {
|
||||
@@ -923,6 +981,73 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.err != nil {
|
||||
m.recordHealth("draft persistence", healthWarning, msg.err.Error())
|
||||
}
|
||||
case mutationQueuedMsg:
|
||||
if msg.err != nil {
|
||||
m.err = fmt.Errorf("queue mutation: %w", msg.err)
|
||||
m.recordHealth("mutation queue", healthError, msg.err.Error())
|
||||
switch msg.operation.Kind {
|
||||
case mutationReply:
|
||||
m.writeMode = writeReply
|
||||
case mutationResolution:
|
||||
m.writeMode = writeNone
|
||||
case mutationPREdit:
|
||||
m.writeMode = writePREdit
|
||||
}
|
||||
return m, m.difflet.setState(diffletRecoverableError)
|
||||
}
|
||||
m.details = m.projectQueuedMutations(m.details)
|
||||
m.err = nil
|
||||
switch msg.operation.Kind {
|
||||
case mutationReply:
|
||||
_ = m.drafts.delete(replyDraftKey(
|
||||
msg.operation.Owner, msg.operation.Repository, msg.operation.Number, msg.operation.ThreadID,
|
||||
))
|
||||
m.replyDraft, m.replyEditor, m.writeThreadID = "", textEditor{}, ""
|
||||
case mutationResolution:
|
||||
m.writeThreadID = ""
|
||||
case mutationPREdit:
|
||||
_ = m.drafts.delete(prMetadataDraftKey(
|
||||
msg.operation.Owner, msg.operation.Repository, msg.operation.Number,
|
||||
))
|
||||
m.clearPREdit()
|
||||
}
|
||||
m.writeMode = writeNone
|
||||
if m.details.FromCache {
|
||||
return m, m.difflet.setState(diffletIdle)
|
||||
}
|
||||
return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletLoading))
|
||||
case mutationReplayMsg:
|
||||
m.mutationReplayBusy = false
|
||||
if msg.state == mutationReplayWaiting {
|
||||
if msg.err != nil {
|
||||
m.recordHealth("mutation replay", healthWarning, msg.err.Error())
|
||||
}
|
||||
return m, m.difflet.setState(diffletRecoverableError)
|
||||
}
|
||||
if msg.state == mutationReplayBlocked {
|
||||
m.blockedMutation = &msg.operation
|
||||
m.blockedMutationDetails = msg.details
|
||||
m.blockedMutationChoice = 0
|
||||
m.writeMode = writeQueueBlocked
|
||||
m.err = errors.New(msg.reason)
|
||||
m.recordHealth("mutation replay", healthWarning, msg.reason)
|
||||
return m, m.difflet.setState(diffletRecoverableError)
|
||||
}
|
||||
if err := m.mutations.remove(msg.operation.ID); err != nil {
|
||||
m.err = fmt.Errorf("finish queued mutation: %w", err)
|
||||
m.recordHealth("mutation queue", healthError, err.Error())
|
||||
return m, m.difflet.setState(diffletRecoverableError)
|
||||
}
|
||||
m.err = nil
|
||||
if m.details.Owner == msg.operation.Owner &&
|
||||
m.details.Repository == msg.operation.Repository && m.details.Number == msg.operation.Number {
|
||||
m.loading = true
|
||||
return m, tea.Batch(
|
||||
m.loadDetails(m.details.PullRequest, false),
|
||||
m.difflet.setState(diffletSuccess),
|
||||
)
|
||||
}
|
||||
return m, tea.Batch(m.startMutationReplay(), m.difflet.setState(diffletSuccess))
|
||||
case branchesLoadedMsg:
|
||||
if m.writeMode != writePREdit ||
|
||||
msg.owner != m.details.Owner || msg.repo != m.details.Repository {
|
||||
@@ -2283,6 +2408,27 @@ func (m App) viewWritePopup() string {
|
||||
lines = m.prEditConfirmationLines(width - 2)
|
||||
case writePREditBusy:
|
||||
lines = []string{titleStyle.Render("Updating pull request…")}
|
||||
case writeQueueBlocked:
|
||||
operation := *m.blockedMutation
|
||||
lines = []string{
|
||||
titleStyle.Render("Queued mutation needs attention"), "",
|
||||
warnStyle.Render(operation.LastError), "",
|
||||
}
|
||||
for index, choice := range blockedMutationChoices(operation) {
|
||||
prefix := " "
|
||||
if index == m.blockedMutationChoice {
|
||||
prefix = "› "
|
||||
choice = activeStyle.Render(choice)
|
||||
}
|
||||
lines = append(lines, prefix+choice)
|
||||
}
|
||||
lines = append(lines, "", dimStyle.Render(fmt.Sprintf(
|
||||
"%s/%s select • %s apply • %s dismiss",
|
||||
primaryKeyLabel(m.keybindings.Navigation.Down),
|
||||
primaryKeyLabel(m.keybindings.Navigation.Up),
|
||||
primaryCombinedKeyLabel(m.keybindings.General.Confirm, m.keybindings.Views.Open),
|
||||
primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel),
|
||||
)))
|
||||
}
|
||||
maxLines := max(3, m.height-4)
|
||||
if len(lines) > maxLines {
|
||||
@@ -2623,6 +2769,9 @@ func (m App) viewPRs() string {
|
||||
if pr.FromCache {
|
||||
draft += " CACHED"
|
||||
}
|
||||
if pr.Pending {
|
||||
draft += " PENDING"
|
||||
}
|
||||
titleWidth := max(10, m.width-36)
|
||||
line := fmt.Sprintf(" #%-5d %-*s %2d threads%s", pr.Number, titleWidth, truncate(pr.Title, titleWidth), pr.ReviewCount, draft)
|
||||
if row.prIndex == m.prIndex {
|
||||
@@ -2914,6 +3063,26 @@ func (m App) healthLines() []string {
|
||||
}
|
||||
components = append(components, component)
|
||||
}
|
||||
if m.mutations != nil {
|
||||
count := m.mutations.count()
|
||||
component := HealthComponent{
|
||||
Name: "mutation queue", Level: healthOK,
|
||||
Summary: fmt.Sprintf("%d queued mutation(s)", count), Detail: m.mutations.path,
|
||||
}
|
||||
if m.mutations.loadErr != nil {
|
||||
component.Level = healthError
|
||||
component.Summary = "mutation queue could not be loaded safely"
|
||||
component.Detail = m.mutations.loadErr.Error()
|
||||
} else if operation, ok := m.mutations.front(); ok && operation.Blocked {
|
||||
component.Level = healthWarning
|
||||
component.Summary = "queued replay is paused"
|
||||
component.Detail = operation.LastError
|
||||
} else if count > 0 {
|
||||
component.Level = healthInfo
|
||||
component.Summary = fmt.Sprintf("%d mutation(s) waiting for ordered replay", count)
|
||||
}
|
||||
components = append(components, component)
|
||||
}
|
||||
if m.ai == nil || !m.ai.config.Enabled {
|
||||
components = append(components, HealthComponent{
|
||||
Name: "AI integration", Level: healthInfo,
|
||||
@@ -3058,6 +3227,9 @@ func (m App) dashboardHeaderLines() []string {
|
||||
"OFFLINE CACHE • saved "+pr.CachedAt.Local().Format("2006-01-02 15:04"),
|
||||
))
|
||||
}
|
||||
if count := m.mutations.count(); count > 0 {
|
||||
lines = append(lines, warnStyle.Render(fmt.Sprintf("%d queued mutation(s) pending", count)))
|
||||
}
|
||||
if len(pr.DataIssues) > 0 {
|
||||
lines = append(lines, warnStyle.Render(fmt.Sprintf(
|
||||
"PARTIAL DATA • %d subsection(s) unavailable; press %s for details",
|
||||
@@ -3391,14 +3563,6 @@ type writeCapability struct {
|
||||
}
|
||||
|
||||
func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||||
if pr.FromCache {
|
||||
reason := "offline cached snapshot"
|
||||
return []writeCapability{
|
||||
{name: "reply", reason: reason}, {name: "resolve", reason: reason},
|
||||
{name: "react", reason: reason}, {name: "update pull request", reason: reason},
|
||||
{name: "auto-merge", reason: reason}, {name: "merge now", reason: reason},
|
||||
}
|
||||
}
|
||||
threadReason := "select a review thread"
|
||||
canReply, canResolve := false, false
|
||||
resolveName := "resolve thread"
|
||||
@@ -3418,14 +3582,23 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability {
|
||||
if pr.Merged || pr.State == "CLOSED" {
|
||||
autoMergeReason = "pull request is already closed"
|
||||
}
|
||||
if pr.FromCache {
|
||||
threadReason = "saved snapshot did not grant this thread permission"
|
||||
autoMergeAllowed = false
|
||||
autoMergeReason = "auto-merge remains online-only"
|
||||
}
|
||||
mergeAllowed := !pr.FromCache && mergeNowStateReason(pr) == ""
|
||||
mergeReason := firstNonEmpty(mergeNowStateReason(pr), "available")
|
||||
if pr.FromCache {
|
||||
mergeReason = "merge remains online-only"
|
||||
}
|
||||
return []writeCapability{
|
||||
capability("reply", canReply, threadReason, true),
|
||||
capability(resolveName, canResolve, threadReason, true),
|
||||
capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false),
|
||||
capability("react", pr.Permissions.CanReact && !pr.FromCache, "reactions remain online-only", false),
|
||||
capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true),
|
||||
capability("auto-merge", autoMergeAllowed, autoMergeReason, true),
|
||||
capability("merge now", mergeNowStateReason(pr) == "",
|
||||
firstNonEmpty(mergeNowStateReason(pr), "available"), true),
|
||||
capability("merge now", mergeAllowed, mergeReason, true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3498,6 +3671,9 @@ func (m App) threadTopLines() []string {
|
||||
" • refreshing live data",
|
||||
))
|
||||
}
|
||||
if count := m.mutations.count(); count > 0 {
|
||||
top = append(top, warnStyle.Render(fmt.Sprintf("%d queued mutation(s) pending", count)))
|
||||
}
|
||||
if pr.ThreadsTruncated {
|
||||
top = append(top, warnStyle.Render("Showing the first 100 review threads."))
|
||||
}
|
||||
@@ -3692,6 +3868,9 @@ func (m App) detailLines(width int) []detailLine {
|
||||
if thread.Origin == reviewOriginLocalAI {
|
||||
status += ", LOCAL AI · LOCAL ONLY"
|
||||
}
|
||||
if thread.Pending {
|
||||
status += ", PENDING"
|
||||
}
|
||||
if m.unreadThreads[thread.ID] {
|
||||
if m.newThreads[thread.ID] {
|
||||
status += ", new thread"
|
||||
|
||||
Reference in New Issue
Block a user