272 lines
7.4 KiB
Go
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
|
|
}
|