feat: unify interactive operation coordination
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
)
|
||||
|
||||
type operationError struct {
|
||||
status app.OperationStatus
|
||||
detail string
|
||||
}
|
||||
|
||||
// operationCoordinator owns the complete interactive operation lifecycle. The
|
||||
// root Model delegates stream consumption, progress, confirmation, current
|
||||
// error, and deferred-exit state here instead of mirroring those states.
|
||||
type operationCoordinator struct {
|
||||
active bool
|
||||
intent app.Intent
|
||||
updates <-chan app.OperationUpdate
|
||||
progress map[string]app.TargetProgressPhase
|
||||
status app.OperationStatus
|
||||
currentError *operationError
|
||||
errorExpanded bool
|
||||
errorOffset int
|
||||
confirmation *app.Confirmation
|
||||
confirmationIntent *app.Intent
|
||||
quitRequested bool
|
||||
}
|
||||
|
||||
type operationProgress struct {
|
||||
update app.OperationUpdate
|
||||
updates <-chan app.OperationUpdate
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newOperationCoordinator() operationCoordinator {
|
||||
return operationCoordinator{progress: make(map[string]app.TargetProgressPhase)}
|
||||
}
|
||||
|
||||
func waitForOperation(updates <-chan app.OperationUpdate) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
update, ok := <-updates
|
||||
if !ok {
|
||||
return operationProgress{updates: updates, closed: true}
|
||||
}
|
||||
return operationProgress{update: update, updates: updates}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) start(application app.InteractiveModule, intent app.Intent) (app.Snapshot, tea.Cmd, bool) {
|
||||
if o.quitRequested || o.active || o.confirmation != nil {
|
||||
o.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current operation to finish before starting another modification"}
|
||||
return application.Snapshot(), nil, false
|
||||
}
|
||||
stream := application.StartOperation(intent)
|
||||
o.active = true
|
||||
o.intent = intent
|
||||
o.updates = stream.Updates
|
||||
o.progress = make(map[string]app.TargetProgressPhase)
|
||||
if intent.TargetID != "" {
|
||||
o.progress[intent.TargetID] = app.TargetProgressRunning
|
||||
}
|
||||
o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
|
||||
return stream.Snapshot, waitForOperation(stream.Updates), true
|
||||
}
|
||||
|
||||
func operationStartingSummary(kind app.IntentKind) string {
|
||||
switch kind {
|
||||
case app.RefreshTargets:
|
||||
return "Checking Target availability and state…"
|
||||
case app.ActivateProfile:
|
||||
return "Changing Active Profile…"
|
||||
default:
|
||||
return "Starting Target mutation…"
|
||||
}
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTargetID string) {
|
||||
status := update.Status()
|
||||
progress := update.Progress()
|
||||
for _, item := range progress {
|
||||
o.progress[item.TargetID] = item.Phase
|
||||
if item.Phase == app.TargetProgressSucceeded {
|
||||
o.resolveTargetError(item.TargetID)
|
||||
}
|
||||
}
|
||||
if status.Summary != "" {
|
||||
o.status = status
|
||||
}
|
||||
if update.IsSucceeded() {
|
||||
o.resolveTargetError(status.TargetID)
|
||||
}
|
||||
if update.IsFailed() {
|
||||
o.currentError = &operationError{status: status, detail: errorDetail(update.Err())}
|
||||
o.errorExpanded = false
|
||||
o.errorOffset = 0
|
||||
firstFailedTargetID = update.FirstFailedTargetID()
|
||||
}
|
||||
if request := update.Confirmation(); request != nil {
|
||||
intent := o.intent
|
||||
o.confirmation = request
|
||||
o.confirmationIntent = &intent
|
||||
}
|
||||
if update.IsTerminal() {
|
||||
o.finish()
|
||||
}
|
||||
return firstFailedTargetID
|
||||
}
|
||||
|
||||
func errorDetail(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) resolveTargetError(targetID string) {
|
||||
if targetID == "" || o.currentError == nil || o.currentError.status.TargetID != targetID {
|
||||
return
|
||||
}
|
||||
o.currentError = nil
|
||||
o.errorExpanded = false
|
||||
o.errorOffset = 0
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) finish() {
|
||||
o.active = false
|
||||
o.intent = app.Intent{}
|
||||
o.updates = nil
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) unexpectedClose() {
|
||||
o.status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation ended without a final result"}
|
||||
o.currentError = &operationError{
|
||||
status: o.status,
|
||||
detail: "The Application operation stream closed before publishing a terminal result.",
|
||||
}
|
||||
o.errorExpanded = false
|
||||
o.errorOffset = 0
|
||||
o.finish()
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) acknowledgeError() {
|
||||
o.currentError = nil
|
||||
o.errorExpanded = false
|
||||
o.errorOffset = 0
|
||||
if o.status.Kind == app.StatusError {
|
||||
o.status = app.OperationStatus{}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) cancelConfirmation() {
|
||||
o.confirmation = nil
|
||||
o.confirmationIntent = nil
|
||||
o.status = app.OperationStatus{}
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) confirmedIntent() (app.Intent, bool) {
|
||||
if o.confirmationIntent == nil {
|
||||
return app.Intent{}, false
|
||||
}
|
||||
intent := *o.confirmationIntent
|
||||
intent.Confirm = true
|
||||
o.confirmation = nil
|
||||
o.confirmationIntent = nil
|
||||
return intent, true
|
||||
}
|
||||
|
||||
func (o operationCoordinator) busy() bool {
|
||||
return o.quitRequested || o.active || o.confirmation != nil
|
||||
}
|
||||
|
||||
func (o operationCoordinator) deferredQuit() tea.Cmd {
|
||||
if o.quitRequested && !o.active {
|
||||
return tea.Quit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) requestQuit() tea.Cmd {
|
||||
o.quitRequested = true
|
||||
if o.active {
|
||||
return nil
|
||||
}
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) fail(err error, summary string) {
|
||||
if err == nil {
|
||||
err = errors.New(summary)
|
||||
}
|
||||
o.status = app.OperationStatus{Kind: app.StatusError, Summary: summary}
|
||||
o.currentError = &operationError{status: o.status, detail: err.Error()}
|
||||
o.finish()
|
||||
}
|
||||
Reference in New Issue
Block a user