pi-agent: Implement contextual Target actions
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+117
-51
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+32
-3
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-14
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user