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
+11 -9
View File
@@ -16,9 +16,7 @@ type dashboardRenderState struct {
selectedTargetID string
focus dashboardPane
detailsOffset int
applying bool
profileTransition bool
progressTarget string
operationProgress map[string]app.TargetProgressPhase
}
func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string {
@@ -51,9 +49,7 @@ func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, w
if item.ID == state.selectedTargetID {
cursor = "> "
}
applying := (state.progressTarget != "" && item.ID == state.progressTarget) ||
(!state.profileTransition && state.progressTarget == "" && state.applying && item.ID == state.selectedTargetID)
badge := p.targetBadge(item, applying)
badge := p.targetBadge(item, state.operationProgress[item.ID])
lines = append(lines, cursor+badge+" "+item.Description.Name)
}
if len(lines) == 0 {
@@ -105,7 +101,13 @@ func fixedViewport(content string, width, height, offset int, wrap bool) string
return view.View()
}
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool) string {
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase) string {
switch progress {
case app.TargetProgressRunning:
return p.theme.renderTargetState(item.State, true)
case app.TargetProgressFailed:
return p.theme.checkFailed.Render("! Failed")
}
switch item.DetectionPhase {
case app.TargetChecking:
return p.theme.checking.Render("… Checking")
@@ -115,11 +117,11 @@ func (p terminalPresentation) targetBadge(item app.TargetSnapshot, applying bool
if !item.Available {
return p.theme.unavailable.Render("○ Unavailable")
}
return p.theme.renderTargetState(item.State, applying)
return p.theme.renderTargetState(item.State, false)
}
func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string {
state := p.targetBadge(item, false)
state := p.targetBadge(item, 0)
evidence := item.Evidence
switch item.DetectionPhase {
case app.TargetChecking:
+1 -1
View File
@@ -267,7 +267,7 @@ func responsiveLayoutModule() *recordingModule {
}}
}
func resizedModel(t *testing.T, module app.Module, mode themeMode, width, height int) Model {
func resizedModel(t *testing.T, module app.InteractiveModule, mode themeMode, width, height int) Model {
t.Helper()
return resize(NewModel(module, withTheme(mode)), width, height)
}
+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,
})
}
+4 -5
View File
@@ -26,13 +26,12 @@ func (m *recordingModule) Dispatch(intent app.Intent) app.Result {
func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream {
result := m.Dispatch(intent)
updates := make(chan app.OperationUpdate, 1)
phase := app.OperationSucceeded
status := app.OperationStatus{Kind: app.StatusSuccess, Summary: result.Notice}
status := app.OperationStatus{Summary: result.Notice}
if result.Err != nil {
phase = app.OperationFailed
status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation failed", Detail: result.Err.Error()}
updates <- app.FailedOperationUpdate(result.Snapshot, app.OperationStatus{Summary: "Operation failed"}, nil, "", result.Err)
} else {
updates <- app.SucceededOperationUpdate(result.Snapshot, status, nil)
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Err: result.Err}
close(updates)
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
}
+86 -16
View File
@@ -57,9 +57,9 @@ func TestCompleteModelKeepsDashboardAndHelpBrowsableWhileMutationIsSerialized(t
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
updates <- app.OperationUpdate{Snapshot: module.snapshot, Phase: app.OperationRunning,
Status: app.OperationStatus{Kind: app.StatusProgress, Summary: "Writing Shell configuration", TargetID: "shell"},
Progress: []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}}}
updates <- app.RunningOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Writing Shell configuration", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
model, command = applyOperationCommand(t, model, command)
assertUsableFrame(t, model.View(), 120, 36, "Applying", "Writing Shell configuration")
@@ -96,9 +96,10 @@ func TestCompleteModelPreservesExpandableCurrentErrorAndFocusesFirstFailedTarget
failedSnapshot := operationSnapshot()
failedSnapshot.Targets[0].State = target.Enabled
failedSnapshot.Targets[1].State = target.Drifted
failure <- app.OperationUpdate{Snapshot: failedSnapshot, Phase: app.OperationFailed, FirstFailedTargetID: "apt", Err: errors.New("apt write failed: permission denied"),
Status: app.OperationStatus{Kind: app.StatusError, Summary: "Active Profile changed with one Target failure", Detail: "apt write failed: permission denied", TargetID: "apt"},
Progress: []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}, {TargetID: "apt", Phase: app.TargetProgressFailed}}}
failure <- app.FailedOperationUpdate(failedSnapshot,
app.OperationStatus{Summary: "Active Profile changed with one Target failure", TargetID: "apt"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}, {TargetID: "apt", Phase: app.TargetProgressFailed}},
"apt", errors.New("apt write failed: permission denied"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
if model.selectedTargetID != "apt" {
@@ -115,19 +116,57 @@ func TestCompleteModelPreservesExpandableCurrentErrorAndFocusesFirstFailedTarget
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
refresh <- app.OperationUpdate{Snapshot: failedSnapshot, Phase: app.OperationSucceeded,
Status: app.OperationStatus{Kind: app.StatusSuccess, Summary: "Target checks completed"}}
refresh <- app.SucceededOperationUpdate(failedSnapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"}, nil)
close(refresh)
model, _ = applyOperationCommand(t, model, command)
if !strings.Contains(model.View(), "Current error: Active Profile changed with one Target failure") {
t.Fatalf("unrelated success replaced current error:\n%s", model.View())
}
model = updateKey(model, "x")
if strings.Contains(model.View(), "Current error:") {
if strings.Contains(model.View(), "Current error:") || strings.Contains(model.View(), "Active Profile changed with one Target failure") {
t.Fatalf("acknowledged error remained visible:\n%s", model.View())
}
}
func TestCompleteModelClearsCurrentErrorForDirectlyRelatedSuccessfulDetection(t *testing.T) {
failure := make(chan app.OperationUpdate, 1)
resolution := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
failure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target update failed", TargetID: "shell"}, nil, "shell", errors.New("permission denied"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
resolution <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(resolution)
model, _ = applyOperationCommand(t, model, command)
if strings.Contains(model.View(), "Current error:") {
t.Fatalf("explicit related resolution did not clear the current error:\n%s", model.View())
}
}
func TestCompleteModelReportsOperationStreamClosingWithoutFinalResult(t *testing.T) {
updates := make(chan app.OperationUpdate)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
close(updates)
model, _ = applyOperationCommand(t, model, command)
if !strings.Contains(model.View(), "Current error: Operation ended without a final result") || model.operation.active {
t.Fatalf("unexpected stream closure was not deterministic:\n%s", model.View())
}
}
func TestCompleteModelUsesStructuredConfirmationAndSafeCancellation(t *testing.T) {
confirmation := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{confirmation}}
@@ -135,9 +174,8 @@ func TestCompleteModelUsesStructuredConfirmationAndSafeCancellation(t *testing.T
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
confirmation <- app.OperationUpdate{Snapshot: module.snapshot, Phase: app.OperationAwaitingConfirmation,
Status: app.OperationStatus{Kind: app.StatusConfirmation, Summary: "Confirm Docker restart"},
Confirmation: &app.Confirmation{Title: "Confirm Docker restart", Summary: "Running work may be disrupted.", ConfirmLabel: "Restart", CancelLabel: "Cancel", HighImpact: true}}
confirmation <- app.ConfirmationOperationUpdate(module.snapshot,
app.Confirmation{Title: "Confirm Docker restart", Summary: "Running work may be disrupted.", ConfirmLabel: "Restart", CancelLabel: "Cancel", HighImpact: true})
close(confirmation)
model, _ = applyOperationCommand(t, model, command)
view := model.View()
@@ -145,8 +183,29 @@ func TestCompleteModelUsesStructuredConfirmationAndSafeCancellation(t *testing.T
updated, next := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if next != nil || model.targetConfirm != nil || len(module.intents) != 1 {
t.Fatalf("safe-default Enter did not cancel: confirmation=%v intents=%#v", model.targetConfirm != nil, module.intents)
if next != nil || model.operation.confirmation != nil || len(module.intents) != 1 {
t.Fatalf("safe-default Enter did not cancel: confirmation=%v intents=%#v", model.operation.confirmation != nil, module.intents)
}
}
func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) {
checking := operationSnapshot()
checking.ActiveProfile = ""
module := &interactiveScriptModule{snapshot: checking}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model.snapshot.Targets[0].DetectionPhase = app.TargetChecking
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || !strings.Contains(model.View(), "disabled while this Target is still being checked") {
t.Fatalf("Checking mutation lacked a visible reason:\n%s", model.View())
}
model.snapshot.Targets[0].DetectionPhase = app.TargetReady
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command != nil || !strings.Contains(model.View(), "disabled because there is no Active Profile") {
t.Fatalf("missing-Profile mutation lacked a visible reason:\n%s", model.View())
}
}
@@ -163,6 +222,16 @@ func TestCompleteModelVisiblyDefersCtrlCFromHelpAndStartsNoNewWork(t *testing.T)
if quit != nil || !strings.Contains(model.View(), "Exit requested — waiting for the current mutation to finish safely") {
t.Fatalf("deferred exit was not visible from Help:\n%s", model.View())
}
model = updateKey(model, "q")
model = updateKey(model, "down")
if model.selectedTargetID != "apt" {
t.Fatalf("deferred exit prevented read-only Target browsing: selected=%q", model.selectedTargetID)
}
model = updateKey(model, "?")
if model.page != helpPage {
t.Fatal("deferred exit prevented opening read-only Help")
}
model = updateKey(model, "q")
for _, key := range []tea.KeyMsg{tea.KeyMsg{Type: tea.KeySpace}, keyMessage("r"), keyMessage("p")} {
updated, command := model.Update(key)
model = updated.(Model)
@@ -171,8 +240,9 @@ func TestCompleteModelVisiblyDefersCtrlCFromHelpAndStartsNoNewWork(t *testing.T)
}
}
updates <- app.OperationUpdate{Snapshot: module.snapshot, Phase: app.OperationSucceeded,
Status: app.OperationStatus{Kind: app.StatusSuccess, Summary: "Shell Target is now Enabled", TargetID: "shell"}}
updates <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(updates)
model, quit = applyOperationCommand(t, model, operation)
if quit == nil {
+198
View File
@@ -0,0 +1,198 @@
package tui
import (
"errors"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
)
type operationError struct {
status app.OperationStatus
detail string
}
// operationCoordinator owns the complete interactive operation lifecycle. The
// root Model delegates stream consumption, progress, confirmation, current
// error, and deferred-exit state here instead of mirroring those states.
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
confirmationIntent *app.Intent
quitRequested bool
}
type operationProgress struct {
update app.OperationUpdate
updates <-chan app.OperationUpdate
closed bool
}
func newOperationCoordinator() operationCoordinator {
return operationCoordinator{progress: make(map[string]app.TargetProgressPhase)}
}
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) 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)
if intent.TargetID != "" {
o.progress[intent.TargetID] = app.TargetProgressRunning
}
o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
return stream.Snapshot, waitForOperation(stream.Updates), 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) apply(update app.OperationUpdate) (firstFailedTargetID string) {
status := update.Status()
progress := update.Progress()
for _, item := range progress {
o.progress[item.TargetID] = item.Phase
if item.Phase == app.TargetProgressSucceeded {
o.resolveTargetError(item.TargetID)
}
}
if status.Summary != "" {
o.status = status
}
if update.IsSucceeded() {
o.resolveTargetError(status.TargetID)
}
if update.IsFailed() {
o.currentError = &operationError{status: status, detail: errorDetail(update.Err())}
o.errorExpanded = false
o.errorOffset = 0
firstFailedTargetID = update.FirstFailedTargetID()
}
if request := update.Confirmation(); request != nil {
intent := o.intent
o.confirmation = request
o.confirmationIntent = &intent
}
if update.IsTerminal() {
o.finish()
}
return firstFailedTargetID
}
func errorDetail(err error) string {
if err == nil {
return ""
}
return err.Error()
}
func (o *operationCoordinator) resolveTargetError(targetID string) {
if targetID == "" || o.currentError == nil || o.currentError.status.TargetID != targetID {
return
}
o.currentError = nil
o.errorExpanded = false
o.errorOffset = 0
}
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.",
}
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.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.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
}
func (o *operationCoordinator) fail(err error, summary string) {
if err == nil {
err = errors.New(summary)
}
o.status = app.OperationStatus{Kind: app.StatusError, Summary: summary}
o.currentError = &operationError{status: o.status, detail: err.Error()}
o.finish()
}
+14 -16
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"strings"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
@@ -22,11 +21,14 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID)
footer := p.dashboardFooter(model, catalog)
if model.targetConfirm && !model.operationActive {
footer = "y: confirm Enter/n/Esc: Cancel"
if model.confirmation != nil && !model.operationActive {
footer = "y: confirm Enter/n/Esc: Cancel q: exit"
}
if model.currentError != nil {
footer += "\ne: expand/collapse current error x: acknowledge error"
if model.errorExpanded {
footer += " PgUp/PgDn: scroll error details"
}
}
page := terminalPage{
header: header,
@@ -36,9 +38,7 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
selectedTargetID: model.selectedTargetID,
focus: model.dashboardFocus,
detailsOffset: model.detailsOffset,
applying: model.applying,
profileTransition: model.profileTransition,
progressTarget: model.progressTarget,
operationProgress: model.operationProgress,
},
}
if model.confirmation != nil {
@@ -66,21 +66,21 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t
visibility = "u: hide unavailable"
}
if model.dashboardFocus == targetDetailsPane {
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit"
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q: quit"
if model.operationActive {
footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets ?: help\nMutations disabled: wait for the current mutation to finish"
}
return footer
}
actions := "↑/k ↓/j: select Tab: focus details " + visibility
if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available && selected.DetectionPhase == app.TargetReady {
if model.operationActive {
actions = "Space: disabled (mutation in progress) " + actions
} else {
actions = "Space: toggle " + actions
}
selected, ok := catalog.selected(model.selectedTargetID)
availability := targetMutationAvailability(selected, ok, model.snapshot.ActiveProfile, model.operationActive || model.exitDeferred)
if availability.available {
actions = "Space: toggle " + actions
} else {
actions = "Space: disabled (" + availability.compactReason + ") " + actions
}
footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit"
footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q: quit"
if model.operationActive {
footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
}
@@ -127,8 +127,6 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
context := selectedProfileDetails(p, model)
footer := "n: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back"
switch {
case model.profileConfirm:
footer = "y: confirm n/Esc: cancel"
case model.confirm:
footer = "y: confirm n/Esc: cancel"
if len(model.snapshot.Profiles) == 1 {
+24 -18
View File
@@ -64,19 +64,15 @@ type presentationState struct {
detailsOffset int
form formState
confirm bool
targetConfirm bool
profileConfirm bool
confirmation *app.Confirmation
status string
operationStatus app.OperationStatus
currentError *app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
operationActive bool
operationKind app.IntentKind
applying bool
profileTransition bool
progressTarget string
operationProgress map[string]app.TargetProgressPhase
}
// terminalPresentation is the terminal-presentation Module. Application
@@ -88,13 +84,15 @@ type terminalPresentation struct {
}
type terminalPage struct {
header string
primary string
context string
footer string
status string
focusLine int
dashboard *dashboardRenderState
header string
primary string
context string
footer string
status string
statusExpanded bool
statusOffset int
focusLine int
dashboard *dashboardRenderState
}
func (p terminalPresentation) Render(state presentationState) string {
@@ -123,6 +121,8 @@ func (p terminalPresentation) Render(state presentationState) string {
page = p.mainView(state, width, height)
}
page.status = renderStatus(state)
page.statusExpanded = state.errorExpanded && state.currentError != nil
page.statusOffset = state.errorOffset
page = redactTerminalPage(page)
// Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation
@@ -140,9 +140,9 @@ func renderStatus(state presentationState) string {
lines = append(lines, state.status)
}
if state.currentError != nil {
errorText := "Current error: " + state.currentError.Summary
if state.errorExpanded && state.currentError.Detail != "" {
errorText += " — " + state.currentError.Detail
errorText := "Current error: " + state.currentError.status.Summary
if state.errorExpanded && state.currentError.detail != "" {
errorText += "\n" + state.currentError.detail
}
lines = append(lines, errorText)
}
@@ -165,7 +165,13 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
status := ""
if page.status != "" {
wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status))
status = viewportContent(wrapped, width, min(2, lineCount(wrapped)), 0)
statusHeight := min(2, lineCount(wrapped))
statusOffset := 0
if page.statusExpanded {
statusHeight = min(lineCount(wrapped), max(3, height/2))
statusOffset = page.statusOffset
}
status = viewportContent(wrapped, width, statusHeight, statusOffset)
}
headerHeight := lineCount(header)
+32 -38
View File
@@ -14,52 +14,41 @@ import (
type progressiveDetectionModule struct {
snapshot app.Snapshot
streams []chan app.TargetDetectionUpdate
streams []chan app.OperationUpdate
begins int
}
func (m *progressiveDetectionModule) Snapshot() app.Snapshot {
return cloneDetectionSnapshot(m.snapshot)
snapshot := cloneDetectionSnapshot(m.snapshot)
if m.begins == 0 {
for i := range snapshot.Targets {
snapshot.Targets[i].DetectionPhase = app.TargetChecking
}
}
return snapshot
}
func (m *progressiveDetectionModule) Dispatch(app.Intent) app.Result {
return app.Result{Snapshot: m.snapshot}
}
func (m *progressiveDetectionModule) BeginTargetDetection() (app.Snapshot, <-chan app.TargetDetectionUpdate) {
func (m *progressiveDetectionModule) StartOperation(intent app.Intent) app.OperationStream {
m.begins++
for i := range m.snapshot.Targets {
m.snapshot.Targets[i].DetectionPhase = app.TargetChecking
m.snapshot.Targets[i].DetectionErr = nil
}
return cloneDetectionSnapshot(m.snapshot), m.streams[m.begins-1]
}
func (m *progressiveDetectionModule) StartOperation(intent app.Intent) app.OperationStream {
initial, detection := m.BeginTargetDetection()
updates := make(chan app.OperationUpdate, len(initial.Targets)+1)
go func() {
defer close(updates)
for update := range detection {
phase := app.OperationRunning
status := app.OperationStatus{Kind: app.StatusProgress, Summary: "Checking Targets"}
if update.Complete {
phase = app.OperationSucceeded
status = app.OperationStatus{Kind: app.StatusSuccess, Summary: "Target checks completed"}
}
updates <- app.OperationUpdate{Snapshot: update.Snapshot, Phase: phase, Status: status}
}
}()
return app.OperationStream{Snapshot: initial, Updates: updates}
return app.OperationStream{Snapshot: cloneDetectionSnapshot(m.snapshot), Updates: m.streams[m.begins-1]}
}
func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *testing.T) {
startup := make(chan app.TargetDetectionUpdate, 4)
refresh := make(chan app.TargetDetectionUpdate, 4)
startup := make(chan app.OperationUpdate, 4)
refresh := make(chan app.OperationUpdate, 4)
module := &progressiveDetectionModule{
snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell description"}},
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT description"}},
{ID: "docker", Description: target.Description{Name: "Docker", Summary: "Docker description"}},
}},
streams: []chan app.TargetDetectionUpdate{startup, refresh},
streams: []chan app.OperationUpdate{startup, refresh},
}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
assertUsableFrame(t, model.View(), 120, 36,
@@ -90,39 +79,40 @@ func TestCompleteModelStreamsStartupAndRefreshByTargetWithoutMovingSelection(t *
failure := errors.New("apt-config inspection failed")
startup <- module.detectionUpdate("shell", app.TargetDetectionFailed, false, "", "", failure)
model, command = applyDetectionCommand(t, model, command)
assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> … Checking APT", "● Enabled Docker")
assertUsableFrame(t, model.View(), 120, 36, "! Failed Shell", "> … Checking APT", "● Enabled Docker")
startup <- module.detectionUpdate("apt", app.TargetReady, false, target.Disabled, "apt-config is unavailable", nil)
model, command = applyDetectionCommand(t, model, command)
startup <- app.TargetDetectionUpdate{Snapshot: cloneDetectionSnapshot(module.snapshot), Complete: true}
startup <- app.FailedOperationUpdate(cloneDetectionSnapshot(module.snapshot), app.OperationStatus{Summary: "Some Target checks failed", TargetID: "shell"}, nil, "shell", failure)
model, command = applyDetectionCommand(t, model, command)
if command != nil || model.selectedTargetID != "apt" {
t.Fatalf("startup completion command=%v selection=%q", command != nil, model.selectedTargetID)
if command != nil || model.selectedTargetID != "shell" {
t.Fatalf("failed startup did not focus the first failed Target: command=%v selection=%q", command != nil, model.selectedTargetID)
}
assertUsableFrame(t, model.View(), 120, 36, "! Check failed Shell", "> ○ Unavailable APT", "● Enabled Docker", "Evidence: apt-config is unavailable")
assertUsableFrame(t, model.View(), 120, 36, "> ! Failed Shell", "● Enabled Docker")
model = updateKey(model, "down")
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
if command == nil || module.begins != 2 {
t.Fatalf("refresh did not reuse progressive detection: command=%v begins=%d", command != nil, module.begins)
}
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> … Checking APT", "… Checking Docker")
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "… Checking APT", "> … Checking Docker")
refresh <- module.detectionUpdate("apt", app.TargetReady, true, target.Enabled, "apt enabled", nil)
model, command = applyDetectionCommand(t, model, command)
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "> ● Enabled APT", "… Checking Docker")
if model.selectedTargetID != "apt" {
assertUsableFrame(t, model.View(), 120, 36, "… Checking Shell", "● Enabled APT", "> … Checking Docker")
if model.selectedTargetID != "docker" {
t.Fatalf("refresh completion moved selection to %q", model.selectedTargetID)
}
}
func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClosesStale(t *testing.T) {
startup := make(chan app.TargetDetectionUpdate)
startup := make(chan app.OperationUpdate)
module := &progressiveDetectionModule{
snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{
ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell description"},
}}},
streams: []chan app.TargetDetectionUpdate{startup},
streams: []chan app.OperationUpdate{startup},
}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
assertUsableFrame(t, model.View(), 80, 24, "> … Checking Shell")
@@ -132,13 +122,13 @@ func TestCompleteModelReconcilesAuthoritativeSnapshotWhenDetectionGenerationClos
_ = module.detectionUpdate("shell", app.TargetReady, true, target.Enabled, "shell enabled", nil)
close(startup)
model, next := applyDetectionCommand(t, model, model.Init())
if next != nil || model.refreshing {
t.Fatalf("stale closure left detection active: next=%v refreshing=%v", next != nil, model.refreshing)
if next != nil || model.operation.active {
t.Fatalf("stale closure left detection active: next=%v active=%v", next != nil, model.operation.active)
}
assertUsableFrame(t, model.View(), 80, 24, "> ● Enabled Shell", "Evidence: shell enabled")
}
func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.TargetDetectionUpdate {
func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.TargetDetectionPhase, available bool, state target.State, evidence string, err error) app.OperationUpdate {
for i := range m.snapshot.Targets {
if m.snapshot.Targets[i].ID != id {
continue
@@ -151,7 +141,11 @@ func (m *progressiveDetectionModule) detectionUpdate(id string, phase app.Target
m.snapshot.Targets[i].Evidence = evidence
break
}
return app.TargetDetectionUpdate{Snapshot: cloneDetectionSnapshot(m.snapshot), TargetID: id}
progressPhase := app.TargetProgressSucceeded
if err != nil {
progressPhase = app.TargetProgressFailed
}
return app.RunningOperationUpdate(cloneDetectionSnapshot(m.snapshot), app.OperationStatus{Summary: "Checked Target", TargetID: id}, []app.TargetProgress{{TargetID: id, Phase: progressPhase, State: state, Evidence: evidence, Err: err}})
}
func cloneDetectionSnapshot(snapshot app.Snapshot) app.Snapshot {
+32 -218
View File
@@ -2,7 +2,6 @@ package tui
import (
"errors"
"reflect"
"strings"
"testing"
@@ -26,26 +25,20 @@ func (m *targetModule) Dispatch(intent app.Intent) app.Result {
return m.result
}
func (m *targetModule) StartOperation(intent app.Intent) app.OperationStream {
initial := m.Snapshot()
result := m.Dispatch(intent)
updates := make(chan app.OperationUpdate, 1)
phase := app.OperationSucceeded
status := app.OperationStatus{Kind: app.StatusSuccess, Summary: result.Notice, TargetID: intent.TargetID}
if result.ConfirmationRequest != nil || result.Confirmation != "" {
phase = app.OperationAwaitingConfirmation
confirmation := result.ConfirmationRequest
if confirmation == nil {
confirmation = &app.Confirmation{Title: "Confirm Target change", Summary: result.Confirmation, ConfirmLabel: "Continue", CancelLabel: "Cancel"}
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: app.OperationStatus{Kind: app.StatusConfirmation, Summary: confirmation.Title}, Confirmation: confirmation}
} else {
if result.Err != nil {
phase = app.OperationFailed
status = app.OperationStatus{Kind: app.StatusError, Summary: "Operation failed", Detail: result.Err.Error(), TargetID: intent.TargetID}
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Err: result.Err}
status := app.OperationStatus{Summary: result.Notice, TargetID: intent.TargetID}
switch {
case result.ConfirmationRequest != nil:
updates <- app.ConfirmationOperationUpdate(result.Snapshot, *result.ConfirmationRequest)
case result.Err != nil:
updates <- app.FailedOperationUpdate(result.Snapshot, app.OperationStatus{Summary: "Operation failed", TargetID: intent.TargetID}, nil, intent.TargetID, result.Err)
default:
updates <- app.SucceededOperationUpdate(result.Snapshot, status, nil)
}
close(updates)
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
return app.OperationStream{Snapshot: initial, Updates: updates}
}
func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing.T) {
@@ -75,7 +68,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing
func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T) {
initial := app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately and may disrupt running work."}}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, ConfirmationRequest: &app.Confirmation{Title: "Confirm Docker restart", Summary: "Docker will restart immediately and may disrupt running work.", ConfirmLabel: "Continue", CancelLabel: "Cancel"}}}
model := NewModel(module)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
@@ -155,7 +148,7 @@ func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) {
Profiles: []profile.Profile{{Name: "new"}},
Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}},
}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.Result)}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.OperationUpdate)}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
@@ -172,8 +165,8 @@ func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) {
}
updated, quit := model.Update(keyMessage("q"))
model = updated.(Model)
if quit != nil || !model.quitRequested || len(module.intents) != 1 {
t.Fatalf("busy activation did not defer quit: command=%v requested=%v intents=%#v", quit != nil, model.quitRequested, module.intents)
if quit != nil || !model.operation.quitRequested || len(module.intents) != 1 {
t.Fatalf("busy activation did not defer quit: command=%v requested=%v intents=%#v", quit != nil, model.operation.quitRequested, module.intents)
}
// Even if a Profiles-page activation key is delivered while the transition
@@ -208,154 +201,11 @@ func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
}
updated, quit := model.Update(keyMessage("q"))
model = updated.(Model)
if quit != nil || !model.quitRequested || len(module.intents) != 1 {
if quit != nil || !model.operation.quitRequested || len(module.intents) != 1 {
t.Fatalf("busy refresh did not defer quit: command=%v intents=%#v", quit != nil, module.intents)
}
}
func TestPendingActivationRemainsBusyUntilConfirmationIsResolved(t *testing.T) {
initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}
updates := make(chan app.Result, 1)
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updated, _ = model.Update(command())
model = updated.(Model)
if !model.busy() || model.pendingActivation.Kind != app.ActivateProfile || model.profileConfirm == nil {
t.Fatalf("pending activation was not guarded: %#v", model)
}
model.page = mainPage
for _, key := range []string{"p", "?", "r", " "} {
updated, blocked := model.Update(keyMessage(key))
model = updated.(Model)
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
t.Fatalf("pending activation accepted %q: page=%v command=%v intents=%#v", key, model.page, blocked != nil, module.intents)
}
}
model.page = profilesPage
updated, _ = model.Update(keyMessage("n"))
model = updated.(Model)
if model.busy() || model.profileConfirm != nil {
t.Fatal("cancelling confirmation did not clear pending activation")
}
}
func TestProfileConfirmationQuitKeysCancelWithoutLeavingBusyState(t *testing.T) {
for _, cancelKey := range []tea.KeyMsg{keyMessage("q"), {Type: tea.KeyEsc}} {
t.Run(cancelKey.String(), func(t *testing.T) {
initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}
updates := make(chan app.Result, 2)
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updated, command = model.Update(command())
model = updated.(Model)
if command != nil || model.profileConfirm == nil || !model.busy() {
t.Fatal("activation confirmation was not presented")
}
updated, command = model.Update(cancelKey)
model = updated.(Model)
if command != nil || model.profileConfirm != nil || model.busy() || model.quitRequested {
t.Fatalf("confirmation cancel %q left the model blocked: %#v", cancelKey.String(), model)
}
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition again"}
model = updateKey(model, "p")
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
if command == nil {
t.Fatal("confirmation cancellation prevented a later activation")
}
})
}
}
func TestDeferredQuitIsTerminalAfterTargetConfirmationCompletion(t *testing.T) {
initial := app.Snapshot{
ActiveProfile: "office",
Profiles: []profile.Profile{{Name: "office"}},
Targets: []app.TargetSnapshot{{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Disabled}},
}
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Confirmation: "Docker will restart immediately."}}
model := NewModel(module)
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
updated, command := model.Update(keyMessage("q"))
model = updated.(Model)
if command != nil || !model.quitRequested {
t.Fatal("quit was not deferred during Target operation")
}
updated, queuedQuit := model.Update(operation())
model = updated.(Model)
if queuedQuit == nil || model.targetConfirm == nil || len(module.intents) != 1 {
t.Fatalf("confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.targetConfirm != nil, module.intents)
}
baselineSnapshot := model.snapshot
baselineStatus := model.status
baselinePage := model.page
baselineSelected := model.selected
baselineConfirmation := *model.targetConfirm
for _, message := range []tea.Msg{
keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, keyMessage(" "),
keyMessage("r"), keyMessage("p"), keyMessage("?"), keyMessage("j"), queuedQuit(),
} {
updated, command = model.Update(message)
model = updated.(Model)
if command != nil {
t.Fatalf("terminal deferred-quit model returned a command for %T", message)
}
if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || model.targetConfirm == nil || !reflect.DeepEqual(*model.targetConfirm, baselineConfirmation) || len(module.intents) != 1 {
t.Fatalf("terminal deferred-quit model changed for %T: model=%#v intents=%#v", message, model, module.intents)
}
}
}
func TestDeferredQuitIsTerminalAfterProfileConfirmationCompletion(t *testing.T) {
initial := app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}
updates := make(chan app.Result, 1)
updates <- app.Result{Snapshot: initial, Confirmation: "confirm transition"}
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")
updated, operation := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
updated, command := model.Update(keyMessage("q"))
model = updated.(Model)
if command != nil || !model.quitRequested {
t.Fatal("quit was not deferred during Profile activation")
}
updated, queuedQuit := model.Update(operation())
model = updated.(Model)
if queuedQuit == nil || model.profileConfirm == nil || len(module.intents) != 1 {
t.Fatalf("Profile confirmation completion did not queue quit: command=%v confirmation=%v intents=%#v", queuedQuit != nil, model.profileConfirm != nil, module.intents)
}
baselineSnapshot := model.snapshot
baselineStatus := model.status
baselinePage := model.page
baselineSelected := model.selected
baselinePending := model.pendingActivation
baselineConfirmation := *model.profileConfirm
for _, message := range []tea.Msg{
keyMessage("y"), keyMessage("n"), tea.KeyMsg{Type: tea.KeyEsc}, tea.KeyMsg{Type: tea.KeyEnter},
keyMessage("d"), keyMessage("e"), keyMessage("a"), keyMessage("r"), queuedQuit(),
} {
updated, command = model.Update(message)
model = updated.(Model)
if command != nil {
t.Fatalf("terminal deferred-quit Profile model returned a command for %T", message)
}
if !model.quitRequested || !reflect.DeepEqual(model.snapshot, baselineSnapshot) || model.status != baselineStatus || model.page != baselinePage || model.selected != baselineSelected || !reflect.DeepEqual(model.pendingActivation, baselinePending) || model.profileConfirm == nil || !reflect.DeepEqual(*model.profileConfirm, baselineConfirmation) || len(module.intents) != 1 {
t.Fatalf("terminal deferred-quit Profile model changed for %T: model=%#v intents=%#v", message, model, module.intents)
}
}
}
func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) {
initial := app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "one", Description: target.Description{Name: "One"}, Available: true},
@@ -411,14 +261,16 @@ func TestTargetPanelNavigationKeysAndQuitCombinations(t *testing.T) {
}
}
}
for _, quit := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} {
_, command := NewModel(module).Update(quit)
if command == nil {
t.Fatalf("quit key %q returned no command", quit.String())
}
if _, ok := command().(tea.QuitMsg); !ok {
t.Fatalf("quit key %q command did not return tea.QuitMsg", quit.String())
}
_, command := NewModel(module).Update(keyMessage("q"))
if command == nil {
t.Fatal("dashboard q returned no command")
}
if _, ok := command().(tea.QuitMsg); !ok {
t.Fatal("dashboard q did not return tea.QuitMsg")
}
_, command = NewModel(module).Update(tea.KeyMsg{Type: tea.KeyEsc})
if command != nil {
t.Fatal("dashboard Escape must remain cancel/back rather than exit")
}
}
@@ -440,59 +292,21 @@ func TestQAndEscapeReturnFromNestedPages(t *testing.T) {
type progressiveTargetModule struct {
targetModule
updates chan app.Result
updates chan app.OperationUpdate
}
func (m *progressiveTargetModule) DispatchProgress(intent app.Intent) <-chan app.Result {
m.intents = append(m.intents, intent)
return m.updates
}
func (m *progressiveTargetModule) StartOperation(intent app.Intent) app.OperationStream {
m.intents = append(m.intents, intent)
updates := make(chan app.OperationUpdate, cap(m.updates)+1)
go func() {
defer close(updates)
for result := range m.updates {
phase := app.OperationRunning
status := app.OperationStatus{Kind: app.StatusProgress, Summary: result.Notice}
var confirmation *app.Confirmation
if result.Confirmation != "" {
phase = app.OperationAwaitingConfirmation
confirmation = &app.Confirmation{Title: "Confirm Active Profile change", Summary: result.Confirmation, ConfirmLabel: "Continue", CancelLabel: "Cancel"}
status = app.OperationStatus{Kind: app.StatusConfirmation, Summary: confirmation.Title}
} else if result.Complete {
phase = app.OperationSucceeded
status.Kind = app.StatusSuccess
if result.Err != nil {
phase = app.OperationFailed
status = app.OperationStatus{Kind: app.StatusError, Summary: "Active Profile change failed", Detail: result.Err.Error()}
}
}
progress := append([]app.TargetProgress(nil), result.Progress...)
for i := range progress {
if progress[i].Applying {
progress[i].Phase = app.TargetProgressRunning
} else if progress[i].Err != nil {
progress[i].Phase = app.TargetProgressFailed
} else {
progress[i].Phase = app.TargetProgressSucceeded
}
}
updates <- app.OperationUpdate{Snapshot: result.Snapshot, Phase: phase, Status: status, Progress: progress, Confirmation: confirmation, Err: result.Err}
if phase == app.OperationAwaitingConfirmation {
return
}
}
}()
return app.OperationStream{Snapshot: m.snapshot, Updates: updates}
return app.OperationStream{Snapshot: m.snapshot, Updates: m.updates}
}
func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
initial := app.Snapshot{ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
updates := make(chan app.Result, 3)
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, Progress: []app.TargetProgress{{TargetID: "shell", Applying: true}}}
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Progress: []app.TargetProgress{{TargetID: "shell", State: target.Enabled}}, Notice: "Shell Target update completed"}
updates <- app.Result{Snapshot: app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}, Complete: true, Notice: "Activated Profile new"}
updates := make(chan app.OperationUpdate, 3)
updates <- app.RunningOperationUpdate(app.Snapshot{ActiveProfile: "new", Targets: initial.Targets}, app.OperationStatus{Summary: "Updating Shell Target", TargetID: "shell"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
completedSnapshot := app.Snapshot{ActiveProfile: "new", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}}
updates <- app.RunningOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Shell Target update completed", TargetID: "shell"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
updates <- app.SucceededOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Activated Profile new"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
close(updates)
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p")