Merge contextual Target actions with Profile workflows

This commit is contained in:
proxyctl coordinator
2026-07-17 23:18:59 +08:00
18 changed files with 1073 additions and 166 deletions
+108 -61
View File
@@ -11,7 +11,6 @@ import (
"proxyctl/internal/app"
"proxyctl/internal/probe"
"proxyctl/internal/profile"
"proxyctl/internal/target"
)
type page int
@@ -32,26 +31,27 @@ type formState struct {
}
type Model struct {
application app.InteractiveModule
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedProfileName string
profileViewportOffset int
profileDetailsOffset int
profileDelete *profileDeleteConfirmation
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
picker profilePickerState
form formState
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
selectedProfileName string
profileViewportOffset int
profileDetailsOffset int
profileDelete *profileDeleteConfirmation
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
picker profilePickerState
form formState
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
}
// probeDone carries one finished Protocol Probe back to the form. address is
@@ -252,27 +252,35 @@ 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"}
case operationBlocked:
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
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 (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.picker.open {
switch key.String() {
@@ -304,6 +312,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, _ := kind.Intent(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
@@ -393,13 +432,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 := selected.PrimaryAction.Kind.Intent(selected.ID)
if !ok {
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"}
break
}
return m, m.startOperation(intent)
}
@@ -662,30 +708,31 @@ func (m *Model) ensureProfileSelectionVisible() {
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,
profileOffset: m.profileViewportOffset,
profileDetails: m.profileDetailsOffset,
profileDelete: m.profileDelete,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
picker: m.picker,
form: m.form,
confirmation: m.operation.confirmation,
confirmationDialog: &m.operation.confirmationDialog,
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,
profileOffset: m.profileViewportOffset,
profileDetails: m.profileDetailsOffset,
profileDelete: m.profileDelete,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
driftResolutionTargetID: m.driftResolutionTargetID,
picker: m.picker,
form: m.form,
confirmation: m.operation.confirmation,
confirmationDialog: &m.operation.confirmationDialog,
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,
})
}