From 2ca42bd4bd82a19c30f0bfd6fb7a16ec45a36bca Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Fri, 17 Jul 2026 22:37:38 +0800 Subject: [PATCH] pi-agent: Implement contextual Target actions --- internal/app/app.go | 176 +++++++++++++-- internal/app/contextual_actions_test.go | 233 ++++++++++++++++++++ internal/app/target_test.go | 9 +- internal/tui/contextual_actions_test.go | 135 ++++++++++++ internal/tui/dashboard.go | 19 ++ internal/tui/dashboard_view.go | 12 + internal/tui/foundation_test.go | 8 +- internal/tui/model.go | 168 +++++++++----- internal/tui/model_test.go | 38 +++- internal/tui/operation_coordination_test.go | 12 +- internal/tui/pages.go | 35 ++- internal/tui/presentation.go | 41 ++-- internal/tui/target_test.go | 27 ++- 13 files changed, 793 insertions(+), 120 deletions(-) create mode 100644 internal/app/contextual_actions_test.go create mode 100644 internal/tui/contextual_actions_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 2eb0456..2cdfb1c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,8 +19,13 @@ const ( UpdateProfile DeleteProfile ActivateProfile - SetTargetDesired + SetTargetDesired // retained for synchronous compatibility; interactive callers use the explicit intents below. RefreshTargets + EnableTarget + DisableTarget + ApplyProfileToTarget + ReapplyActiveProfile + RestoreOriginal ) type Intent struct { @@ -32,6 +37,26 @@ type Intent struct { Enabled bool } +// TargetActionKind identifies a Target action independently of display labels. +type TargetActionKind uint8 + +const ( + EnableTargetAction TargetActionKind = iota + 1 + DisableTargetAction + ApplyProfileTargetAction + ResolveDriftTargetAction + ReapplyActiveProfileAction + RestoreOriginalAction +) + +// TargetAction exposes whether an intent is currently available and, when it +// is not, the reason presentation layers should show instead of ignoring it. +type TargetAction struct { + Kind TargetActionKind + Available bool + DisabledReason string +} + type TargetDetectionPhase uint8 const ( @@ -43,14 +68,16 @@ const ( ) type TargetSnapshot struct { - ID string - Description target.Description - DetectionPhase TargetDetectionPhase - DetectionErr error - Available bool - AvailabilityEvidence string - State target.State - Evidence string + ID string + Description target.Description + DetectionPhase TargetDetectionPhase + DetectionErr error + Available bool + AvailabilityEvidence string + State target.State + Evidence string + PrimaryAction TargetAction + DriftResolutionActions []TargetAction } type Snapshot struct { @@ -154,14 +181,78 @@ func (a *Application) snapshotLocked() Snapshot { for i, id := range a.targetIDs { availability := a.availabilities[id] inspection := a.inspections[id] + phase := a.detectionPhase[id] + primary, resolution := contextualTargetActions(phase, availability.Available, inspection.State, state.ActiveProfile != "") snapshot.Targets = append(snapshot.Targets, TargetSnapshot{ - ID: id, Description: a.targetDescriptions[i], DetectionPhase: a.detectionPhase[id], DetectionErr: a.detectionErr[id], + ID: id, Description: a.targetDescriptions[i], DetectionPhase: phase, DetectionErr: a.detectionErr[id], Available: availability.Available, AvailabilityEvidence: availability.Evidence, State: inspection.State, Evidence: inspection.Evidence, + PrimaryAction: primary, DriftResolutionActions: resolution, }) } return snapshot } +func contextualTargetActions(phase TargetDetectionPhase, available bool, state target.State, hasActiveProfile bool) (TargetAction, []TargetAction) { + primary := TargetAction{Kind: primaryTargetActionKind(state), Available: true} + if reason := targetActionDisabledReason(phase, available); reason != "" { + primary.Available = false + primary.DisabledReason = reason + } + needsProfile := state == target.Disabled || state == target.External + if primary.Available && needsProfile && !hasActiveProfile { + primary.Available = false + primary.DisabledReason = "This action is unavailable because there is no Active Profile; open Profile management first" + } + if state != target.Drifted { + return primary, nil + } + + resolution := []TargetAction{ + {Kind: ReapplyActiveProfileAction, Available: true}, + {Kind: RestoreOriginalAction, Available: true}, + } + if !hasActiveProfile { + resolution[0].Available = false + resolution[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile; open Profile management first" + } + if reason := targetActionDisabledReason(phase, available); reason != "" { + for i := range resolution { + resolution[i].Available = false + resolution[i].DisabledReason = reason + } + } + return primary, resolution +} + +func primaryTargetActionKind(state target.State) TargetActionKind { + switch state { + case target.Disabled: + return EnableTargetAction + case target.Enabled: + return DisableTargetAction + case target.External: + return ApplyProfileTargetAction + case target.Drifted: + return ResolveDriftTargetAction + default: + return 0 + } +} + +func targetActionDisabledReason(phase TargetDetectionPhase, available bool) string { + switch phase { + case TargetChecking: + return "Target actions are unavailable while this Target is still being checked" + case TargetDetectionFailed: + return "Target actions are unavailable because this Target check failed; refresh Targets to retry" + default: + if !available { + return "Target actions are unavailable because this Target is unavailable" + } + return "" + } +} + type targetDetectionOutcome struct { targetID string detection target.Detection @@ -261,10 +352,10 @@ func (a *Application) dispatch(intent Intent, publish func(Result)) Result { return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("reload Profile state for mutation: %w", err)} } a.replaceState(fresh) - if intent.Kind == SetTargetDesired || intent.Kind == ActivateProfile { + if isTargetMutationIntent(intent.Kind) || intent.Kind == ActivateProfile { a.refreshInspections() } - if intent.Kind == SetTargetDesired { + if isTargetMutationIntent(intent.Kind) { return a.setTargetDesired(intent, publish) } if intent.Kind == RefreshTargets { @@ -580,6 +671,32 @@ func (a *Application) refreshInspections() { a.replaceInspections(inspections) } +func isTargetMutationIntent(kind IntentKind) bool { + switch kind { + case SetTargetDesired, EnableTarget, DisableTarget, ApplyProfileToTarget, ReapplyActiveProfile, RestoreOriginal: + return true + default: + return false + } +} + +func contextualIntentSemantics(kind IntentKind) (expected target.State, enabled bool, ok bool) { + switch kind { + case EnableTarget: + return target.Disabled, true, true + case DisableTarget: + return target.Enabled, false, true + case ApplyProfileToTarget: + return target.External, true, true + case ReapplyActiveProfile: + return target.Drifted, true, true + case RestoreOriginal: + return target.Drifted, false, true + default: + return "", false, false + } +} + func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Result { selectedIndex := -1 for i, id := range a.targetIDs { @@ -592,11 +709,19 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q does not exist", intent.TargetID)} } selected := a.targets[selectedIndex] + enabled := intent.Enabled + if expected, contextualEnabled, contextual := contextualIntentSemantics(intent.Kind); contextual { + actual := a.inspection(intent.TargetID).State + if actual != expected { + return Result{Snapshot: a.Snapshot(), Err: fmt.Errorf("Target %q action is no longer available because its state is %s", intent.TargetID, actual)} + } + enabled = contextualEnabled + } active := a.activeProfile() - if intent.Enabled && active == nil { + if enabled && active == nil { return Result{Snapshot: a.Snapshot(), Err: errors.New("no Active Profile; open Profile management before enabling a Target")} } - if warning := selected.Confirmation(intent.Enabled); warning != "" && !intent.Confirm { + if warning := selected.Confirmation(enabled); warning != "" && !intent.Confirm { confirmation := &Confirmation{Title: "Confirm " + a.targetDescriptions[selectedIndex].Name + " Target change", Summary: warning, ConfirmLabel: "Continue", CancelLabel: "Cancel", HighImpact: true} return Result{Snapshot: a.Snapshot(), ConfirmationRequest: confirmation, @@ -606,7 +731,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu publish(Result{Snapshot: a.Snapshot(), Progress: []TargetProgress{{TargetID: intent.TargetID, Phase: TargetProgressRunning, State: a.inspection(intent.TargetID).State}}, Status: OperationStatus{Summary: "Updating " + a.targetDescriptions[selectedIndex].Name + " Target", TargetID: intent.TargetID}}) } - result := selected.SetDesired(context.Background(), intent.Enabled, active) + result := selected.SetDesired(context.Background(), enabled, active) inspection := result.Inspection if result.Err != nil { inspection = selected.Inspect(context.Background(), active) @@ -617,10 +742,7 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu outcome, detail = "failure", result.Err.Error() } if a.logger != nil { - operation := "disable" - if intent.Enabled { - operation = "enable" - } + operation := targetOperationName(intent.Kind, enabled) _ = a.logger.Write(LogEntry{Time: time.Now(), Operation: operation, Target: intent.TargetID, Result: outcome, Detail: RedactLogDetail(detail)}) } notice := "" @@ -637,6 +759,22 @@ func (a *Application) setTargetDesired(intent Intent, publish func(Result)) Resu return Result{Snapshot: a.Snapshot(), Notice: notice, Status: status, Progress: []TargetProgress{progress}, FirstFailedTargetID: map[bool]string{true: intent.TargetID}[result.Err != nil], Err: result.Err} } +func targetOperationName(kind IntentKind, enabled bool) string { + switch kind { + case ApplyProfileToTarget: + return "apply-Profile" + case ReapplyActiveProfile: + return "reapply-Active-Profile" + case RestoreOriginal: + return "restore-original" + default: + if enabled { + return "enable" + } + return "disable" + } +} + func create(state profile.State, input profile.Profile) (profile.State, string, error) { p, err := profile.ValidateAndNormalize(input) if err != nil { diff --git a/internal/app/contextual_actions_test.go b/internal/app/contextual_actions_test.go new file mode 100644 index 0000000..b6d61a2 --- /dev/null +++ b/internal/app/contextual_actions_test.go @@ -0,0 +1,233 @@ +package app_test + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/profile" + "proxyctl/internal/target" +) + +func TestApplicationSnapshotStructuresContextualTargetActions(t *testing.T) { + states := []struct { + id string + state target.State + action app.TargetActionKind + }{ + {id: "disabled", state: target.Disabled, action: app.EnableTargetAction}, + {id: "enabled", state: target.Enabled, action: app.DisableTargetAction}, + {id: "external", state: target.External, action: app.ApplyProfileTargetAction}, + {id: "drifted", state: target.Drifted, action: app.ResolveDriftTargetAction}, + } + adapters := make([]target.Target, 0, len(states)) + for _, item := range states { + adapters = append(adapters, targetWithID{fakeTarget: &fakeTarget{available: true, state: item.state}, id: item.id}) + } + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + + snapshot := application.Snapshot() + for i, want := range states { + got := snapshot.Targets[i] + if got.PrimaryAction.Kind != want.action || !got.PrimaryAction.Available || got.PrimaryAction.DisabledReason != "" { + t.Errorf("%s primary action = %#v, want available %v", want.state, got.PrimaryAction, want.action) + } + } + drift := snapshot.Targets[3] + if len(drift.DriftResolutionActions) != 2 || drift.DriftResolutionActions[0].Kind != app.ReapplyActiveProfileAction || drift.DriftResolutionActions[1].Kind != app.RestoreOriginalAction { + t.Fatalf("Drift Resolution actions = %#v", drift.DriftResolutionActions) + } + for _, action := range drift.DriftResolutionActions { + if !action.Available || action.DisabledReason != "" { + t.Errorf("Drift Resolution action = %#v, want available", action) + } + } +} + +func TestApplicationDispatchesExplicitContextualTargetIntents(t *testing.T) { + tests := []struct { + name string + state target.State + intent app.IntentKind + enabled bool + }{ + {name: "Enable", state: target.Disabled, intent: app.EnableTarget, enabled: true}, + {name: "Disable", state: target.Enabled, intent: app.DisableTarget, enabled: false}, + {name: "Apply Profile", state: target.External, intent: app.ApplyProfileToTarget, enabled: true}, + {name: "Reapply Active Profile", state: target.Drifted, intent: app.ReapplyActiveProfile, enabled: true}, + {name: "Restore Original", state: target.Drifted, intent: app.RestoreOriginal, enabled: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.Disabled} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + adapter.state = test.state + adapter.calls = nil + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + + result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()}) + if result.Err != nil || len(adapter.calls) != 1 || adapter.calls[0] != test.enabled { + t.Fatalf("result = %#v, SetDesired calls = %#v", result, adapter.calls) + } + wantState := target.Disabled + if test.enabled { + wantState = target.Enabled + } + if result.Snapshot.Targets[0].State != wantState { + t.Fatalf("final state = %s, want %s", result.Snapshot.Targets[0].State, wantState) + } + }) + } +} + +func TestApplicationRejectsContextualIntentWhenTargetStateChanged(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.External} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + + result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()}) + if result.Err == nil || !strings.Contains(result.Err.Error(), "External") || len(adapter.calls) != 0 { + t.Fatalf("stale Enable intent = %#v, calls = %#v", result, adapter.calls) + } +} + +func TestApplicationReapplyPublishesTruthfulDriftWhenAdapterCannotConverge(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.Disabled} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + adapter.state = target.Drifted + adapter.setState = target.Drifted + adapter.calls = nil + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + + result := application.Dispatch(app.Intent{Kind: app.ReapplyActiveProfile, TargetID: adapter.ID()}) + if result.Err != nil || result.Snapshot.Targets[0].State != target.Drifted || result.Progress[0].State != target.Drifted { + t.Fatalf("truthful non-converged reapply = %#v", result) + } +} + +func TestApplicationRejectsStructuredUnavailableActionWithoutAdapterMutation(t *testing.T) { + adapter := &fakeTarget{available: false, state: target.External} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + adapter.calls = nil + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + if action := application.Snapshot().Targets[0].PrimaryAction; action.Available || !strings.Contains(action.DisabledReason, "unavailable") { + t.Fatalf("unavailable action = %#v", action) + } + + result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()}) + if result.Err == nil || !strings.Contains(result.Err.Error(), "unavailable") || len(adapter.calls) != 0 { + t.Fatalf("unavailable action result = %#v, calls=%#v", result, adapter.calls) + } +} + +func TestApplicationRejectsProfileDependentIntentWithoutActiveProfile(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.External} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()}) + if result.Err == nil || !strings.Contains(result.Err.Error(), "no Active Profile") || len(adapter.calls) != 0 { + t.Fatalf("missing Active Profile result = %#v, calls=%#v", result, adapter.calls) + } +} + +func TestApplicationReapplyAndRestoreFailuresPublishFreshDriftedState(t *testing.T) { + for _, test := range []struct { + name string + intent app.IntentKind + err string + }{ + {name: "reapply", intent: app.ReapplyActiveProfile, err: "write failed"}, + {name: "restore ownership loss", intent: app.RestoreOriginal, err: "restoration evidence is insufficient; refusing to overwrite external changes"}, + } { + t.Run(test.name, func(t *testing.T) { + adapter := &fakeTarget{available: true, state: target.Drifted, setErr: errors.New(test.err)} + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), []target.Target{adapter}) + if err != nil { + t.Fatal(err) + } + createAndActivate(t, application) + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + + result := application.Dispatch(app.Intent{Kind: test.intent, TargetID: adapter.ID()}) + if result.Err == nil || !strings.Contains(result.Err.Error(), test.err) || result.Snapshot.Targets[0].State != target.Drifted || result.Snapshot.Targets[0].Evidence != "actual state" { + t.Fatalf("failure = %#v", result) + } + if len(adapter.calls) != 1 || adapter.calls[0] != (test.intent == app.ReapplyActiveProfile) { + t.Fatalf("SetDesired calls = %#v", adapter.calls) + } + }) + } +} + +func TestApplicationSnapshotDisablesProfileDependentActionsWithoutActiveProfile(t *testing.T) { + adapters := []target.Target{ + targetWithID{fakeTarget: &fakeTarget{available: true, state: target.Disabled}, id: "disabled"}, + targetWithID{fakeTarget: &fakeTarget{available: true, state: target.External}, id: "external"}, + targetWithID{fakeTarget: &fakeTarget{available: true, state: target.Drifted}, id: "drifted"}, + } + application, err := app.NewWithTargets(profile.NewFileStore(filepath.Join(t.TempDir(), "profiles.json")), adapters) + if err != nil { + t.Fatal(err) + } + if result := application.Dispatch(app.Intent{Kind: app.RefreshTargets}); result.Err != nil { + t.Fatal(result.Err) + } + + snapshot := application.Snapshot() + for _, index := range []int{0, 1} { + action := snapshot.Targets[index].PrimaryAction + if action.Available || !strings.Contains(action.DisabledReason, "no Active Profile") { + t.Errorf("profile-dependent primary action = %#v", action) + } + } + drift := snapshot.Targets[2] + if !drift.PrimaryAction.Available { + t.Fatalf("Restore Original should keep Resolve available: %#v", drift.PrimaryAction) + } + if got := drift.DriftResolutionActions[0]; got.Available || !strings.Contains(got.DisabledReason, "no Active Profile") { + t.Fatalf("Reapply action without Active Profile = %#v", got) + } + if got := drift.DriftResolutionActions[1]; !got.Available { + t.Fatalf("Restore Original action without Active Profile = %#v", got) + } +} diff --git a/internal/app/target_test.go b/internal/app/target_test.go index 155dcab..25c4ebc 100644 --- a/internal/app/target_test.go +++ b/internal/app/target_test.go @@ -16,6 +16,7 @@ type fakeTarget struct { available bool state target.State setErr error + setState target.State confirmation string calls []bool } @@ -34,7 +35,9 @@ func (f *fakeTarget) Confirmation(bool) string { return f.confirmation } func (f *fakeTarget) SetDesired(_ context.Context, enabled bool, _ *profile.Profile) target.OperationResult { f.calls = append(f.calls, enabled) if f.setErr == nil { - if enabled { + if f.setState != "" { + f.state = f.setState + } else if enabled { f.state = target.Enabled } else { f.state = target.Disabled @@ -84,11 +87,11 @@ func TestApplicationRequiresTargetConfirmationBeforeMutation(t *testing.T) { } createAndActivate(t, application) - warning := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true}) + warning := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon"}) if warning.ConfirmationRequest == nil || warning.Err != nil || len(adapter.calls) != 0 || warning.Snapshot.Targets[0].State != target.Disabled { t.Fatalf("unconfirmed result = %#v, calls=%v", warning, adapter.calls) } - confirmed := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "docker-daemon", Enabled: true, Confirm: true}) + confirmed := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "docker-daemon", Confirm: true}) if confirmed.Err != nil || len(adapter.calls) != 1 || confirmed.Snapshot.Targets[0].State != target.Enabled { t.Fatalf("confirmed result = %#v, calls=%v", confirmed, adapter.calls) } diff --git a/internal/tui/contextual_actions_test.go b/internal/tui/contextual_actions_test.go new file mode 100644 index 0000000..9310e65 --- /dev/null +++ b/internal/tui/contextual_actions_test.go @@ -0,0 +1,135 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "proxyctl/internal/app" + "proxyctl/internal/target" +) + +func completeTargetSnapshot(state target.State, primary app.TargetAction) app.Snapshot { + return app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ + ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: state, PrimaryAction: primary, + }}} +} + +func TestCompleteModelRendersAndDispatchesContextualPrimaryActions(t *testing.T) { + tests := []struct { + name string + state target.State + action app.TargetActionKind + intent app.IntentKind + label string + }{ + {name: "Disabled", state: target.Disabled, action: app.EnableTargetAction, intent: app.EnableTarget, label: "Enable"}, + {name: "Enabled", state: target.Enabled, action: app.DisableTargetAction, intent: app.DisableTarget, label: "Disable"}, + {name: "External", state: target.External, action: app.ApplyProfileTargetAction, intent: app.ApplyProfileToTarget, label: "Apply Profile"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + initial := completeTargetSnapshot(test.state, app.TargetAction{Kind: test.action, Available: true}) + module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}} + model := resizedModel(t, module, themeModeNoColor, 120, 36) + view := model.View() + if !strings.Contains(view, "Action: "+test.label) || !strings.Contains(view, "Space: "+test.label) { + t.Fatalf("contextual action not rendered:\n%s", view) + } + if test.state == target.External && (!strings.Contains(view, "proxyctl will begin managing this configuration") || !strings.Contains(view, "while preserving restorable state")) { + t.Fatalf("Apply Profile ownership explanation missing:\n%s", view) + } + + updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + if command == nil || len(module.intents) != 1 || module.intents[0].Kind != test.intent { + t.Fatalf("intent = %#v, command=%v", module.intents, command != nil) + } + }) + } +} + +func TestCompleteModelOffersBothExplicitDriftResolutionIntents(t *testing.T) { + initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}) + initial.Targets[0].DriftResolutionActions = []app.TargetAction{ + {Kind: app.ReapplyActiveProfileAction, Available: true}, + {Kind: app.RestoreOriginalAction, Available: true}, + } + for _, test := range []struct { + key string + intent app.IntentKind + }{ + {key: "r", intent: app.ReapplyActiveProfile}, + {key: "o", intent: app.RestoreOriginal}, + } { + t.Run(test.key, func(t *testing.T) { + module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Notice: "done"}} + model := resizedModel(t, module, themeModeNoColor, 120, 36) + model = updateKey(model, " ") + view := model.View() + for _, want := range []string{"Drift Resolution", "Reapply Active Profile", "Restore Original"} { + if !strings.Contains(view, want) { + t.Fatalf("missing %q:\n%s", want, view) + } + } + updated, command := model.Update(keyMessage(test.key)) + model = updated.(Model) + if command == nil || len(module.intents) != 1 || module.intents[0].Kind != test.intent { + t.Fatalf("resolution intent = %#v, command=%v", module.intents, command != nil) + } + }) + } +} + +func TestCompleteModelUsesStructuredDisabledReasonWithoutInferringFromState(t *testing.T) { + initial := completeTargetSnapshot(target.Disabled, app.TargetAction{ + Kind: app.EnableTargetAction, DisabledReason: "No Active Profile is selected; create or select one before enabling", + }) + module := &targetModule{snapshot: initial} + model := resizedModel(t, module, themeModeNoColor, 120, 36) + view := model.View() + if !strings.Contains(view, "No Active Profile is selected") || !strings.Contains(view, "Space: Enable disabled") { + t.Fatalf("structured disabled reason missing:\n%s", view) + } + updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "No Active Profile is selected") { + t.Fatalf("disabled action mutated: intents=%#v\n%s", module.intents, model.View()) + } +} + +func TestCompleteModelRestoreOwnershipLossFailsTruthfully(t *testing.T) { + initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}) + initial.Targets[0].DriftResolutionActions = []app.TargetAction{ + {Kind: app.ReapplyActiveProfileAction, Available: true}, + {Kind: app.RestoreOriginalAction, Available: true}, + } + module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("restoration evidence is insufficient; external changes were not overwritten")}} + model := resizedModel(t, module, themeModeNoColor, 120, 36) + model = updateKey(model, " ") + updated, command := model.Update(keyMessage("o")) + model = updated.(Model) + if command == nil { + t.Fatal("Restore Original did not start") + } + updated, _ = model.Update(command()) + model = updated.(Model) + if len(module.intents) != 1 || module.intents[0].Kind != app.RestoreOriginal || !strings.Contains(model.View(), "Current error") || !strings.Contains(model.View(), "Drifted") { + t.Fatalf("ownership-loss result: intents=%#v\n%s", module.intents, model.View()) + } +} + +func TestCompleteModelCancelsDriftResolutionWithoutMutation(t *testing.T) { + initial := completeTargetSnapshot(target.Drifted, app.TargetAction{Kind: app.ResolveDriftTargetAction, Available: true}) + initial.Targets[0].DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: true}, {Kind: app.RestoreOriginalAction, Available: true}} + module := &targetModule{snapshot: initial} + model := resizedModel(t, module, themeModeNoColor, 120, 36) + model = updateKey(model, " ") + updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(Model) + if command != nil || len(module.intents) != 0 || strings.Contains(model.View(), "r: Reapply Active Profile") { + t.Fatalf("safe-default cancellation mutated: intents=%#v\n%s", module.intents, model.View()) + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index f88942b..888d774 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -5,6 +5,25 @@ import ( "proxyctl/internal/target" ) +func targetActionLabel(kind app.TargetActionKind) string { + switch kind { + case app.EnableTargetAction: + return "Enable" + case app.DisableTargetAction: + return "Disable" + case app.ApplyProfileTargetAction: + return "Apply Profile" + case app.ResolveDriftTargetAction: + return "Resolve" + case app.ReapplyActiveProfileAction: + return "Reapply Active Profile" + case app.RestoreOriginalAction: + return "Restore Original" + default: + return "Unavailable" + } +} + type dashboardPane uint8 const ( diff --git a/internal/tui/dashboard_view.go b/internal/tui/dashboard_view.go index 8b55ccb..ac2a8fb 100644 --- a/internal/tui/dashboard_view.go +++ b/internal/tui/dashboard_view.go @@ -142,6 +142,18 @@ func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { var details strings.Builder details.WriteString(p.theme.section.Render(item.Description.Name)) fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence) + if item.PrimaryAction.Kind != 0 { + fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind)) + if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" { + fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason) + } + switch item.PrimaryAction.Kind { + case app.ApplyProfileTargetAction: + details.WriteString("\nApply Profile means proxyctl will begin managing this configuration while preserving restorable state.") + case app.ResolveDriftTargetAction: + details.WriteString("\nResolve opens Drift Resolution without choosing a recovery direction for you.") + } + } if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil { fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr) } diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index f0537e0..49113e9 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -51,11 +51,11 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) { model := resizedModel(t, module, themeModeNoColor, 120, 36) model = updateKey(model, "down") model = resize(model, 80, 24) - if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") { + if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") { t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view) } model = resize(model, 120, 36) - if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") { + if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "Action: Apply Profile") { t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view) } @@ -199,7 +199,7 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl "State: ! Drifted", "Docker daemon selected evidence.", "Status: Refresh completed; selected Target remains actionable.", - "Space: toggle", + "Space: Resolve", "p: manage Proxy Profiles", ) @@ -209,7 +209,7 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl "> ! Drifted Docker daemon", "State: ! Drifted", "Docker daemon selected evidence.", - "Space: toggle", + "Space: Resolve", ) } diff --git a/internal/tui/model.go b/internal/tui/model.go index d3bab47..9fdaca3 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,7 +10,6 @@ import ( "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" - "proxyctl/internal/target" ) type page int @@ -31,22 +30,23 @@ type formState struct { } type Model struct { - 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) + application app.InteractiveModule + presentation terminalPresentation + snapshot app.Snapshot + page page + width int + height int + selected int + selectedTargetID string + dashboardFocus dashboardPane + showUnavailable bool + detailsOffset int + driftResolutionTargetID string + 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 @@ -237,27 +237,54 @@ type mutationAvailability struct { 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 { +// targetMutationAvailability consumes the Application's structured action +// policy. It adds only Model-owned serialization state and never derives an +// action from Target state or display text. +func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, 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"} + case !selected.PrimaryAction.Available: + reason := selected.PrimaryAction.DisabledReason + if reason == "" { + reason = "This Target action is unavailable" + } + return mutationAvailability{reason: reason, compactReason: "unavailable"} default: return mutationAvailability{available: true} } } +func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) { + for _, action := range selected.DriftResolutionActions { + if action.Kind == kind { + return action, true + } + } + return app.TargetAction{Kind: kind}, false +} + +func targetIntent(kind app.TargetActionKind, targetID string) (app.Intent, bool) { + intent := app.Intent{TargetID: targetID} + switch kind { + case app.EnableTargetAction: + intent.Kind = app.EnableTarget + case app.DisableTargetAction: + intent.Kind = app.DisableTarget + case app.ApplyProfileTargetAction: + intent.Kind = app.ApplyProfileToTarget + case app.ReapplyActiveProfileAction: + intent.Kind = app.ReapplyActiveProfile + case app.RestoreOriginalAction: + intent.Kind = app.RestoreOriginal + default: + return app.Intent{}, false + } + return intent, true +} + func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.operation.confirmation != nil { switch key.String() { @@ -273,6 +300,37 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } + if m.driftResolutionTargetID != "" { + switch key.String() { + case "enter", "esc", "n": + m.driftResolutionTargetID = "" + return m, nil + case "r", "o": + kind := app.ReapplyActiveProfileAction + if key.String() == "o" { + kind = app.RestoreOriginalAction + } + selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID) + if !ok { + m.driftResolutionTargetID = "" + m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"} + return m, nil + } + action, ok := driftResolutionAction(selected, kind) + if !ok || !action.Available { + reason := action.DisabledReason + if reason == "" { + reason = "This Drift Resolution action is unavailable" + } + m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason} + return m, nil + } + intent, _ := targetIntent(kind, selected.ID) + m.driftResolutionTargetID = "" + return m, m.startOperation(intent) + } + return m, nil + } if key.String() == "e" && m.operation.currentError != nil { m.operation.errorExpanded = !m.operation.errorExpanded m.operation.errorOffset = 0 @@ -356,13 +414,20 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { } case " ": selected, ok := catalog.selected(m.selectedTargetID) - availability := targetMutationAvailability(selected, ok, m.snapshot.ActiveProfile, m.operation.active || m.operation.quitRequested) + availability := targetMutationAvailability(selected, ok, 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} + if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction { + m.driftResolutionTargetID = selected.ID + break + } + intent, ok := targetIntent(selected.PrimaryAction.Kind, selected.ID) + if !ok { + m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"} + break + } return m, m.startOperation(intent) } @@ -553,26 +618,27 @@ func (m *Model) selectName(name string) { func (m Model) View() string { return m.presentation.Render(presentationState{ - snapshot: m.snapshot, - page: m.page, - width: m.width, - height: m.height, - selected: m.selected, - selectedTargetID: m.selectedTargetID, - dashboardFocus: m.dashboardFocus, - showUnavailable: m.showUnavailable, - detailsOffset: m.detailsOffset, - form: m.form, - confirm: m.confirm, - confirmation: m.operation.confirmation, - status: m.status, - 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, + snapshot: m.snapshot, + page: m.page, + width: m.width, + height: m.height, + selected: m.selected, + selectedTargetID: m.selectedTargetID, + dashboardFocus: m.dashboardFocus, + showUnavailable: m.showUnavailable, + detailsOffset: m.detailsOffset, + driftResolutionTargetID: m.driftResolutionTargetID, + form: m.form, + confirm: m.confirm, + confirmation: m.operation.confirmation, + status: m.status, + 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, }) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 731bcf1..968b227 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -11,6 +11,7 @@ import ( "proxyctl/internal/app" "proxyctl/internal/probe" "proxyctl/internal/profile" + "proxyctl/internal/target" ) type recordingModule struct { @@ -18,10 +19,43 @@ type recordingModule struct { intents []app.Intent } -func (m *recordingModule) Snapshot() app.Snapshot { return m.snapshot } +func withTestTargetActions(snapshot app.Snapshot) app.Snapshot { + for i := range snapshot.Targets { + item := &snapshot.Targets[i] + if item.PrimaryAction.Kind != 0 { + continue + } + switch item.State { + case target.Disabled: + item.PrimaryAction.Kind = app.EnableTargetAction + case target.Enabled: + item.PrimaryAction.Kind = app.DisableTargetAction + case target.External: + item.PrimaryAction.Kind = app.ApplyProfileTargetAction + case target.Drifted: + item.PrimaryAction.Kind = app.ResolveDriftTargetAction + item.DriftResolutionActions = []app.TargetAction{{Kind: app.ReapplyActiveProfileAction, Available: snapshot.ActiveProfile != ""}, {Kind: app.RestoreOriginalAction, Available: true}} + } + item.PrimaryAction.Available = item.Available && item.DetectionPhase == app.TargetReady + if item.PrimaryAction.Available && (item.State == target.Disabled || item.State == target.External) && snapshot.ActiveProfile == "" { + item.PrimaryAction.Available = false + item.PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first" + } + if item.DetectionPhase == app.TargetChecking { + item.PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked" + } else if item.DetectionPhase == app.TargetDetectionFailed { + item.PrimaryAction.DisabledReason = "Target mutation is disabled because this Target check failed; refresh Targets to retry" + } else if !item.Available { + item.PrimaryAction.DisabledReason = "Target mutation is disabled because this Target is unavailable" + } + } + return snapshot +} + +func (m *recordingModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) } func (m *recordingModule) Dispatch(intent app.Intent) app.Result { m.intents = append(m.intents, intent) - return app.Result{Snapshot: m.snapshot, Notice: "done"} + return app.Result{Snapshot: withTestTargetActions(m.snapshot), Notice: "done"} } func (m *recordingModule) StartOperation(intent app.Intent) app.OperationStream { result := m.Dispatch(intent) diff --git a/internal/tui/operation_coordination_test.go b/internal/tui/operation_coordination_test.go index c6625f2..69c066a 100644 --- a/internal/tui/operation_coordination_test.go +++ b/internal/tui/operation_coordination_test.go @@ -18,7 +18,7 @@ type interactiveScriptModule struct { intents []app.Intent } -func (m *interactiveScriptModule) Snapshot() app.Snapshot { return m.snapshot } +func (m *interactiveScriptModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) } func (m *interactiveScriptModule) Dispatch(intent app.Intent) app.Result { m.intents = append(m.intents, intent) return app.Result{Snapshot: m.snapshot} @@ -27,18 +27,18 @@ func (m *interactiveScriptModule) StartOperation(intent app.Intent) app.Operatio m.intents = append(m.intents, intent) stream := m.streams[0] m.streams = m.streams[1:] - return app.OperationStream{Snapshot: m.snapshot, Updates: stream} + return app.OperationStream{Snapshot: withTestTargetActions(m.snapshot), Updates: stream} } func operationSnapshot() app.Snapshot { - return app.Snapshot{ + return withTestTargetActions(app.Snapshot{ ActiveProfile: "office", Profiles: []profile.Profile{{Name: "office"}, {Name: "remote"}}, Targets: []app.TargetSnapshot{ {ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell details"}, Available: true, State: target.Disabled}, {ID: "apt", Description: target.Description{Name: "APT", Summary: "APT details"}, Available: true, State: target.Enabled}, }, - } + }) } func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, tea.Cmd) { @@ -194,6 +194,8 @@ func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) { module := &interactiveScriptModule{snapshot: checking} model := resizedModel(t, module, themeModeNoColor, 120, 36) model.snapshot.Targets[0].DetectionPhase = app.TargetChecking + model.snapshot.Targets[0].PrimaryAction.Available = false + model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled while this Target is still being checked" updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) model = updated.(Model) @@ -202,6 +204,8 @@ func TestCompleteModelExplainsDisabledTargetMutations(t *testing.T) { } model.snapshot.Targets[0].DetectionPhase = app.TargetReady + model.snapshot.Targets[0].PrimaryAction.Available = false + model.snapshot.Targets[0].PrimaryAction.DisabledReason = "Target mutation is disabled because there is no Active Profile; open Profile management first" 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") { diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 97f6910..8d5be8f 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "proxyctl/internal/app" "proxyctl/internal/profile" ) @@ -41,6 +42,13 @@ func (p terminalPresentation) mainView(model presentationState, width, height in operationProgress: model.operationProgress, }, } + if model.driftResolutionTargetID != "" && model.confirmation == nil { + if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok { + page.dashboard = nil + page.primary, page.context = p.driftResolutionView(selected) + page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/Esc: Cancel" + } + } if model.confirmation != nil { var details strings.Builder details.WriteString(p.theme.section.Render(model.confirmation.Title)) @@ -57,6 +65,26 @@ func (p terminalPresentation) mainView(model presentationState, width, height in return page } +func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (string, string) { + var choices strings.Builder + fmt.Fprintf(&choices, "%s\nChoose how to resolve %s without an automatic recovery direction.", p.theme.section.Render("Drift Resolution — "+selected.Description.Name), selected.Description.Name) + for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} { + action, ok := driftResolutionAction(selected, kind) + label := targetActionLabel(kind) + if !ok || !action.Available { + reason := action.DisabledReason + if reason == "" { + reason = "unavailable" + } + fmt.Fprintf(&choices, "\n\n%s — unavailable: %s", label, reason) + continue + } + fmt.Fprintf(&choices, "\n\n%s", label) + } + context := "Reapply Active Profile writes the Active Profile again. Restore Original asks the Target Adapter to use its existing ownership and restoration safeguards; insufficient evidence fails safely without overwriting external changes." + return choices.String(), context +} + func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { if model.exitDeferred { return "Exit pending; browsing remains safe while the current mutation completes" @@ -74,11 +102,12 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t } actions := "↑/k ↓/j: select Tab: focus details " + visibility selected, ok := catalog.selected(model.selectedTargetID) - availability := targetMutationAvailability(selected, ok, model.snapshot.ActiveProfile, model.operationActive || model.exitDeferred) + availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred) + label := targetActionLabel(selected.PrimaryAction.Kind) if availability.available { - actions = "Space: toggle " + actions + actions = "Space: " + label + " " + actions } else { - actions = "Space: disabled (" + availability.compactReason + ") " + actions + actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions } footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q: quit" if model.operationActive { diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index 228f8cb..92f4379 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -53,26 +53,27 @@ func defaultModelOptions() modelOptions { } type presentationState struct { - snapshot app.Snapshot - page page - width int - height int - selected int - selectedTargetID string - dashboardFocus dashboardPane - showUnavailable bool - detailsOffset int - form formState - confirm bool - confirmation *app.Confirmation - status string - operationStatus app.OperationStatus - currentError *operationError - errorExpanded bool - errorOffset int - exitDeferred bool - operationActive bool - operationProgress map[string]app.TargetProgressPhase + snapshot app.Snapshot + page page + width int + height int + selected int + selectedTargetID string + dashboardFocus dashboardPane + showUnavailable bool + detailsOffset int + driftResolutionTargetID string + form formState + confirm bool + confirmation *app.Confirmation + status string + operationStatus app.OperationStatus + currentError *operationError + errorExpanded bool + errorOffset int + exitDeferred bool + operationActive bool + operationProgress map[string]app.TargetProgressPhase } // terminalPresentation is the terminal-presentation Module. Application diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index 1dc8b4c..ec3cb4e 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -18,9 +18,10 @@ type targetModule struct { intents []app.Intent } -func (m *targetModule) Snapshot() app.Snapshot { return m.snapshot } +func (m *targetModule) Snapshot() app.Snapshot { return withTestTargetActions(m.snapshot) } func (m *targetModule) Dispatch(intent app.Intent) app.Result { m.intents = append(m.intents, intent) + m.result.Snapshot = withTestTargetActions(m.result.Snapshot) m.snapshot = m.result.Snapshot return m.result } @@ -49,7 +50,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing module.result = app.Result{Snapshot: app.Snapshot{ActiveProfile: "office", Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Exports for Bash/Zsh"}, Available: true, State: target.Enabled, Evidence: "matches"}}}, Notice: "Shell Target is now Enabled"} model := NewModel(module) view := model.View() - if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Exports for Bash/Zsh") { + if !strings.Contains(view, "Shell") || strings.Contains(view, "APT") || !strings.Contains(view, "Action: Apply Profile") { t.Fatalf("Target panel:\n%s", view) } @@ -61,7 +62,7 @@ func TestTargetPanelShowsOnlyAvailableTargetsAndAppliesAsynchronously(t *testing message := command() updated, _ = model.Update(message) model = updated.(Model) - if len(module.intents) != 1 || module.intents[0].Kind != app.SetTargetDesired || !module.intents[0].Enabled || !strings.Contains(model.View(), "Enabled") { + if len(module.intents) != 1 || module.intents[0].Kind != app.ApplyProfileToTarget || !strings.Contains(model.View(), "Enabled") { t.Fatalf("completed toggle = %#v\n%s", module.intents, model.View()) } } @@ -109,7 +110,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) { }}} model := NewModel(module) view := model.View() - if strings.Contains(view, "APT") || !strings.Contains(view, "summary") || !strings.Contains(view, "integration installed") { + if strings.Contains(view, "APT") || !strings.Contains(view, "Action: Enable") || !strings.Contains(view, "integration installed") { t.Fatalf("main Target panel:\n%s", view) } model = updateKey(model, "?") @@ -346,17 +347,15 @@ func TestTargetPanelRendersEveryTargetState(t *testing.T) { } } -func TestDriftedTargetWithoutActiveProfileTogglesTowardDisabled(t *testing.T) { - initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}} - module := &targetModule{snapshot: initial, result: app.Result{Snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}}} +func TestDriftedTargetWithoutActiveProfileRequiresExplicitResolutionDirection(t *testing.T) { + initial := withTestTargetActions(app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Drifted}}}) + initial.Targets[0].DriftResolutionActions[0].DisabledReason = "Reapply Active Profile is unavailable because there is no Active Profile" + module := &targetModule{snapshot: initial} model := NewModel(module) - _, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) - if command == nil { - t.Fatal("Drifted Target did not start disable") - } - _ = command() - if len(module.intents) != 1 || module.intents[0].Enabled { - t.Fatalf("Drifted Target intent = %#v", module.intents) + updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace}) + model = updated.(Model) + if command != nil || len(module.intents) != 0 || !strings.Contains(model.View(), "Drift Resolution") || !strings.Contains(model.View(), "Reapply Active Profile is unavailable") { + t.Fatalf("Drifted Target chose a direction implicitly: intents=%#v\n%s", module.intents, model.View()) } }