feat: unify interactive operation coordination

This commit is contained in:
proxyctl coordinator
2026-07-17 22:23:31 +08:00
parent 177bf34354
commit 7c0b4481d7
17 changed files with 985 additions and 1013 deletions
+123 -405
View File
@@ -31,64 +31,22 @@ type formState struct {
}
type Model struct {
application app.Module
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
targetConfirm *app.Intent
profileConfirm *app.Intent
pendingActivation app.Intent
status string
operationStatus app.OperationStatus
currentError *app.OperationStatus
errorExpanded bool
confirmation *app.Confirmation
operationActive bool
operationKind app.IntentKind
operationIntent app.Intent
operationUpdates <-chan app.OperationUpdate
operationProgress map[string]app.TargetProgressPhase
applying bool
refreshing bool
detectionUpdates <-chan app.TargetDetectionUpdate
profileTransition bool
progressTarget string
quitRequested bool
detect func(context.Context, string) (probe.Capabilities, error)
}
type targetOperationDone struct {
result app.Result
}
type refreshDone struct {
result app.Result
}
type targetDetectionProgress struct {
update app.TargetDetectionUpdate
updates <-chan app.TargetDetectionUpdate
closed bool
}
type profileOperationProgress struct {
result app.Result
updates <-chan app.Result
}
type operationProgress struct {
update app.OperationUpdate
updates <-chan app.OperationUpdate
closed bool
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. address is
@@ -102,54 +60,23 @@ type probeDone struct {
gen uint64
}
func waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return targetDetectionProgress{updates: updates, closed: true}
}
return targetDetectionProgress{update: update, updates: updates}
}
}
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 waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
return func() tea.Msg {
result, ok := <-updates
if !ok {
return profileOperationProgress{result: app.Result{Complete: true}, updates: updates}
}
return profileOperationProgress{result: result, updates: updates}
}
}
func NewModel(application app.Module, configure ...ModelOption) Model {
func NewModel(application app.InteractiveModule, configure ...ModelOption) Model {
options := defaultModelOptions()
for _, option := range configure {
option(&options)
}
snapshot := application.Snapshot()
model := Model{
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: snapshot,
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
width: 80,
height: 24,
operationProgress: make(map[string]app.TargetProgressPhase),
detect: probe.Detect,
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: snapshot,
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
width: 80,
height: 24,
operation: newOperationCoordinator(),
detect: probe.Detect,
}
_, progressiveDetection := application.(app.ProgressiveDetectionModule)
if targetsChecking(snapshot) || progressiveDetection {
if targetsChecking(snapshot) {
model.startOperation(app.Intent{Kind: app.RefreshTargets})
}
return model
@@ -165,8 +92,8 @@ func targetsChecking(snapshot app.Snapshot) bool {
}
func (m Model) Init() tea.Cmd {
if m.operationUpdates != nil {
return waitForOperation(m.operationUpdates)
if m.operation.updates != nil {
return waitForOperation(m.operation.updates)
}
return nil
}
@@ -174,16 +101,14 @@ func (m Model) Init() tea.Cmd {
// busy is the single-operation guard for asynchronous Application work. Every
// operation start checks it, so a completion can only belong to the one current
// operation rather than racing another operation for shared model state.
func (m Model) busy() bool {
return m.quitRequested || m.operationInFlight() || m.pendingActivation.Kind != 0
}
func (m Model) busy() bool { return m.operation.busy() }
func (m Model) operationInFlight() bool {
return m.operationActive || m.applying || m.refreshing || m.profileTransition
}
func (m Model) operationInFlight() bool { return m.operation.active }
func (m Model) dashboardNavigationAvailable() bool {
return !m.quitRequested
// A deferred exit rejects new work, not read-only browsing. Selection,
// details, and Help remain useful until the current mutation is safe.
return true
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
@@ -197,139 +122,41 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
// terminal so intervening input cannot cancel a confirmation or start a
// second mutation.
if m.quitRequested && !m.operationInFlight() {
if m.operation.quitRequested && !m.operationInFlight() {
return m, nil
}
if key, ok := message.(tea.KeyMsg); ok {
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q" && m.targetConfirm == nil && m.profileConfirm == nil)
// Dashboard q always means exit, even when confirmation is visible.
// Escape remains the deterministic cancel/back key.
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q")
if exitRequest {
if m.operationInFlight() {
m.quitRequested = true
return m, nil
}
return m, tea.Quit
return m, m.operation.requestQuit()
}
}
if progress, ok := message.(operationProgress); ok {
if !m.operationActive || progress.updates != m.operationUpdates {
if !m.operation.active || progress.updates != m.operation.updates {
return m, nil
}
if progress.closed {
m.snapshot = m.application.Snapshot()
m.retainTargetSelection()
m.finishOperation()
return m, m.deferredQuit()
m.operation.unexpectedClose()
return m, m.operation.deferredQuit()
}
m.applyOperationUpdate(progress.update)
if isTerminalOperationPhase(progress.update.Phase) {
m.finishOperation()
return m, m.deferredQuit()
m.snapshot = progress.update.Snapshot
m.retainTargetSelection()
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
m.selectedTargetID = firstFailed
m.detailsOffset = 0
}
if m.operation.confirmation != nil {
m.page = mainPage
}
if progress.update.IsTerminal() {
return m, m.operation.deferredQuit()
}
return m, waitForOperation(progress.updates)
}
if progress, ok := message.(targetDetectionProgress); ok {
if !m.refreshing || progress.updates != m.detectionUpdates {
return m, nil
}
if progress.closed {
// A stale Application generation closes without claiming an
// authoritative completion snapshot. Reconcile with the Application
// so this Model does not strand rows in its superseded Checking frame.
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = m.application.Snapshot()
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
m.refreshing = false
m.detectionUpdates = nil
return m, m.deferredQuit()
}
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = progress.update.Snapshot
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
if progress.update.Complete {
m.refreshing = false
m.detectionUpdates = nil
m.status = "Target availability and state checks completed"
return m, m.deferredQuit()
}
return m, waitForTargetDetection(progress.updates)
}
if completed, ok := message.(refreshDone); ok {
if !m.refreshing {
return m, nil
}
m.refreshing = false
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = completed.result.Snapshot
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
if completed.result.Err != nil {
m.status = "Error: " + completed.result.Err.Error()
} else {
m.status = completed.result.Notice
}
return m, m.deferredQuit()
}
if progress, ok := message.(profileOperationProgress); ok {
if !m.profileTransition {
return m, nil
}
m.snapshot = progress.result.Snapshot
m.retainTargetSelection()
m.progressTarget = ""
if len(progress.result.Progress) > 0 {
item := progress.result.Progress[len(progress.result.Progress)-1]
if item.Applying {
m.progressTarget = item.TargetID
}
}
if progress.result.Confirmation != "" {
m.applying = false
m.profileTransition = false
m.page = profilesPage
intent := m.pendingActivation
m.profileConfirm = &intent
m.status = progress.result.Confirmation
return m, m.deferredQuit()
}
if progress.result.Err != nil {
m.status = "Error: " + progress.result.Err.Error()
} else if progress.result.Notice != "" {
m.status = progress.result.Notice
}
if progress.result.Complete {
m.applying = false
m.profileTransition = false
m.progressTarget = ""
m.pendingActivation = app.Intent{}
return m, m.deferredQuit()
}
return m, waitForProfileProgress(progress.updates)
}
if completed, ok := message.(targetOperationDone); ok {
if !m.applying || m.profileTransition {
return m, nil
}
m.applying = false
m.snapshot = completed.result.Snapshot
m.retainTargetSelection()
if completed.result.Confirmation != "" {
intent := app.Intent{Kind: app.SetTargetDesired}
if m.targetConfirm != nil {
intent = *m.targetConfirm
}
m.targetConfirm = &intent
m.status = completed.result.Confirmation
} else if completed.result.Err != nil {
m.targetConfirm = nil
m.status = "Error: " + completed.result.Err.Error()
} else {
m.targetConfirm = nil
m.status = completed.result.Notice
}
return m, m.deferredQuit()
}
if done, ok := message.(probeDone); ok {
if !m.form.probing || done.gen != m.form.probeGen {
return m, nil
@@ -396,172 +223,93 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *Model) startOperation(intent app.Intent) tea.Cmd {
if m.quitRequested {
return nil
snapshot, command, started := m.operation.start(m.application, intent)
if started {
m.snapshot = snapshot
m.retainTargetSelection()
}
if m.operationActive {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before starting another modification"}
return nil
}
interactive, ok := m.application.(app.InteractiveModule)
if !ok {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Interactive operation streaming is unavailable"}
return nil
}
stream := interactive.StartOperation(intent)
m.snapshot = stream.Snapshot
m.retainTargetSelection()
m.operationActive = true
m.operationKind = intent.Kind
m.operationIntent = intent
m.operationUpdates = stream.Updates
m.operationProgress = make(map[string]app.TargetProgressPhase)
m.applying = intent.Kind == app.SetTargetDesired || intent.Kind == app.ActivateProfile
m.refreshing = intent.Kind == app.RefreshTargets
m.profileTransition = intent.Kind == app.ActivateProfile
m.progressTarget = ""
m.operationStatus = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
return waitForOperation(stream.Updates)
return command
}
func operationStartingSummary(kind app.IntentKind) string {
switch kind {
case app.RefreshTargets:
return "Checking Target availability and state…"
case app.ActivateProfile:
return "Changing Active Profile…"
type mutationAvailability struct {
available bool
reason string
compactReason string
}
// targetMutationAvailability is the sole policy used by both input handling
// and rendering, so the displayed disabled reason cannot drift from behavior.
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, activeProfile string, operationBlocked bool) mutationAvailability {
switch {
case operationBlocked:
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
case !selectedOK:
return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"}
case selected.DetectionPhase == app.TargetChecking:
return mutationAvailability{reason: "Target mutation is disabled while this Target is still being checked", compactReason: "checking Target"}
case selected.DetectionPhase == app.TargetDetectionFailed:
return mutationAvailability{reason: "Target mutation is disabled because this Target check failed; refresh Targets to retry", compactReason: "Target check failed"}
case !selected.Available:
return mutationAvailability{reason: "Target mutation is disabled because this Target is unavailable", compactReason: "Target unavailable"}
case (selected.State == target.Disabled || selected.State == target.External) && activeProfile == "":
return mutationAvailability{reason: "Target mutation is disabled because there is no Active Profile; open Profile management first", compactReason: "no Active Profile"}
default:
return "Starting Target mutation…"
return mutationAvailability{available: true}
}
}
func (m *Model) finishOperation() {
m.operationActive = false
m.operationKind = 0
m.operationIntent = app.Intent{}
m.operationUpdates = nil
m.applying = false
m.refreshing = false
m.profileTransition = false
m.progressTarget = ""
}
func isTerminalOperationPhase(phase app.OperationPhase) bool {
return phase == app.OperationAwaitingConfirmation || phase == app.OperationSucceeded || phase == app.OperationFailed || phase == app.OperationBlocked
}
func (m *Model) applyOperationUpdate(update app.OperationUpdate) {
m.snapshot = update.Snapshot
m.retainTargetSelection()
for _, progress := range update.Progress {
m.operationProgress[progress.TargetID] = progress.Phase
if progress.Phase == app.TargetProgressRunning {
m.progressTarget = progress.TargetID
} else if m.progressTarget == progress.TargetID {
m.progressTarget = ""
}
}
if update.Status.Kind != 0 {
m.operationStatus = update.Status
}
if update.Status.Kind == app.StatusError {
current := update.Status
if current.Detail == "" && update.Err != nil {
current.Detail = update.Err.Error()
}
m.currentError = &current
m.errorExpanded = false
} else if update.Phase == app.OperationSucceeded && m.currentError != nil && update.Status.TargetID != "" && update.Status.TargetID == m.currentError.TargetID {
m.currentError = nil
m.errorExpanded = false
}
if update.FirstFailedTargetID != "" {
m.selectedTargetID = update.FirstFailedTargetID
m.detailsOffset = 0
}
if isTerminalOperationPhase(update.Phase) && update.Phase != app.OperationAwaitingConfirmation {
m.pendingActivation = app.Intent{}
}
if update.Phase == app.OperationAwaitingConfirmation && update.Confirmation != nil {
intent := m.operationIntent
m.confirmation = update.Confirmation
m.page = mainPage
if intent.Kind == app.ActivateProfile {
m.pendingActivation = intent
m.profileConfirm = &intent
} else {
m.targetConfirm = &intent
}
}
}
func isQuitKey(key tea.KeyMsg) bool {
switch key.String() {
case "q", "esc", "ctrl+c":
return true
default:
return false
}
}
func (m Model) deferredQuit() tea.Cmd {
if m.quitRequested {
return tea.Quit
}
return nil
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.targetConfirm != nil || m.profileConfirm != nil {
intent := m.targetConfirm
if intent == nil {
intent = m.profileConfirm
}
if m.operation.confirmation != nil {
switch key.String() {
case "y":
confirmed := *intent
confirmed.Confirm = true
m.targetConfirm = nil
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
m.confirmation = nil
confirmed, ok := m.operation.confirmedIntent()
if !ok {
return m, nil
}
m.status = ""
return m, m.startOperation(confirmed)
case "enter", "n", "esc", "q":
m.targetConfirm = nil
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
m.confirmation = nil
m.operationStatus = app.OperationStatus{}
case "enter", "n", "esc":
m.operation.cancelConfirmation()
}
return m, nil
}
if key.String() == "e" && m.currentError != nil {
m.errorExpanded = !m.errorExpanded
if key.String() == "e" && m.operation.currentError != nil {
m.operation.errorExpanded = !m.operation.errorExpanded
m.operation.errorOffset = 0
return m, nil
}
if key.String() == "x" && m.currentError != nil {
m.currentError = nil
m.errorExpanded = false
if key.String() == "x" && m.operation.currentError != nil {
m.operation.acknowledgeError()
return m, nil
}
if m.operation.errorExpanded {
switch key.String() {
case "pgup":
m.operation.errorOffset = max(0, m.operation.errorOffset-5)
return m, nil
case "pgdown":
m.operation.errorOffset += 5
return m, nil
}
}
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
switch key.String() {
case "p":
if m.operationActive || m.quitRequested {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"}
if m.operation.active || m.operation.quitRequested {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"}
} else {
m.page = profilesPage
m.selected = 0
m.status = ""
if m.operation.status.Kind != app.StatusError {
m.operation.status = app.OperationStatus{}
}
}
case "?":
if !m.quitRequested {
m.page = helpPage
}
m.page = helpPage
case "r":
if m.operationActive || m.quitRequested {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"}
if m.operation.active || m.operation.quitRequested {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"}
} else {
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
}
@@ -607,41 +355,21 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
m.detailsOffset += 5
}
case " ":
if m.operationActive || m.quitRequested {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before starting another modification"}
break
}
selected, ok := catalog.selected(m.selectedTargetID)
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
availability := targetMutationAvailability(selected, ok, m.snapshot.ActiveProfile, m.operation.active || m.operation.quitRequested)
if !availability.available {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason}
break
}
desired := selected.State == target.Disabled || selected.State == target.External
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
return m, m.startOperation(intent)
case "esc":
if !m.operationActive {
return m, tea.Quit
}
}
return m, nil
}
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.profileConfirm != nil {
switch key.String() {
case "y":
intent := *m.profileConfirm
intent.Confirm = true
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
return m.beginProfileActivation(intent)
case "n", "esc", "q":
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
m.status = ""
}
return m, nil
}
if m.confirm {
switch key.String() {
case "y":
@@ -778,10 +506,9 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
if m.busy() {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
return m, nil
}
m.pendingActivation = intent
m.page = mainPage
m.selected = 0
return m, m.startOperation(intent)
@@ -790,11 +517,6 @@ func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
func (m *Model) apply(intent app.Intent) {
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if result.Confirmation != "" {
m.profileConfirm = &intent
m.status = result.Confirmation
return
}
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 {
m.selected--
}
@@ -842,19 +564,15 @@ func (m Model) View() string {
detailsOffset: m.detailsOffset,
form: m.form,
confirm: m.confirm,
targetConfirm: m.targetConfirm != nil,
profileConfirm: m.profileConfirm != nil,
confirmation: m.confirmation,
confirmation: m.operation.confirmation,
status: m.status,
operationStatus: m.operationStatus,
currentError: m.currentError,
errorExpanded: m.errorExpanded,
exitDeferred: m.quitRequested && m.operationInFlight(),
operationActive: m.operationActive,
operationKind: m.operationKind,
applying: m.applying,
profileTransition: m.profileTransition,
progressTarget: m.progressTarget,
operationStatus: m.operation.status,
currentError: m.operation.currentError,
errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
})
}