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 }