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 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)), } } // waitForOperation races the stream against the real Bubbles tick command. // Only the stream is read directly, so a losing tick cannot consume an update. func waitForOperation(updates <-chan app.OperationUpdate, tick tea.Cmd) tea.Cmd { return func() tea.Msg { if tick == nil { update, ok := <-updates if !ok { return operationProgress{updates: updates, closed: true} } return operationProgress{update: update, updates: updates} } ticks := make(chan tea.Msg, 1) go func() { ticks <- tick() }() select { case update, ok := <-updates: if !ok { return operationProgress{updates: updates, closed: true} } return operationProgress{update: update, updates: updates} case message := <-ticks: return message } } } func (o operationCoordinator) wait() tea.Cmd { if !o.active || o.updates == nil { return nil } return waitForOperation(o.updates, 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.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.wait(), 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) tick(message spinner.TickMsg) tea.Cmd { if !o.active { return nil } updated, nextTick := o.spinner.Update(message) o.spinner = updated return waitForOperation(o.updates, 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.currentError = &operationError{status: decoded.Status, detail: errorDetail(decoded.Err), intent: o.intent} o.errorExpanded = false o.errorOffset = 0 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) 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 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() { 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.", intent: o.intent, } 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.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 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 { return nil } return tea.Quit }