Files
proxy/internal/tui/operation_coordinator.go
T

255 lines
6.9 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
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
}