Files
proxy/internal/tui/operation_coordinator.go
T

272 lines
7.4 KiB
Go

package tui
import (
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
)
type operationError struct {
status app.OperationStatus
detail string
intent app.Intent
}
// operationCoordinator owns the complete interactive operation lifecycle. The
// root Model delegates stream consumption, progress, confirmation, current
// error, spinner, and deferred-exit state here instead of mirroring them.
type operationCoordinator struct {
active bool
intent app.Intent
updates <-chan app.OperationUpdate
cancel func()
progress map[string]app.TargetProgressPhase
status app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
confirmation *app.Confirmation
confirmationDialog confirmationDialog
confirmationIntent *app.Intent
quitRequested bool
spinner spinner.Model
}
type operationProgress struct {
update app.OperationUpdate
updates <-chan app.OperationUpdate
closed bool
}
var applyingSpinner = spinner.Spinner{
Frames: []string{"|", "/", "-", "\\"},
FPS: 90 * time.Millisecond,
}
func newOperationCoordinator() operationCoordinator {
return operationCoordinator{
progress: make(map[string]app.TargetProgressPhase),
spinner: spinner.New(spinner.WithSpinner(applyingSpinner)),
}
}
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) wait() tea.Cmd {
if !o.active || o.updates == nil {
return nil
}
return waitForOperation(o.updates)
}
func (o operationCoordinator) commands() tea.Cmd {
if !o.active {
return nil
}
return tea.Batch(o.wait(), o.spinner.Tick)
}
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.cancel = stream.Cancel
o.progress = make(map[string]app.TargetProgressPhase)
o.spinner = spinner.New(spinner.WithSpinner(applyingSpinner))
if intent.TargetID != "" {
o.progress[intent.TargetID] = app.TargetProgressRunning
}
o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
return stream.Snapshot, o.commands(), 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…"
case app.CreateProfile, app.UpdateProfile, app.DeleteProfile:
return "Saving Proxy Profile changes…"
default:
return "Starting Target mutation…"
}
}
func (o *operationCoordinator) tick(message spinner.TickMsg) tea.Cmd {
if !o.active {
return nil
}
updated, nextTick := o.spinner.Update(message)
o.spinner = updated
return nextTick
}
func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTargetID string) {
decoded := update.Decode()
for _, item := range decoded.Progress {
o.progress[item.TargetID] = item.Phase
}
if decoded.Status.Summary != "" {
o.status = decoded.Status
}
switch decoded.Kind {
case app.OperationSucceeded:
o.resolveError(o.intent)
case app.OperationFailed:
o.recordError(&operationError{status: decoded.Status, detail: errorDetail(decoded.Err), intent: o.intent})
firstFailedTargetID = decoded.FirstFailedTargetID
case app.OperationConfirmation:
intent := o.intent
o.confirmation = decoded.Confirmation
o.confirmationDialog = newConfirmationDialog(*decoded.Confirmation)
o.confirmationIntent = &intent
}
if decoded.IsTerminal() {
o.finish()
}
return firstFailedTargetID
}
func errorDetail(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func (o *operationCoordinator) recordError(candidate *operationError) {
if o.currentError != nil && !relatedResolution(o.currentError.intent, candidate.intent) {
return
}
o.currentError = candidate
o.errorExpanded = false
o.errorOffset = 0
}
func (o *operationCoordinator) resolveError(success app.Intent) {
if o.currentError == nil || !relatedResolution(o.currentError.intent, success) {
return
}
o.currentError = nil
o.errorExpanded = false
o.errorOffset = 0
}
func relatedResolution(failed, success app.Intent) bool {
switch {
case failed.Kind == app.RefreshTargets:
return success.Kind == app.RefreshTargets
case failed.Kind == app.ActivateProfile:
return success.Kind == app.ActivateProfile && failed.Name == success.Name
case isProfileMutation(failed.Kind):
return isProfileMutation(success.Kind) && failed.Name == success.Name
case isTargetMutation(failed.Kind):
return isTargetMutation(success.Kind) && failed.TargetID != "" && failed.TargetID == success.TargetID
default:
return false
}
}
func isTargetMutation(kind app.IntentKind) bool {
switch kind {
case app.EnableTarget, app.DisableTarget, app.ApplyProfileToTarget, app.ReapplyActiveProfile, app.RestoreOriginal:
return true
default:
return false
}
}
func (o *operationCoordinator) finish() {
// start seeds speculative Running progress before the stream publishes its
// first update. A terminal update may contain no replacement progress (most
// notably a confirmation request), so never let that transient phase outlive
// the operation that created it.
for targetID, phase := range o.progress {
if phase == app.TargetProgressRunning {
delete(o.progress, targetID)
}
}
o.active = false
o.intent = app.Intent{}
o.updates = nil
o.cancel = nil
}
func (o *operationCoordinator) unexpectedClose() {
o.status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation ended without a final result"}
o.recordError(&operationError{
status: o.status,
detail: "The Application operation stream closed before publishing a terminal result.",
intent: o.intent,
})
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.confirmationDialog = confirmationDialog{}
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
intent.ConfirmationToken = o.confirmation.Token
o.confirmation = nil
o.confirmationDialog = confirmationDialog{}
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 {
if o.cancel != nil {
o.cancel()
}
return nil
}
return tea.Quit
}