pi-agent: Fix final integrated review

This commit is contained in:
proxyctl coordinator
2026-07-18 02:37:54 +08:00
parent ed8b527f1f
commit 652d7eba2b
18 changed files with 566 additions and 393 deletions
+2 -21
View File
@@ -19,7 +19,6 @@ const (
UpdateProfile UpdateProfile
DeleteProfile DeleteProfile
ActivateProfile ActivateProfile
SetTargetDesired // retained for synchronous compatibility; interactive callers use the explicit intents below.
RefreshTargets RefreshTargets
EnableTarget EnableTarget
DisableTarget DisableTarget
@@ -34,7 +33,6 @@ type Intent struct {
Profile profile.Profile Profile profile.Profile
Confirm bool Confirm bool
TargetID string TargetID string
Enabled bool
} }
// TargetActionKind identifies a Target action independently of display labels. // TargetActionKind identifies a Target action independently of display labels.
@@ -726,9 +724,6 @@ func (a *Application) refreshInspections() {
} }
func isTargetMutationIntent(kind IntentKind) bool { func isTargetMutationIntent(kind IntentKind) bool {
if kind == SetTargetDesired {
return true
}
for _, policy := range targetActionPolicies { for _, policy := range targetActionPolicies {
if policy.intent == kind { if policy.intent == kind {
return true return true
@@ -737,10 +732,7 @@ func isTargetMutationIntent(kind IntentKind) bool {
return false return false
} }
func requestedTargetAction(intent Intent, selected TargetSnapshot) (TargetActionKind, bool) { func requestedTargetAction(intent Intent) (TargetActionKind, bool) {
if intent.Kind == SetTargetDesired {
return legacyTargetAction(intent.Enabled, selected.State)
}
for _, policy := range targetActionPolicies { for _, policy := range targetActionPolicies {
if policy.intent == intent.Kind { if policy.intent == intent.Kind {
return policy.kind, true return policy.kind, true
@@ -749,17 +741,6 @@ func requestedTargetAction(intent Intent, selected TargetSnapshot) (TargetAction
return 0, false return 0, false
} }
// legacyTargetAction is the only compatibility boundary for the former
// SetTargetDesired boolean contract. All current callers use TargetActionKind.
func legacyTargetAction(enabled bool, state target.State) (TargetActionKind, bool) {
for _, policy := range targetActionPolicies {
if policy.intent != 0 && policy.state == state && policy.desired == enabled {
return policy.kind, true
}
}
return 0, false
}
func structuredTargetAction(selected TargetSnapshot, kind TargetActionKind) (TargetAction, bool) { func structuredTargetAction(selected TargetSnapshot, kind TargetActionKind) (TargetAction, bool) {
if selected.PrimaryAction.Kind == kind { if selected.PrimaryAction.Kind == kind {
return selected.PrimaryAction, true return selected.PrimaryAction, true
@@ -773,7 +754,7 @@ func structuredTargetAction(selected TargetSnapshot, kind TargetActionKind) (Tar
} }
func validateTargetAction(intent Intent, selected TargetSnapshot) (targetActionPolicy, error) { func validateTargetAction(intent Intent, selected TargetSnapshot) (targetActionPolicy, error) {
kind, ok := requestedTargetAction(intent, selected) kind, ok := requestedTargetAction(intent)
if !ok { if !ok {
return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State) return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State)
} }
+8 -8
View File
@@ -92,7 +92,7 @@ func TestSecondApplicationUsesPersistedActiveProfileChangedByFirstApplication(t
if result := first.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "new"}); result.Err != nil { if result := first.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "new"}); result.Err != nil {
t.Fatalf("first Application activates new Profile: %v", result.Err) t.Fatalf("first Application activates new Profile: %v", result.Err)
} }
result := second.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) result := second.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "shell"})
if result.Err != nil { if result.Err != nil {
t.Fatalf("second Application enables Target: %v", result.Err) t.Fatalf("second Application enables Target: %v", result.Err)
} }
@@ -122,7 +122,7 @@ func TestActiveProfileSwitchCoordinatesEnabledTargetsWithoutRollback(t *testing.
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
for _, id := range []string{"first", "second"} { for _, id := range []string{"first", "second"} {
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil { if result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: id}); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
} }
@@ -192,7 +192,7 @@ func TestApplicationCoordinatesMultipleRealAndFakeHermeticAdaptersThroughPartial
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
for _, id := range []string{shellAdapter.ID(), dockerAdapter.ID(), failure.ID()} { for _, id := range []string{shellAdapter.ID(), dockerAdapter.ID(), failure.ID()} {
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil { if result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: id}); result.Err != nil {
t.Fatalf("enable %s: %v", id, result.Err) t.Fatalf("enable %s: %v", id, result.Err)
} }
} }
@@ -233,7 +233,7 @@ func TestActiveProfileProgressReusesAvailabilityCache(t *testing.T) {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}); result.Err != nil { if result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "shell"}); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
@@ -269,7 +269,7 @@ func TestActiveProfileSwitchStreamsApplyingAndCompletionForEachTarget(t *testing
t.Fatal(result.Err) t.Fatal(result.Err)
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}); result.Err != nil { if result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "shell"}); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
@@ -315,7 +315,7 @@ func TestDeletingFinalProfileLeavesManagedTargetsDriftedAndDisableStillRestoresE
t.Fatal(activated.Err) t.Fatal(activated.Err)
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
if enabled := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true}); enabled.Err != nil { if enabled := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: adapter.ID()}); enabled.Err != nil {
t.Fatal(enabled.Err) t.Fatal(enabled.Err)
} }
@@ -327,11 +327,11 @@ func TestDeletingFinalProfileLeavesManagedTargetsDriftedAndDisableStillRestoresE
if deleted.Err != nil || deleted.Snapshot.ActiveProfile != "" || deleted.Snapshot.Targets[0].State != target.Drifted { if deleted.Err != nil || deleted.Snapshot.ActiveProfile != "" || deleted.Snapshot.Targets[0].State != target.Drifted {
t.Fatalf("confirmed final delete = %#v", deleted) t.Fatalf("confirmed final delete = %#v", deleted)
} }
enable := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true}) enable := application.Dispatch(app.Intent{Kind: app.ReapplyActiveProfile, TargetID: adapter.ID()})
if enable.Err == nil || !strings.Contains(enable.Err.Error(), "Profile management") { if enable.Err == nil || !strings.Contains(enable.Err.Error(), "Profile management") {
t.Fatalf("re-enable without Active Profile = %v", enable.Err) t.Fatalf("re-enable without Active Profile = %v", enable.Err)
} }
disabled := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: false}) disabled := application.Dispatch(app.Intent{Kind: app.RestoreOriginal, TargetID: adapter.ID()})
if disabled.Err != nil || disabled.Snapshot.Targets[0].State != target.External { if disabled.Err != nil || disabled.Snapshot.Targets[0].State != target.External {
t.Fatalf("disable after final delete = %#v", disabled) t.Fatalf("disable after final delete = %#v", disabled)
} }
+1 -1
View File
@@ -127,7 +127,7 @@ func TestApplicationSerializesAndLogsTargetMutations(t *testing.T) {
adapter.inspection = target.Inspection{State: target.Enabled} adapter.inspection = target.Inspection{State: target.Enabled}
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
adapter.disableErr = errors.New("failed http://alice:secret@proxy.test/private") adapter.disableErr = errors.New("failed http://alice:secret@proxy.test/private")
result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "fake", Enabled: false}) result := application.Dispatch(app.Intent{Kind: app.DisableTarget, TargetID: "fake"})
if result.Err == nil { if result.Err == nil {
t.Fatal("Target failure reported success") t.Fatal("Target failure reported success")
} }
+63 -70
View File
@@ -42,6 +42,34 @@ const (
// exactly one of running, confirmation, succeeded, failed, or closed. // exactly one of running, confirmation, succeeded, failed, or closed.
type OperationState interface { type OperationState interface {
operationState() operationState()
decode() DecodedOperationUpdate
}
// OperationUpdateKind is the decoded variant of one operation stream update.
// Consumers decode once and handle the payload without repeating type switches.
type OperationUpdateKind uint8
const (
OperationRunning OperationUpdateKind = iota + 1
OperationConfirmation
OperationSucceeded
OperationFailed
OperationClosed
)
// DecodedOperationUpdate is the complete behavior-bearing projection of an
// OperationUpdate variant. Fields that do not apply to Kind retain zero values.
type DecodedOperationUpdate struct {
Kind OperationUpdateKind
Status OperationStatus
Progress []TargetProgress
Confirmation *Confirmation
Err error
FirstFailedTargetID string
}
func (u DecodedOperationUpdate) IsTerminal() bool {
return u.Kind != OperationRunning
} }
type operationRunning struct { type operationRunning struct {
@@ -50,10 +78,18 @@ type operationRunning struct {
} }
func (operationRunning) operationState() {} func (operationRunning) operationState() {}
func (state operationRunning) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{Kind: OperationRunning, Status: state.status, Progress: cloneProgress(state.progress)}
}
type operationConfirmation struct{ request Confirmation } type operationConfirmation struct{ request Confirmation }
func (operationConfirmation) operationState() {} func (operationConfirmation) operationState() {}
func (state operationConfirmation) decode() DecodedOperationUpdate {
request := state.request
request.Details = append([]string(nil), request.Details...)
return DecodedOperationUpdate{Kind: OperationConfirmation, Confirmation: &request}
}
type operationSucceeded struct { type operationSucceeded struct {
status OperationStatus status OperationStatus
@@ -61,6 +97,9 @@ type operationSucceeded struct {
} }
func (operationSucceeded) operationState() {} func (operationSucceeded) operationState() {}
func (state operationSucceeded) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{Kind: OperationSucceeded, Status: state.status, Progress: cloneProgress(state.progress)}
}
type operationFailed struct { type operationFailed struct {
status OperationStatus status OperationStatus
@@ -70,10 +109,19 @@ type operationFailed struct {
} }
func (operationFailed) operationState() {} func (operationFailed) operationState() {}
func (state operationFailed) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{
Kind: OperationFailed, Status: state.status, Progress: cloneProgress(state.progress),
Err: state.err, FirstFailedTargetID: state.firstFailedTargetID,
}
}
type operationClosed struct{ status OperationStatus } type operationClosed struct{ status OperationStatus }
func (operationClosed) operationState() {} func (operationClosed) operationState() {}
func (state operationClosed) decode() DecodedOperationUpdate {
return DecodedOperationUpdate{Kind: OperationClosed, Status: state.status}
}
// OperationUpdate is the common progressive contract used by interactive // OperationUpdate is the common progressive contract used by interactive
// detection, Target mutation, and Active Profile transition operations. Build // detection, Target mutation, and Active Profile transition operations. Build
@@ -116,79 +164,24 @@ func cloneProgress(progress []TargetProgress) []TargetProgress {
return append([]TargetProgress(nil), progress...) return append([]TargetProgress(nil), progress...)
} }
func (u OperationUpdate) Status() OperationStatus { // Decode centralizes the closed-set state decoding for all consumers.
switch state := u.State.(type) { func (u OperationUpdate) Decode() DecodedOperationUpdate {
case operationRunning: if u.State == nil {
return state.status return DecodedOperationUpdate{}
case operationSucceeded:
return state.status
case operationFailed:
return state.status
case operationClosed:
return state.status
default:
return OperationStatus{}
} }
return u.State.decode()
} }
func (u OperationUpdate) Progress() []TargetProgress { func (u OperationUpdate) Status() OperationStatus { return u.Decode().Status }
switch state := u.State.(type) { func (u OperationUpdate) Progress() []TargetProgress { return u.Decode().Progress }
case operationRunning: func (u OperationUpdate) Confirmation() *Confirmation { return u.Decode().Confirmation }
return cloneProgress(state.progress) func (u OperationUpdate) Err() error { return u.Decode().Err }
case operationSucceeded: func (u OperationUpdate) FirstFailedTargetID() string { return u.Decode().FirstFailedTargetID }
return cloneProgress(state.progress) func (u OperationUpdate) IsRunning() bool { return u.Decode().Kind == OperationRunning }
case operationFailed: func (u OperationUpdate) IsConfirmation() bool { return u.Decode().Kind == OperationConfirmation }
return cloneProgress(state.progress) func (u OperationUpdate) IsSucceeded() bool { return u.Decode().Kind == OperationSucceeded }
default: func (u OperationUpdate) IsFailed() bool { return u.Decode().Kind == OperationFailed }
return nil func (u OperationUpdate) IsTerminal() bool { return u.Decode().IsTerminal() }
}
}
func (u OperationUpdate) Confirmation() *Confirmation {
state, ok := u.State.(operationConfirmation)
if !ok {
return nil
}
request := state.request
request.Details = append([]string(nil), request.Details...)
return &request
}
func (u OperationUpdate) Err() error {
if state, ok := u.State.(operationFailed); ok {
return state.err
}
return nil
}
func (u OperationUpdate) FirstFailedTargetID() string {
if state, ok := u.State.(operationFailed); ok {
return state.firstFailedTargetID
}
return ""
}
func (u OperationUpdate) IsRunning() bool {
_, ok := u.State.(operationRunning)
return ok
}
func (u OperationUpdate) IsConfirmation() bool {
_, ok := u.State.(operationConfirmation)
return ok
}
func (u OperationUpdate) IsSucceeded() bool {
_, ok := u.State.(operationSucceeded)
return ok
}
func (u OperationUpdate) IsFailed() bool {
_, ok := u.State.(operationFailed)
return ok
}
func (u OperationUpdate) IsTerminal() bool { return !u.IsRunning() }
// OperationStream exposes the useful initial frame synchronously and all // OperationStream exposes the useful initial frame synchronously and all
// subsequent structured changes through Updates. Updates closes after a // subsequent structured changes through Updates. Updates closes after a
+6 -6
View File
@@ -97,7 +97,7 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) operation := application.StartOperation(app.Intent{Kind: app.EnableTarget, TargetID: "shell"})
running := <-operation.Updates running := <-operation.Updates
runningProgress := running.Progress() runningProgress := running.Progress()
if !running.IsRunning() || len(runningProgress) != 1 || runningProgress[0].Phase != app.TargetProgressRunning || runningProgress[0].TargetID != "shell" || running.Status().Kind != app.StatusProgress { if !running.IsRunning() || len(runningProgress) != 1 || runningProgress[0].Phase != app.TargetProgressRunning || runningProgress[0].TargetID != "shell" || running.Status().Kind != app.StatusProgress {
@@ -105,7 +105,7 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr
} }
<-adapter.started <-adapter.started
blocked := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: false}) blocked := application.StartOperation(app.Intent{Kind: app.DisableTarget, TargetID: "shell"})
blockedUpdate := <-blocked.Updates blockedUpdate := <-blocked.Updates
if !blockedUpdate.IsTerminal() || blockedUpdate.Status().Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status().Summary, "already in progress") { if !blockedUpdate.IsTerminal() || blockedUpdate.Status().Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status().Summary, "already in progress") {
t.Fatalf("blocked update = %#v", blockedUpdate) t.Fatalf("blocked update = %#v", blockedUpdate)
@@ -148,7 +148,7 @@ func TestDetectionOperationSerializesWithSynchronousMutation(t *testing.T) {
mutationDone := make(chan app.Result, 1) mutationDone := make(chan app.Result, 1)
go func() { go func() {
close(mutationAttempted) close(mutationAttempted)
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) mutationDone <- application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "shell"})
}() }()
<-mutationAttempted <-mutationAttempted
close(adapter.detectionRelease) close(adapter.detectionRelease)
@@ -182,7 +182,7 @@ func TestStartOperationReturnsBeforeDispatchSerializationIsAvailable(t *testing.
mutationDone := make(chan app.Result, 1) mutationDone := make(chan app.Result, 1)
go func() { go func() {
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) mutationDone <- application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: "shell"})
}() }()
<-adapter.started // barrier: synchronous Dispatch owns dispatch serialization <-adapter.started // barrier: synchronous Dispatch owns dispatch serialization
adapter.detectionStarted = make(chan struct{}) adapter.detectionStarted = make(chan struct{})
@@ -221,7 +221,7 @@ func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) {
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
operation := application.StartOperation(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true}) operation := application.StartOperation(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()})
update := <-operation.Updates update := <-operation.Updates
confirmation := update.Confirmation() confirmation := update.Confirmation()
if !update.IsConfirmation() || confirmation == nil || confirmation.Title == "" || confirmation.CancelLabel == "" { if !update.IsConfirmation() || confirmation == nil || confirmation.Title == "" || confirmation.CancelLabel == "" {
@@ -249,7 +249,7 @@ func TestInteractiveProfileTransitionReportsFirstFailureWithoutRollback(t *testi
} }
refreshApplicationTargets(t, application) refreshApplicationTargets(t, application)
for _, id := range []string{"first", "second"} { for _, id := range []string{"first", "second"} {
if result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: id, Enabled: true}); result.Err != nil { if result := application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: id}); result.Err != nil {
t.Fatal(result.Err) t.Fatal(result.Err)
} }
} }
+1 -1
View File
@@ -220,7 +220,7 @@ func TestDispatchMutationWaitsForInFlightTargetDetection(t *testing.T) {
<-adapter.started <-adapter.started
mutationDone := make(chan app.Result, 1) mutationDone := make(chan app.Result, 1)
go func() { go func() {
mutationDone <- application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: adapter.ID(), Enabled: true}) mutationDone <- application.Dispatch(app.Intent{Kind: app.EnableTarget, TargetID: adapter.ID()})
}() }()
close(adapter.release) close(adapter.release)
for range detection.Updates { for range detection.Updates {
+3 -3
View File
@@ -60,20 +60,20 @@ func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t *
t.Fatalf("initial Target snapshot = %#v", initial.Targets) t.Fatalf("initial Target snapshot = %#v", initial.Targets)
} }
result := application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) result := application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: "shell"})
if result.Err == nil || !strings.Contains(result.Err.Error(), "Profile management") || len(adapter.calls) != 0 || result.Snapshot.Targets[0].State != target.External { if result.Err == nil || !strings.Contains(result.Err.Error(), "Profile management") || len(adapter.calls) != 0 || result.Snapshot.Targets[0].State != target.External {
t.Fatalf("enable without Active Profile = %#v, calls=%v", result, adapter.calls) t.Fatalf("enable without Active Profile = %#v, calls=%v", result, adapter.calls)
} }
createAndActivate(t, application) createAndActivate(t, application)
result = application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: true}) result = application.Dispatch(app.Intent{Kind: app.ApplyProfileToTarget, TargetID: "shell"})
if result.Err != nil || result.Snapshot.Targets[0].State != target.Enabled || len(adapter.calls) != 1 { if result.Err != nil || result.Snapshot.Targets[0].State != target.Enabled || len(adapter.calls) != 1 {
t.Fatalf("enable result = %#v", result) t.Fatalf("enable result = %#v", result)
} }
adapter.setErr = errors.New("permission denied") adapter.setErr = errors.New("permission denied")
adapter.state = target.Drifted adapter.state = target.Drifted
result = application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: "shell", Enabled: false}) result = application.Dispatch(app.Intent{Kind: app.RestoreOriginal, TargetID: "shell"})
if result.Err == nil || result.Snapshot.Targets[0].State != target.Drifted || result.Snapshot.Targets[0].Evidence != "actual state" { if result.Err == nil || result.Snapshot.Targets[0].State != target.Drifted || result.Snapshot.Targets[0].Evidence != "actual state" {
t.Fatalf("failed operation not truthful: %#v", result) t.Fatalf("failed operation not truthful: %#v", result)
} }
+1 -1
View File
@@ -213,7 +213,7 @@ func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFai
close(updates) close(updates)
model, _ = applyOperationCommand(t, model, command) model, _ = applyOperationCommand(t, model, command)
view := model.View() view := model.View()
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") { if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Drifted ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") {
t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view) t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
} }
} }
+17
View File
@@ -38,6 +38,23 @@ func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t
} }
} }
func TestCompleteModelFailedProgressSupplementsTruthfulTargetState(t *testing.T) {
for _, state := range []target.State{target.Enabled, target.Disabled, target.External, target.Drifted} {
t.Run(string(state), func(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{{
ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: state,
}}}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
model.operation.progress["shell"] = app.TargetProgressFailed
view := model.View()
wantState := targetStateLabel(state)
if !strings.Contains(view, "> "+wantState+" ! Failed Shell") {
t.Fatalf("failed progress replaced truthful %s state instead of supplementing it:\n%s", state, view)
}
})
}
}
func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t *testing.T) { func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t *testing.T) {
module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{ module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled, Evidence: "fragment absent"}, {ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled, Evidence: "fragment absent"},
+22 -14
View File
@@ -17,10 +17,13 @@ type dashboardRenderState struct {
focus dashboardPane focus dashboardPane
detailsOffset int detailsOffset int
operationProgress map[string]app.TargetProgressPhase operationProgress map[string]app.TargetProgressPhase
applyingFrame string
terminalWidth int
terminalHeight int
} }
func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string { func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string {
if width >= wideWidth && height >= 12 { if state.terminalWidth >= wideWidth && state.terminalHeight >= wideHeight {
leftWidth := (width - 1) * 2 / 5 leftWidth := (width - 1) * 2 / 5
rightWidth := width - leftWidth - 1 rightWidth := width - leftWidth - 1
left := p.renderTargetListPane(state, leftWidth, height) left := p.renderTargetListPane(state, leftWidth, height)
@@ -49,7 +52,7 @@ func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, w
if item.ID == state.selectedTargetID { if item.ID == state.selectedTargetID {
cursor = "> " cursor = "> "
} }
badge := p.targetBadge(item, state.operationProgress[item.ID]) badge := p.targetBadge(item, state.operationProgress[item.ID], state.applyingFrame)
lines = append(lines, cursor+badge+" "+item.Description.Name) lines = append(lines, cursor+badge+" "+item.Description.Name)
} }
if len(lines) == 0 { if len(lines) == 0 {
@@ -70,7 +73,7 @@ func (p terminalPresentation) renderTargetDetailsPane(state dashboardRenderState
selected, ok := state.catalog.selected(state.selectedTargetID) selected, ok := state.catalog.selected(state.selectedTargetID)
content := "Select a Target to review its details." content := "Select a Target to review its details."
if ok { if ok {
content = p.targetDetails(selected) content = p.targetDetails(selected, state.operationProgress[selected.ID], state.applyingFrame)
} }
body := fixedViewport(content, width, bodyHeight, state.detailsOffset, true) body := fixedViewport(content, width, bodyHeight, state.detailsOffset, true)
return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height) return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height)
@@ -101,27 +104,32 @@ func fixedViewport(content string, width, height, offset int, wrap bool) string
return view.View() return view.View()
} }
func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase) string { func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string {
switch progress {
case app.TargetProgressRunning:
return p.theme.renderTargetState(item.State, true)
case app.TargetProgressFailed:
return p.theme.checkFailed.Render("! Failed")
}
switch item.DetectionPhase { switch item.DetectionPhase {
case app.TargetChecking: case app.TargetChecking:
return p.theme.checking.Render("… Checking") return p.theme.checking.Render("… Checking")
case app.TargetDetectionFailed: case app.TargetDetectionFailed:
return p.theme.checkFailed.Render("! Check failed") return p.theme.checkFailed.Render("! Failed")
} }
if !item.Available { if !item.Available {
return p.theme.unavailable.Render("○ Unavailable") return p.theme.unavailable.Render("○ Unavailable")
} }
return p.theme.renderTargetState(item.State, false) actual := p.theme.renderTargetState(item.State, false)
switch progress {
case app.TargetProgressRunning:
if applyingFrame == "" {
applyingFrame = "|"
}
return p.theme.applying.Render(applyingFrame + " Applying")
case app.TargetProgressFailed:
return actual + " " + p.theme.checkFailed.Render("! Failed")
default:
return actual
}
} }
func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string {
state := p.targetBadge(item, 0) state := p.targetBadge(item, progress, applyingFrame)
evidence := item.Evidence evidence := item.Evidence
switch item.DetectionPhase { switch item.DetectionPhase {
case app.TargetChecking: case app.TargetChecking:
+31
View File
@@ -46,6 +46,37 @@ func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
}) })
} }
func TestCompleteModelWideLayoutUsesOriginalTerminalBoundary(t *testing.T) {
module := responsiveLayoutModule()
for _, test := range []struct {
name string
width int
height int
wantWide bool
expandError bool
}{
{name: "one row below height boundary", width: 100, height: 27},
{name: "exact boundary", width: 100, height: 28, wantWide: true},
{name: "expanded error does not change exact boundary", width: 100, height: 28, wantWide: true, expandError: true},
} {
t.Run(test.name, func(t *testing.T) {
model := resizedModel(t, module, themeModeNoColor, test.width, test.height)
if test.expandError {
model.operation.currentError = &operationError{
status: app.OperationStatus{Kind: app.StatusError, Summary: "Target update failed"},
detail: strings.Repeat("expanded diagnostic line\n", 20),
}
model.operation.errorExpanded = true
}
view := model.View()
if got := sameLine(view, "Targets", "Target details"); got != test.wantWide {
t.Fatalf("side-by-side = %v, want %v at %dx%d (expanded=%v):\n%s", got, test.wantWide, test.width, test.height, test.expandError, view)
}
})
}
}
func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) { func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
module := responsiveLayoutModule() module := responsiveLayoutModule()
model := resizedModel(t, module, themeModeNoColor, 120, 36) model := resizedModel(t, module, themeModeNoColor, 120, 36)
+37 -35
View File
@@ -57,54 +57,56 @@ func (m Model) dashboardQuitAvailable() bool {
} }
func (p terminalPresentation) helpDialogView(model presentationState) terminalPage { func (p terminalPresentation) helpDialogView(model presentationState) terminalPage {
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(protectRenderedText(p.contextualHelp(model))) content := lipgloss.NewStyle().Width(max(1, model.terminal.width)).Render(protectRenderedText(p.contextualHelp(model)))
exitHint := "Ctrl+C: exit" exitHint := "Ctrl+C: exit"
if model.operationActive { if model.operation.active {
exitHint = "Ctrl+C: defer safe exit" exitHint = "Ctrl+C: defer safe exit"
} }
if model.exitDeferred { if model.operation.exitDeferred {
exitHint = "Exit pending until safe completion" exitHint = "Exit pending until safe completion"
} }
return terminalPage{ return terminalPage{
primary: content, primary: content,
footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint, footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint,
focusLine: -1, focusLine: -1,
primaryOffset: model.help.offset, primaryOffset: model.help.dialog.offset,
} }
} }
func (p terminalPresentation) contextualHelp(model presentationState) string { func (p terminalPresentation) contextualHelp(model presentationState) string {
dashboard, operation := model.dashboard, model.operation
profiles, form := model.profile, model.form.state
var body strings.Builder var body strings.Builder
body.WriteString(p.theme.section.Render("Contextual Help")) body.WriteString(p.theme.section.Render("Contextual Help"))
body.WriteString("\nThis describes the page or dialog beneath Help. While Help is open, only its scrolling and closing keys plus global Ctrl+C are accepted; the underlying state remains unchanged.\n\n") body.WriteString("\nThis describes the page or dialog beneath Help. While Help is open, only its scrolling and closing keys plus global Ctrl+C are accepted; the underlying state remains unchanged.\n\n")
switch { switch {
case model.confirmation != nil: case operation.confirmation != nil:
body.WriteString(p.theme.section.Render("Safety confirmation")) body.WriteString(p.theme.section.Render("Safety confirmation"))
fmt.Fprintf(&body, "\n%s\n", model.confirmation.Title) fmt.Fprintf(&body, "\n%s\n", operation.confirmation.Title)
if model.confirmation.Summary != "" { if operation.confirmation.Summary != "" {
fmt.Fprintf(&body, "%s\n", model.confirmation.Summary) fmt.Fprintf(&body, "%s\n", operation.confirmation.Summary)
} }
body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ") body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ")
if model.confirmationDialog != nil { if operation.confirmationDialog != nil {
body.WriteByte('\n') body.WriteByte('\n')
body.WriteString(confirmationFocusDescription(*model.confirmationDialog)) body.WriteString(confirmationFocusDescription(*operation.confirmationDialog))
} }
body.WriteByte('\n') body.WriteByte('\n')
case model.profileDelete != nil: case profiles.deletion != nil:
body.WriteString(p.theme.section.Render("Delete confirmation")) body.WriteString(p.theme.section.Render("Delete confirmation"))
fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", model.profileDelete.name) fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", profiles.deletion.name)
body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ") body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ")
body.WriteByte('\n') body.WriteByte('\n')
body.WriteString(confirmationFocusDescription(model.profileDelete.dialog)) body.WriteString(confirmationFocusDescription(profiles.deletion.dialog))
body.WriteByte('\n') body.WriteByte('\n')
case model.picker.open: case dashboard.picker.open:
body.WriteString(p.theme.section.Render("Active Profile picker")) body.WriteString(p.theme.section.Render("Active Profile picker"))
body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down or Ctrl+P/Ctrl+N selects, and PgUp/PgDn moves by a page. Enter activates the selection. Escape cancels.\n") body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down or Ctrl+P/Ctrl+N selects, and PgUp/PgDn moves by a page. Enter activates the selection. Escape cancels.\n")
case model.driftResolutionTargetID != "": case dashboard.driftResolutionTargetID != "":
body.WriteString(p.theme.section.Render("Drift Resolution dialog")) body.WriteString(p.theme.section.Render("Drift Resolution dialog"))
body.WriteString("\nr reapplies the Active Profile. o restores original configuration when ownership evidence permits. Enter, q, or Escape cancels.\n") body.WriteString("\nr reapplies the Active Profile. o restores original configuration when ownership evidence permits. Enter, q, or Escape cancels.\n")
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok { if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok {
for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} { for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} {
action, found := driftResolutionAction(selected, kind) action, found := driftResolutionAction(selected, kind)
if found && action.Available { if found && action.Available {
@@ -120,10 +122,10 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
} }
case model.page == profilesPage: case model.page == profilesPage:
body.WriteString(p.theme.section.Render("Profile management")) body.WriteString(p.theme.section.Render("Profile management"))
if selected, ok := profileAt(model.snapshot, model.selected); ok { if selected, ok := profileAt(model.snapshot, profiles.selected); ok {
fmt.Fprintf(&body, "\nSelected Profile: %s.\n", selected.Name) fmt.Fprintf(&body, "\nSelected Profile: %s.\n", selected.Name)
body.WriteString("Up/Down or j/k selects a Profile. PgUp/PgDn scrolls its details. n creates, e or Enter edits, and d opens the delete confirmation. q or Escape returns to the dashboard.\n") body.WriteString("Up/Down or j/k selects a Profile. PgUp/PgDn scrolls its details. n creates, e or Enter edits, and d opens the delete confirmation. q or Escape returns to the dashboard.\n")
if model.operationActive || model.exitDeferred { if operation.active || operation.exitDeferred {
body.WriteString("Profile modifications are disabled until the current mutation completes.\n") body.WriteString("Profile modifications are disabled until the current mutation completes.\n")
} else { } else {
body.WriteString("Create, Edit selected Profile, and Delete selected Profile are available.\n") body.WriteString("Create, Edit selected Profile, and Delete selected Profile are available.\n")
@@ -133,36 +135,36 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
} }
case model.page == formPage: case model.page == formPage:
body.WriteString(p.theme.section.Render("Profile form")) body.WriteString(p.theme.section.Render("Profile form"))
if model.form.field >= 0 && model.form.field < len(model.form.inputs) { if form.field >= 0 && form.field < len(form.inputs) {
fmt.Fprintf(&body, "\nFocus: %s field.\n", model.form.inputs[model.form.field].label) fmt.Fprintf(&body, "\nFocus: %s field.\n", form.inputs[form.field].label)
} }
body.WriteString("Type or paste to insert at the cursor. Left/Right moves the cursor; Home/End moves to the field boundary; Backspace/Delete removes text. Long inputs scroll horizontally. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode while preserving Profile values. Ctrl+S validates and saves. q or Escape cancels and returns to Profile management.\n") body.WriteString("Type or paste to insert at the cursor. Left/Right moves the cursor; Home/End moves to the field boundary; Backspace/Delete removes text. Long inputs scroll horizontally. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode while preserving Profile values. Ctrl+S validates and saves. q or Escape cancels and returns to Profile management.\n")
if model.form.currentField() == profile.BypassField { if form.currentField() == profile.BypassField {
body.WriteString("PgUp/PgDn scrolls the normalized Bypass preview while the Bypass field is focused.\n") body.WriteString("PgUp/PgDn scrolls the normalized Bypass preview while the Bypass field is focused.\n")
} }
if model.form.profile.Mode == profile.SingleMode { if form.profile.Mode == profile.SingleMode {
switch { switch {
case model.form.probe.active: case form.probe.active:
body.WriteString("Protocol Probe is in progress for the Single proxy URL field. Its progress remains attached to that field; Ctrl+D does not start a duplicate probe while that field is focused.\n") body.WriteString("Protocol Probe is in progress for the Single proxy URL field. Its progress remains attached to that field; Ctrl+D does not start a duplicate probe while that field is focused.\n")
case model.form.currentField() == profile.SingleURLField: case form.currentField() == profile.SingleURLField:
body.WriteString("Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field. It never runs on form entry, focus changes, or save.\n") body.WriteString("Ctrl+D explicitly runs Protocol Probe for the focused Single proxy URL field. It never runs on form entry, focus changes, or save.\n")
default: default:
body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n") body.WriteString("The Single proxy URL field visibly offers Protocol Probe; focus that field and press Ctrl+D to run it explicitly.\n")
} }
} else if model.form.hasProbeSourceAttachment() { } else if form.hasProbeSourceAttachment() {
body.WriteString("The Protocol Probe result remains attached to its read-only initiating Single proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from that probe.\n") body.WriteString("The Protocol Probe result remains attached to its read-only initiating Single proxy URL context. The HTTP, HTTPS, and ALL/SOCKS values were generated from that probe.\n")
} }
default: default:
body.WriteString(p.theme.section.Render("Dashboard")) body.WriteString(p.theme.section.Render("Dashboard"))
focus := "Targets focus" focus := "Targets focus"
if model.dashboardFocus == targetDetailsPane { if dashboard.focus == targetDetailsPane {
focus = "Target details focus" focus = "Target details focus"
} }
fmt.Fprintf(&body, "\nContext: %s.\n", focus) fmt.Fprintf(&body, "\nContext: %s.\n", focus)
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID) catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
if selected, ok := catalog.selected(model.selectedTargetID); ok { if selected, ok := catalog.selected(dashboard.selectedTargetID); ok {
fmt.Fprintf(&body, "Selected Target: %s. State: %s.\n", selected.Description.Name, targetStateLabel(selected.State)) fmt.Fprintf(&body, "Selected Target: %s. State: %s.\n", selected.Description.Name, targetStateLabel(selected.State))
availability := targetMutationAvailability(selected, true, model.operationActive || model.exitDeferred) availability := targetMutationAvailability(selected, true, operation.active || operation.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind) label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available { if availability.available {
fmt.Fprintf(&body, "Primary action: %s — available with Space.\n", label) fmt.Fprintf(&body, "Primary action: %s — available with Space.\n", label)
@@ -170,12 +172,12 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
fmt.Fprintf(&body, "Primary action: %s — disabled: %s.\n", label, availability.reason) fmt.Fprintf(&body, "Primary action: %s — disabled: %s.\n", label, availability.reason)
} }
} }
if model.dashboardFocus == targetDetailsPane { if dashboard.focus == targetDetailsPane {
body.WriteString("Up/Down, j/k, and PgUp/PgDn scroll Target details. Tab focuses Targets.\n") body.WriteString("Up/Down, j/k, and PgUp/PgDn scroll Target details. Tab focuses Targets.\n")
} else { } else {
body.WriteString("Up/Down or j/k selects a Target. Tab focuses Target details.\n") body.WriteString("Up/Down or j/k selects a Target. Tab focuses Target details.\n")
} }
if model.operationActive || model.exitDeferred { if operation.active || operation.exitDeferred {
body.WriteString("u still shows or hides unavailable Targets. p, m, r, and Space modifications are disabled until the current mutation completes.\n") body.WriteString("u still shows or hides unavailable Targets. p, m, r, and Space modifications are disabled until the current mutation completes.\n")
} else { } else {
body.WriteString("u shows or hides unavailable Targets. p opens the Active Profile picker. m opens Profile management. r refreshes Targets.\n") body.WriteString("u shows or hides unavailable Targets. p opens the Active Profile picker. m opens Profile management. r refreshes Targets.\n")
@@ -185,18 +187,18 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
body.WriteString("\n") body.WriteString("\n")
body.WriteString(p.theme.section.Render("Operation state")) body.WriteString(p.theme.section.Render("Operation state"))
switch { switch {
case model.exitDeferred: case operation.exitDeferred:
body.WriteString("\nExit is pending. Read-only browsing and Help remain available; all new work is disabled until the mutation completes safely.\n") body.WriteString("\nExit is pending. Read-only browsing and Help remain available; all new work is disabled until the mutation completes safely.\n")
case model.operationActive: case operation.active:
body.WriteString("\nMutation in progress. Target selection, details scrolling, and Help remain available.\nConflicting mutations are disabled until completion. Ctrl+C requests a deferred safe exit.\n") body.WriteString("\nMutation in progress. Target selection, details scrolling, and Help remain available.\nConflicting mutations are disabled until completion. Ctrl+C requests a deferred safe exit.\n")
default: default:
body.WriteString("\nNo mutation is in progress. Ctrl+C exits immediately.\n") body.WriteString("\nNo mutation is in progress. Ctrl+C exits immediately.\n")
} }
if model.currentError != nil && model.page == mainPage && model.confirmation == nil && !model.picker.open && model.driftResolutionTargetID == "" { if operation.currentError != nil && model.page == mainPage && operation.confirmation == nil && !dashboard.picker.open && dashboard.driftResolutionTargetID == "" {
body.WriteString("e expands or collapses the current error; x acknowledges it.\n") body.WriteString("e expands or collapses the current error; x acknowledges it.\n")
} }
if model.page == mainPage && model.profileDelete == nil { if model.page == mainPage && profiles.deletion == nil {
body.WriteString("\n") body.WriteString("\n")
body.WriteString(p.theme.section.Render("Built-in Target reference")) body.WriteString(p.theme.section.Render("Built-in Target reference"))
body.WriteString("\n") body.WriteString("\n")
+58 -71
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app" "proxyctl/internal/app"
@@ -91,12 +92,7 @@ func targetsChecking(snapshot app.Snapshot) bool {
return false return false
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd { return m.operation.wait() }
if m.operation.updates != nil {
return waitForOperation(m.operation.updates)
}
return nil
}
// busy is the single-operation guard for asynchronous Application work. Every // busy is the single-operation guard for asynchronous Application work. Every
// operation start checks it, so a completion can only belong to the one current // operation start checks it, so a completion can only belong to the one current
@@ -105,12 +101,6 @@ func (m Model) busy() bool { return m.operation.busy() }
func (m Model) operationInFlight() bool { return m.operation.active } func (m Model) operationInFlight() bool { return m.operation.active }
func (m Model) dashboardNavigationAvailable() bool {
// A deferred exit rejects new work, not read-only browsing. Selection,
// details, and Help remain useful until the current mutation is safe.
return true
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok { if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width m.width = size.Width
@@ -141,6 +131,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
} }
if tick, ok := message.(spinner.TickMsg); ok {
return m, m.operation.tick(tick)
}
if progress, ok := message.(operationProgress); ok { if progress, ok := message.(operationProgress); ok {
if !m.operation.active || progress.updates != m.operation.updates { if !m.operation.active || progress.updates != m.operation.updates {
return m, nil return m, nil
@@ -170,7 +163,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if progress.update.IsTerminal() { if progress.update.IsTerminal() {
return m, m.operation.deferredQuit() return m, m.operation.deferredQuit()
} }
return m, waitForOperation(progress.updates) return m, m.operation.wait()
} }
if done, ok := message.(probeDone); ok { if done, ok := message.(probeDone); ok {
return m, m.form.applyProbeResult(done.token, done.caps, done.err) return m, m.form.applyProbeResult(done.token, done.caps, done.err)
@@ -346,44 +339,36 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets}) return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
} }
case "tab": case "tab":
if m.dashboardNavigationAvailable() { if m.dashboardFocus == targetListPane {
if m.dashboardFocus == targetListPane { m.dashboardFocus = targetDetailsPane
m.dashboardFocus = targetDetailsPane } else {
} else { m.dashboardFocus = targetListPane
m.dashboardFocus = targetListPane
}
} }
case "u": case "u":
if m.dashboardNavigationAvailable() { formerIndex := catalog.index(m.selectedTargetID)
formerIndex := catalog.index(m.selectedTargetID) m.showUnavailable = !m.showUnavailable
m.showUnavailable = !m.showUnavailable m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex) m.detailsOffset = 0
case "up", "k":
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0 m.detailsOffset = 0
} }
case "up", "k":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0
}
}
case "down", "j": case "down", "j":
if m.dashboardNavigationAvailable() { if m.dashboardFocus == targetDetailsPane {
if m.dashboardFocus == targetDetailsPane { m.detailsOffset++
m.detailsOffset++ } else {
} else { m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.selectedTargetID = catalog.move(m.selectedTargetID, 1) m.detailsOffset = 0
m.detailsOffset = 0
}
} }
case "pgup": case "pgup":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5) m.detailsOffset = max(0, m.detailsOffset-5)
} }
case "pgdown": case "pgdown":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { if m.dashboardFocus == targetDetailsPane {
m.detailsOffset += 5 m.detailsOffset += 5
} }
case " ": case " ":
@@ -641,39 +626,41 @@ func (m *Model) ensureProfileSelectionVisible() {
func (m Model) View() string { func (m Model) View() string {
return m.presentation.Render(presentationState{ return m.presentation.Render(presentationState{
snapshot: m.snapshot, snapshot: m.snapshot,
page: m.page, page: m.page,
width: m.width, terminal: terminalDimensions{width: m.width, height: m.height},
height: m.height, dashboard: dashboardPresentationState{
selected: m.selected, selectedTargetID: m.selectedTargetID,
profileOffset: m.profileViewportOffset, focus: m.dashboardFocus,
profileDetails: m.profileDetailsOffset, showUnavailable: m.showUnavailable,
profileDelete: m.profileDelete, detailsOffset: m.detailsOffset,
selectedTargetID: m.selectedTargetID, driftResolutionTargetID: m.driftResolutionTargetID,
dashboardFocus: m.dashboardFocus, picker: m.picker,
showUnavailable: m.showUnavailable, },
detailsOffset: m.detailsOffset, profile: profilePresentationState{
driftResolutionTargetID: m.driftResolutionTargetID, selected: m.selected,
picker: m.picker, listOffset: m.profileViewportOffset,
form: m.form, detailsOffset: m.profileDetailsOffset,
help: m.help, deletion: m.profileDelete,
confirmation: m.operation.confirmation, status: m.status,
confirmationDialog: &m.operation.confirmationDialog, },
status: m.status, form: formPresentationState{state: m.form},
operationStatus: m.operation.status, help: helpPresentationState{dialog: m.help},
currentError: m.operation.currentError, operation: operationPresentationState{
errorExpanded: m.operation.errorExpanded, confirmation: m.operation.confirmation,
errorOffset: m.operation.errorOffset, confirmationDialog: &m.operation.confirmationDialog,
exitDeferred: m.operation.quitRequested && m.operationInFlight(), status: m.operation.status,
operationActive: m.operation.active, currentError: m.operation.currentError,
operationProgress: m.operation.progress, errorExpanded: m.operation.errorExpanded,
errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
active: m.operation.active,
progress: m.operation.progress,
spinnerFrame: m.operation.spinner.View(),
},
}) })
} }
func (m Model) availableTargets() []app.TargetSnapshot {
return availableTargets(m.snapshot)
}
func (m *Model) retainTargetSelection() { func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID) catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID)) m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
+82 -3
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app" "proxyctl/internal/app"
@@ -50,6 +51,35 @@ func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, t
return updated.(Model), next return updated.(Model), next
} }
func TestCompleteModelUsesAnimatedBubblesSpinnerOnlyWhileOperationRuns(t *testing.T) {
updates := make(chan app.OperationUpdate)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
if command == nil || !strings.Contains(model.View(), "| Applying") {
t.Fatalf("operation did not start the Bubbles spinner:\n%s", model.View())
}
updated, next := model.Update(spinner.TickMsg{ID: model.operation.spinner.ID()})
model = updated.(Model)
if next == nil || !strings.Contains(model.View(), "/ Applying") {
t.Fatalf("spinner tick did not animate the in-row Applying marker:\n%s", model.View())
}
terminal := app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
updated, _ = model.Update(operationProgress{update: terminal, updates: updates})
model = updated.(Model)
terminalView := model.View()
updated, stale := model.Update(spinner.TickMsg{ID: model.operation.spinner.ID()})
model = updated.(Model)
if stale != nil || model.View() != terminalView || strings.Contains(model.View(), "Applying") {
t.Fatalf("spinner tick changed terminal operation state:\n%s", model.View())
}
}
func TestCompleteModelKeepsDashboardAndHelpBrowsableWhileMutationIsSerialized(t *testing.T) { func TestCompleteModelKeepsDashboardAndHelpBrowsableWhileMutationIsSerialized(t *testing.T) {
updates := make(chan app.OperationUpdate, 2) updates := make(chan app.OperationUpdate, 2)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}} module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{updates}}
@@ -129,7 +159,32 @@ func TestCompleteModelPreservesExpandableCurrentErrorAndFocusesFirstFailedTarget
} }
} }
func TestCompleteModelClearsCurrentErrorForDirectlyRelatedSuccessfulDetection(t *testing.T) { func TestCompleteModelReadOnlyRefreshDoesNotClearTargetMutationErrorForSameTarget(t *testing.T) {
failure := make(chan app.OperationUpdate, 1)
refresh := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, refresh}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model)
failure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target update failed", TargetID: "shell"}, nil, "shell", errors.New("permission denied"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
refresh <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(refresh)
model, _ = applyOperationCommand(t, model, command)
if !strings.Contains(model.View(), "Current error: Shell Target update failed") {
t.Fatalf("read-only Refresh Targets cleared an unresolved mutation error:\n%s", model.View())
}
}
func TestCompleteModelRelatedTargetMutationClearsErrorForSameTarget(t *testing.T) {
failure := make(chan app.OperationUpdate, 1) failure := make(chan app.OperationUpdate, 1)
resolution := make(chan app.OperationUpdate, 1) resolution := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}} module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}}
@@ -145,12 +200,36 @@ func TestCompleteModelClearsCurrentErrorForDirectlyRelatedSuccessfulDetection(t
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace}) updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
model = updated.(Model) model = updated.(Model)
resolution <- app.SucceededOperationUpdate(module.snapshot, resolution <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Shell Target check completed", TargetID: "shell"}, app.OperationStatus{Summary: "Shell Target is now Enabled", TargetID: "shell"},
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}}) []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}})
close(resolution) close(resolution)
model, _ = applyOperationCommand(t, model, command) model, _ = applyOperationCommand(t, model, command)
if strings.Contains(model.View(), "Current error:") { if strings.Contains(model.View(), "Current error:") {
t.Fatalf("explicit related resolution did not clear the current error:\n%s", model.View()) t.Fatalf("explicit related Target resolution did not clear the current error:\n%s", model.View())
}
}
func TestCompleteModelSuccessfulRefreshClearsOnlyRefreshError(t *testing.T) {
failure := make(chan app.OperationUpdate, 1)
resolution := make(chan app.OperationUpdate, 1)
module := &interactiveScriptModule{snapshot: operationSnapshot(), streams: []chan app.OperationUpdate{failure, resolution}}
model := resizedModel(t, module, themeModeNoColor, 120, 36)
updated, command := model.Update(keyMessage("r"))
model = updated.(Model)
failure <- app.FailedOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Some Target checks failed", TargetID: "shell"}, nil, "shell", errors.New("inspection failed"))
close(failure)
model, _ = applyOperationCommand(t, model, command)
updated, command = model.Update(keyMessage("r"))
model = updated.(Model)
resolution <- app.SucceededOperationUpdate(module.snapshot,
app.OperationStatus{Summary: "Target availability and state checks completed"}, nil)
close(resolution)
model, _ = applyOperationCommand(t, model, command)
if strings.Contains(model.View(), "Current error:") {
t.Fatalf("successful Refresh Targets did not clear its own error:\n%s", model.View())
} }
} }
+94 -42
View File
@@ -1,8 +1,9 @@
package tui package tui
import ( import (
"errors" "time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app" "proxyctl/internal/app"
@@ -11,11 +12,12 @@ import (
type operationError struct { type operationError struct {
status app.OperationStatus status app.OperationStatus
detail string detail string
intent app.Intent
} }
// operationCoordinator owns the complete interactive operation lifecycle. The // operationCoordinator owns the complete interactive operation lifecycle. The
// root Model delegates stream consumption, progress, confirmation, current // root Model delegates stream consumption, progress, confirmation, current
// error, and deferred-exit state here instead of mirroring those states. // error, spinner, and deferred-exit state here instead of mirroring them.
type operationCoordinator struct { type operationCoordinator struct {
active bool active bool
intent app.Intent intent app.Intent
@@ -29,6 +31,7 @@ type operationCoordinator struct {
confirmationDialog confirmationDialog confirmationDialog confirmationDialog
confirmationIntent *app.Intent confirmationIntent *app.Intent
quitRequested bool quitRequested bool
spinner spinner.Model
} }
type operationProgress struct { type operationProgress struct {
@@ -37,20 +40,50 @@ type operationProgress struct {
closed bool closed bool
} }
func newOperationCoordinator() operationCoordinator { var applyingSpinner = spinner.Spinner{
return operationCoordinator{progress: make(map[string]app.TargetProgressPhase)} Frames: []string{"|", "/", "-", "\\"},
FPS: 90 * time.Millisecond,
} }
func waitForOperation(updates <-chan app.OperationUpdate) tea.Cmd { func newOperationCoordinator() operationCoordinator {
return func() tea.Msg { return operationCoordinator{
update, ok := <-updates progress: make(map[string]app.TargetProgressPhase),
if !ok { spinner: spinner.New(spinner.WithSpinner(applyingSpinner)),
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
} }
} }
// waitForOperation races the stream against the real Bubbles tick command.
// Only the stream is read directly, so a losing tick cannot consume an update.
func waitForOperation(updates <-chan app.OperationUpdate, tick tea.Cmd) tea.Cmd {
return func() tea.Msg {
if tick == nil {
update, ok := <-updates
if !ok {
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
}
ticks := make(chan tea.Msg, 1)
go func() { ticks <- tick() }()
select {
case update, ok := <-updates:
if !ok {
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
case message := <-ticks:
return message
}
}
}
func (o operationCoordinator) wait() tea.Cmd {
if !o.active || o.updates == nil {
return nil
}
return waitForOperation(o.updates, o.spinner.Tick)
}
func (o *operationCoordinator) start(application app.InteractiveModule, intent app.Intent) (app.Snapshot, tea.Cmd, bool) { func (o *operationCoordinator) start(application app.InteractiveModule, intent app.Intent) (app.Snapshot, tea.Cmd, bool) {
if o.quitRequested || o.active || o.confirmation != nil { if o.quitRequested || o.active || o.confirmation != nil {
o.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current operation to finish before starting another modification"} o.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current operation to finish before starting another modification"}
@@ -61,11 +94,12 @@ func (o *operationCoordinator) start(application app.InteractiveModule, intent a
o.intent = intent o.intent = intent
o.updates = stream.Updates o.updates = stream.Updates
o.progress = make(map[string]app.TargetProgressPhase) o.progress = make(map[string]app.TargetProgressPhase)
o.spinner = spinner.New(spinner.WithSpinner(applyingSpinner))
if intent.TargetID != "" { if intent.TargetID != "" {
o.progress[intent.TargetID] = app.TargetProgressRunning o.progress[intent.TargetID] = app.TargetProgressRunning
} }
o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)} o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
return stream.Snapshot, waitForOperation(stream.Updates), true return stream.Snapshot, o.wait(), true
} }
func operationStartingSummary(kind app.IntentKind) string { func operationStartingSummary(kind app.IntentKind) string {
@@ -79,34 +113,38 @@ func operationStartingSummary(kind app.IntentKind) string {
} }
} }
func (o *operationCoordinator) tick(message spinner.TickMsg) tea.Cmd {
if !o.active {
return nil
}
updated, nextTick := o.spinner.Update(message)
o.spinner = updated
return waitForOperation(o.updates, nextTick)
}
func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTargetID string) { func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTargetID string) {
status := update.Status() decoded := update.Decode()
progress := update.Progress() for _, item := range decoded.Progress {
for _, item := range progress {
o.progress[item.TargetID] = item.Phase o.progress[item.TargetID] = item.Phase
if item.Phase == app.TargetProgressSucceeded {
o.resolveTargetError(item.TargetID)
}
} }
if status.Summary != "" { if decoded.Status.Summary != "" {
o.status = status o.status = decoded.Status
} }
if update.IsSucceeded() { switch decoded.Kind {
o.resolveTargetError(status.TargetID) case app.OperationSucceeded:
} o.resolveError(o.intent)
if update.IsFailed() { case app.OperationFailed:
o.currentError = &operationError{status: status, detail: errorDetail(update.Err())} o.currentError = &operationError{status: decoded.Status, detail: errorDetail(decoded.Err), intent: o.intent}
o.errorExpanded = false o.errorExpanded = false
o.errorOffset = 0 o.errorOffset = 0
firstFailedTargetID = update.FirstFailedTargetID() firstFailedTargetID = decoded.FirstFailedTargetID
} case app.OperationConfirmation:
if request := update.Confirmation(); request != nil {
intent := o.intent intent := o.intent
o.confirmation = request o.confirmation = decoded.Confirmation
o.confirmationDialog = newConfirmationDialog(*request) o.confirmationDialog = newConfirmationDialog(*decoded.Confirmation)
o.confirmationIntent = &intent o.confirmationIntent = &intent
} }
if update.IsTerminal() { if decoded.IsTerminal() {
o.finish() o.finish()
} }
return firstFailedTargetID return firstFailedTargetID
@@ -119,8 +157,8 @@ func errorDetail(err error) string {
return err.Error() return err.Error()
} }
func (o *operationCoordinator) resolveTargetError(targetID string) { func (o *operationCoordinator) resolveError(success app.Intent) {
if targetID == "" || o.currentError == nil || o.currentError.status.TargetID != targetID { if o.currentError == nil || !relatedResolution(o.currentError.intent, success) {
return return
} }
o.currentError = nil o.currentError = nil
@@ -128,6 +166,28 @@ func (o *operationCoordinator) resolveTargetError(targetID string) {
o.errorOffset = 0 o.errorOffset = 0
} }
func relatedResolution(failed, success app.Intent) bool {
switch {
case failed.Kind == app.RefreshTargets:
return success.Kind == app.RefreshTargets
case failed.Kind == app.ActivateProfile:
return success.Kind == app.ActivateProfile && failed.Name == success.Name
case isTargetMutation(failed.Kind):
return isTargetMutation(success.Kind) && failed.TargetID != "" && failed.TargetID == success.TargetID
default:
return false
}
}
func isTargetMutation(kind app.IntentKind) bool {
switch kind {
case app.EnableTarget, app.DisableTarget, app.ApplyProfileToTarget, app.ReapplyActiveProfile, app.RestoreOriginal:
return true
default:
return false
}
}
func (o *operationCoordinator) finish() { func (o *operationCoordinator) finish() {
o.active = false o.active = false
o.intent = app.Intent{} o.intent = app.Intent{}
@@ -139,6 +199,7 @@ func (o *operationCoordinator) unexpectedClose() {
o.currentError = &operationError{ o.currentError = &operationError{
status: o.status, status: o.status,
detail: "The Application operation stream closed before publishing a terminal result.", detail: "The Application operation stream closed before publishing a terminal result.",
intent: o.intent,
} }
o.errorExpanded = false o.errorExpanded = false
o.errorOffset = 0 o.errorOffset = 0
@@ -191,12 +252,3 @@ func (o *operationCoordinator) requestQuit() tea.Cmd {
} }
return tea.Quit return tea.Quit
} }
func (o *operationCoordinator) fail(err error, summary string) {
if err == nil {
err = errors.New(summary)
}
o.status = app.OperationStatus{Kind: app.StatusError, Summary: summary}
o.currentError = &operationError{status: o.status, detail: err.Error()}
o.finish()
}
+61 -54
View File
@@ -9,6 +9,7 @@ import (
) )
func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage { func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage {
dashboard, operation := model.dashboard, model.operation
activeProfile := model.snapshot.ActiveProfile activeProfile := model.snapshot.ActiveProfile
if activeProfile == "" { if activeProfile == "" {
activeProfile = "No Active Profile" activeProfile = "No Active Profile"
@@ -20,11 +21,11 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot)) p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot))
} }
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID) catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
footer := p.dashboardFooter(model, catalog) footer := p.dashboardFooter(model, catalog)
if model.currentError != nil { if operation.currentError != nil {
footer += "\ne: expand/collapse current error x: acknowledge error" footer += "\ne: expand/collapse current error x: acknowledge error"
if model.errorExpanded { if operation.errorExpanded {
footer += " PgUp/PgDn: scroll error details" footer += " PgUp/PgDn: scroll error details"
} }
} }
@@ -33,22 +34,25 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
footer: footer, footer: footer,
dashboard: &dashboardRenderState{ dashboard: &dashboardRenderState{
catalog: catalog, catalog: catalog,
selectedTargetID: model.selectedTargetID, selectedTargetID: dashboard.selectedTargetID,
focus: model.dashboardFocus, focus: dashboard.focus,
detailsOffset: model.detailsOffset, detailsOffset: dashboard.detailsOffset,
operationProgress: model.operationProgress, operationProgress: operation.progress,
applyingFrame: operation.spinnerFrame,
terminalWidth: width,
terminalHeight: height,
}, },
} }
if model.picker.open { if dashboard.picker.open {
page.dashboard = nil page.dashboard = nil
page.primary = model.picker.view(model.snapshot, width, max(5, height-9)) page.primary = dashboard.picker.view(model.snapshot, width, max(5, height-9))
page.footer = "Type to search ↑/↓ PgUp/PgDn: select Enter: activate\nEsc: cancel ?: help" page.footer = "Type to search ↑/↓ PgUp/PgDn: select Enter: activate\nEsc: cancel ?: help"
if width < 70 { if width < 70 {
page.footer = "Type: search ↑/↓: select Enter: activate\nEsc: cancel ?: help" page.footer = "Type: search ↑/↓: select Enter: activate\nEsc: cancel ?: help"
} }
} }
if model.driftResolutionTargetID != "" && model.confirmation == nil { if dashboard.driftResolutionTargetID != "" && operation.confirmation == nil {
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok { if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok {
page.dashboard = nil page.dashboard = nil
page.primary, page.context = p.driftResolutionView(selected) page.primary, page.context = p.driftResolutionView(selected)
page.wrapPrimary = true page.wrapPrimary = true
@@ -56,10 +60,10 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/q/Esc: cancel ?: help" page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/q/Esc: cancel ?: help"
} }
} }
if model.confirmation != nil { if operation.confirmation != nil {
dialog := newConfirmationDialog(*model.confirmation) dialog := newConfirmationDialog(*operation.confirmation)
if model.confirmationDialog != nil { if operation.confirmationDialog != nil {
dialog = *model.confirmationDialog dialog = *operation.confirmationDialog
} }
page.dashboard = nil page.dashboard = nil
page.primary = dialog.view() page.primary = dialog.view()
@@ -93,71 +97,73 @@ func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (
} }
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
dashboard, operation, width := model.dashboard, model.operation, model.terminal.width
visibility := "u: show unavailable" visibility := "u: show unavailable"
if model.showUnavailable { if dashboard.showUnavailable {
visibility = "u: hide unavailable" visibility = "u: hide unavailable"
} }
if model.dashboardFocus == targetDetailsPane { if dashboard.focus == targetDetailsPane {
if model.exitDeferred { if operation.exitDeferred {
return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion" return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion"
} }
if model.width < wideWidth { if width < wideWidth {
if model.operationActive { if operation.active {
return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets\n?: help q/Ctrl+C: defer exit; mutations disabled" return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets\n?: help q/Ctrl+C: defer exit; mutations disabled"
} }
if model.width < 70 { if width < 70 {
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Profile m: manage ?: help q: quit" return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Profile m: manage ?: help q: quit"
} }
return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit" return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit"
} }
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: switch Active Profile m: manage Proxy Profiles ?: help q/Ctrl+C: quit" footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: switch Active Profile m: manage Proxy Profiles ?: help q/Ctrl+C: quit"
if model.operationActive { if operation.active {
footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + " ?: help\nq/Ctrl+C: defer safe exit; mutations disabled" footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + " ?: help\nq/Ctrl+C: defer safe exit; mutations disabled"
} }
return footer return footer
} }
actions := "↑/k ↓/j: select Tab: focus details " + visibility actions := "↑/k ↓/j: select Tab: focus details " + visibility
if model.exitDeferred { if operation.exitDeferred {
return actions + " ?: help\nExit pending; new work disabled until safe completion" return actions + " ?: help\nExit pending; new work disabled until safe completion"
} }
selected, ok := catalog.selected(model.selectedTargetID) selected, ok := catalog.selected(dashboard.selectedTargetID)
availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred) availability := targetMutationAvailability(selected, ok, operation.active || operation.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind) label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available { if availability.available {
actions = "Space: " + label + " " + actions actions = "Space: " + label + " " + actions
} else { } else {
actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions
} }
if model.width < wideWidth { if width < wideWidth {
if availability.available { if availability.available {
actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details" actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details"
} else { } else {
actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: focus details" actions = "Space: " + label + " disabled ↑/k ↓/j: select Tab: focus details"
} }
if model.operationActive { if operation.active {
return actions + "\n?: help q/Ctrl+C: defer exit; mutations disabled" return actions + "\n?: help q/Ctrl+C: defer exit; mutations disabled"
} }
if model.width < 70 { if width < 70 {
return actions + "\np: switch Profile m: manage ?: help q: quit" return actions + "\np: switch Profile m: manage ?: help q: quit"
} }
return actions + "\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit" return actions + "\np: switch Active Profile m: manage Proxy Profiles ?: help q: quit"
} }
footer := actions + "\np: switch Active Profile m: manage Proxy Profiles r: refresh ?: help q/Ctrl+C: quit" footer := actions + "\np: switch Active Profile m: manage Proxy Profiles r: refresh ?: help q/Ctrl+C: quit"
if model.operationActive { if operation.active {
footer = actions + "\n?: help q: defer safe exit; new mutations disabled" footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
} }
return footer return footer
} }
func (p terminalPresentation) profilesView(model presentationState) terminalPage { func (p terminalPresentation) profilesView(model presentationState) terminalPage {
state := model.profile
var list strings.Builder var list strings.Builder
if len(model.snapshot.Profiles) == 0 { if len(model.snapshot.Profiles) == 0 {
list.WriteString("No Proxy Profiles saved. Press n to create one.") list.WriteString("No Proxy Profiles saved. Press n to create one.")
} }
for i, item := range model.snapshot.Profiles { for i, item := range model.snapshot.Profiles {
cursor, active := " ", " " cursor, active := " ", " "
if i == model.selected { if i == state.selected {
cursor = "> " cursor = "> "
} }
if item.Name == model.snapshot.ActiveProfile { if item.Name == model.snapshot.ActiveProfile {
@@ -171,17 +177,17 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
context := selectedProfileDetails(p, model) context := selectedProfileDetails(p, model)
footer := "n: create e: edit Enter: edit d: delete PgUp/PgDn: scroll details\n↑/k ↓/j: select ?: help q/Esc: back" footer := "n: create e: edit Enter: edit d: delete PgUp/PgDn: scroll details\n↑/k ↓/j: select ?: help q/Esc: back"
if model.width < 80 { if model.terminal.width < 80 {
footer = "↑/k ↓/j: select PgUp/PgDn: details n: create\ne/Enter: edit d: delete ?: help q/Esc: back" footer = "↑/k ↓/j: select PgUp/PgDn: details n: create\ne/Enter: edit d: delete ?: help q/Esc: back"
} }
if len(model.snapshot.Profiles) == 0 { if len(model.snapshot.Profiles) == 0 {
footer = "n: create a Proxy Profile ?: help q/Esc: back" footer = "n: create a Proxy Profile ?: help q/Esc: back"
} }
if model.profileDelete != nil { if state.deletion != nil {
return terminalPage{ return terminalPage{
header: p.theme.title.Render("Proxy Profile management"), header: p.theme.title.Render("Proxy Profile management"),
primary: model.profileDelete.dialog.view(), primary: state.deletion.dialog.view(),
primaryOffset: model.profileDelete.dialog.offset, primaryOffset: state.deletion.dialog.offset,
wrapPrimary: true, wrapPrimary: true,
footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help", footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help",
focusLine: -1, focusLine: -1,
@@ -192,9 +198,9 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
primary: list.String(), primary: list.String(),
context: context, context: context,
footer: footer, footer: footer,
focusLine: model.selected, focusLine: state.selected,
primaryOffset: model.profileOffset, primaryOffset: state.listOffset,
contextOffset: model.profileDetails, contextOffset: state.detailsOffset,
wrapContext: true, wrapContext: true,
} }
} }
@@ -204,7 +210,7 @@ func sanitizeFormText(value string) string {
} }
func selectedProfileDetails(p terminalPresentation, model presentationState) string { func selectedProfileDetails(p terminalPresentation, model presentationState) string {
selected, ok := profileAt(model.snapshot, model.selected) selected, ok := profileAt(model.snapshot, model.profile.selected)
if !ok { if !ok {
return "" return ""
} }
@@ -233,36 +239,37 @@ func selectedProfileDetails(p terminalPresentation, model presentationState) str
} }
func (p terminalPresentation) formView(model presentationState) terminalPage { func (p terminalPresentation) formView(model presentationState) terminalPage {
form, width := model.form.state, model.terminal.width
action := "Create" action := "Create"
if model.form.originalName != "" { if form.originalName != "" {
action = "Edit" action = "Edit"
} }
var fields, bypassPreview strings.Builder var fields, bypassPreview strings.Builder
focusLine, currentLine := 0, 0 focusLine, currentLine := 0, 0
for i, item := range model.form.inputs { for i, item := range form.inputs {
if fields.Len() > 0 { if fields.Len() > 0 {
fields.WriteByte('\n') fields.WriteByte('\n')
currentLine++ currentLine++
} }
cursor, focus := " ", "" cursor, focus := " ", ""
if i == model.form.field { if i == form.field {
cursor, focus = "> ", " [focused]" cursor, focus = "> ", " [focused]"
focusLine = currentLine focusLine = currentLine
} }
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, model.form.renderedInput(i, model.width)) fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, form.renderedInput(i, width))
if probeText := model.form.probeText(item.field); probeText != "" { if probeText := form.probeText(item.field); probeText != "" {
fmt.Fprintf(&fields, "\n %s", probeText) fmt.Fprintf(&fields, "\n %s", probeText)
currentLine++ currentLine++
} }
if item.field == profile.NameField && model.form.hasProbeSourceAttachment() { if item.field == profile.NameField && form.hasProbeSourceAttachment() {
fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(model.form.probe.sourceValue)) fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(form.probe.sourceValue))
fmt.Fprintf(&fields, "\n %s", model.form.probe.feedback) fmt.Fprintf(&fields, "\n %s", form.probe.feedback)
fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:") fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:")
currentLine += 3 currentLine += 3
} }
validation := model.form.validationText(i) validation := form.validationText(i)
if item.field == profile.NameField { if item.field == profile.NameField {
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" { if conflict := form.nameConflict(model.snapshot.Profiles); conflict != "" {
validation = conflict validation = conflict
} }
} }
@@ -272,7 +279,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
currentLine++ currentLine++
} }
if item.field == profile.BypassField { if item.field == profile.BypassField {
analysis := model.form.bypassAnalysis() analysis := form.bypassAnalysis()
bypassPreview.WriteString("Normalized Bypass preview:") bypassPreview.WriteString("Normalized Bypass preview:")
if len(analysis) == 0 { if len(analysis) == 0 {
bypassPreview.WriteString(" (none)") bypassPreview.WriteString(" (none)")
@@ -284,24 +291,24 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
} }
} }
secondaryHints := "?: help q/Esc: cancel" secondaryHints := "?: help q/Esc: cancel"
if model.form.currentField() == profile.BypassField { if form.currentField() == profile.BypassField {
secondaryHints = "PgUp/PgDn: scroll form preview " + secondaryHints secondaryHints = "PgUp/PgDn: scroll form preview " + secondaryHints
} }
if model.form.profile.Mode == profile.SingleMode && model.form.currentField() == profile.SingleURLField { if form.profile.Mode == profile.SingleMode && form.currentField() == profile.SingleURLField {
probeHint := "Ctrl+D: probe" probeHint := "Ctrl+D: probe"
if model.form.probe.active { if form.probe.active {
probeHint = "Protocol Probe in progress" probeHint = "Protocol Probe in progress"
} }
secondaryHints = probeHint + " " + secondaryHints secondaryHints = probeHint + " " + secondaryHints
} }
footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints
return terminalPage{ return terminalPage{
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)), header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, form.profile.Mode)),
primary: fields.String(), primary: fields.String(),
context: bypassPreview.String(), context: bypassPreview.String(),
footer: footer, footer: footer,
focusLine: focusLine, focusLine: focusLine,
contextAnchor: model.form.bypassPreviewLine, contextAnchor: form.bypassPreviewLine,
anchorContext: true, anchorContext: true,
} }
} }
+79 -53
View File
@@ -56,32 +56,57 @@ func defaultModelOptions() modelOptions {
} }
type presentationState struct { type presentationState struct {
snapshot app.Snapshot snapshot app.Snapshot
page page page page
width int terminal terminalDimensions
height int dashboard dashboardPresentationState
selected int profile profilePresentationState
profileOffset int form formPresentationState
profileDetails int help helpPresentationState
profileDelete *profileDeleteConfirmation operation operationPresentationState
}
type terminalDimensions struct {
width int
height int
}
type dashboardPresentationState struct {
selectedTargetID string selectedTargetID string
dashboardFocus dashboardPane focus dashboardPane
showUnavailable bool showUnavailable bool
detailsOffset int detailsOffset int
driftResolutionTargetID string driftResolutionTargetID string
picker profilePickerState picker profilePickerState
form formState }
help helpDialogState
confirmation *app.Confirmation type profilePresentationState struct {
confirmationDialog *confirmationDialog selected int
status string listOffset int
operationStatus app.OperationStatus detailsOffset int
currentError *operationError deletion *profileDeleteConfirmation
errorExpanded bool status string
errorOffset int }
exitDeferred bool
operationActive bool type formPresentationState struct {
operationProgress map[string]app.TargetProgressPhase state formState
}
type helpPresentationState struct {
dialog helpDialogState
}
type operationPresentationState struct {
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
status app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
active bool
progress map[string]app.TargetProgressPhase
spinnerFrame string
} }
// terminalPresentation is the terminal-presentation Module. Application // terminalPresentation is the terminal-presentation Module. Application
@@ -116,7 +141,7 @@ func (p terminalPresentation) Render(state presentationState) string {
// semanticTheme style is applied; otherwise an attacker could inject one of // semanticTheme style is applied; otherwise an attacker could inject one of
// the same allowlisted SGR sequences that the theme legitimately emits. // the same allowlisted SGR sequences that the theme legitimately emits.
state = sanitizePresentationState(state) state = sanitizePresentationState(state)
width, height := state.width, state.height width, height := state.terminal.width, state.terminal.height
if width <= 0 { if width <= 0 {
width = 80 width = 80
} }
@@ -138,7 +163,7 @@ func (p terminalPresentation) Render(state presentationState) string {
default: default:
page = p.mainView(state, width, height) page = p.mainView(state, width, height)
} }
if state.help.top(state.confirmation) { if state.help.dialog.top(state.operation.confirmation) {
help := p.helpDialogView(state) help := p.helpDialogView(state)
// Retain the underlying page header so Help is visibly contextual and // Retain the underlying page header so Help is visibly contextual and
// modal rather than a replacement navigation destination. // modal rather than a replacement navigation destination.
@@ -151,8 +176,8 @@ func (p terminalPresentation) Render(state presentationState) string {
page.dashboard = nil page.dashboard = nil
} }
page.status = renderStatus(state) page.status = renderStatus(state)
page.statusExpanded = state.errorExpanded && state.currentError != nil page.statusExpanded = state.operation.errorExpanded && state.operation.currentError != nil
page.statusOffset = state.errorOffset page.statusOffset = state.operation.errorOffset
page = redactTerminalPage(page) page = redactTerminalPage(page)
// Untrusted values were redacted and stripped of controls before semantic // Untrusted values were redacted and stripped of controls before semantic
// styling. The final pass therefore removes unexpected terminal controls // styling. The final pass therefore removes unexpected terminal controls
@@ -162,17 +187,18 @@ func (p terminalPresentation) Render(state presentationState) string {
func renderStatus(state presentationState) string { func renderStatus(state presentationState) string {
var lines []string var lines []string
if state.exitDeferred { operation := state.operation
if operation.exitDeferred {
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely") lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
} else if state.operationStatus.Summary != "" { } else if operation.status.Summary != "" {
lines = append(lines, state.operationStatus.Summary) lines = append(lines, operation.status.Summary)
} else if state.status != "" { } else if state.profile.status != "" {
lines = append(lines, state.status) lines = append(lines, state.profile.status)
} }
if state.currentError != nil { if operation.currentError != nil {
errorText := "Current error: " + state.currentError.status.Summary errorText := "Current error: " + operation.currentError.status.Summary
if state.errorExpanded && state.currentError.detail != "" { if operation.errorExpanded && operation.currentError.detail != "" {
errorText += "\n" + state.currentError.detail errorText += "\n" + operation.currentError.detail
} }
lines = append(lines, errorText) lines = append(lines, errorText)
} }
@@ -236,35 +262,35 @@ func sanitizePresentationState(state presentationState) presentationState {
// Apply that stronger policy to every status/error ingress before styling. // Apply that stronger policy to every status/error ingress before styling.
sanitizeStatus = sanitizeUntrustedUserinfoText sanitizeStatus = sanitizeUntrustedUserinfoText
} }
state.status = sanitizeStatus(state.status) state.profile.status = sanitizeStatus(state.profile.status)
state.operationStatus.Summary = sanitizeStatus(state.operationStatus.Summary) state.operation.status.Summary = sanitizeStatus(state.operation.status.Summary)
if state.currentError != nil { if state.operation.currentError != nil {
current := *state.currentError current := *state.operation.currentError
current.status.Summary = sanitizeStatus(current.status.Summary) current.status.Summary = sanitizeStatus(current.status.Summary)
current.detail = sanitizeStatus(current.detail) current.detail = sanitizeStatus(current.detail)
state.currentError = &current state.operation.currentError = &current
} }
if state.confirmation != nil { if state.operation.confirmation != nil {
confirmation := sanitizePresentationConfirmation(*state.confirmation) confirmation := sanitizePresentationConfirmation(*state.operation.confirmation)
state.confirmation = &confirmation state.operation.confirmation = &confirmation
} }
if state.confirmationDialog != nil { if state.operation.confirmationDialog != nil {
dialog := *state.confirmationDialog dialog := *state.operation.confirmationDialog
dialog.request = sanitizePresentationConfirmation(dialog.request) dialog.request = sanitizePresentationConfirmation(dialog.request)
state.confirmationDialog = &dialog state.operation.confirmationDialog = &dialog
} }
if state.profileDelete != nil { if state.profile.deletion != nil {
confirmation := *state.profileDelete confirmation := *state.profile.deletion
confirmation.name = sanitizeUntrustedText(confirmation.name) confirmation.name = sanitizeUntrustedText(confirmation.name)
confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request) confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request)
state.profileDelete = &confirmation state.profile.deletion = &confirmation
} }
state.picker.selectedName = sanitizeUntrustedText(state.picker.selectedName) state.dashboard.picker.selectedName = sanitizeUntrustedText(state.dashboard.picker.selectedName)
searchPosition := state.picker.search.Position() searchPosition := state.dashboard.picker.search.Position()
state.picker.search.SetValue(sanitizeUntrustedText(state.picker.search.Value())) state.dashboard.picker.search.SetValue(sanitizeUntrustedText(state.dashboard.picker.search.Value()))
state.picker.search.SetCursor(searchPosition) state.dashboard.picker.search.SetCursor(searchPosition)
state.form = sanitizePresentationForm(state.form) state.form.state = sanitizePresentationForm(state.form.state)
return state return state
} }
-10
View File
@@ -5,16 +5,6 @@ import (
"proxyctl/internal/profile" "proxyctl/internal/profile"
) )
func availableTargets(snapshot app.Snapshot) []app.TargetSnapshot {
result := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
for _, item := range snapshot.Targets {
if item.Available {
result = append(result, item)
}
}
return result
}
func profileAt(snapshot app.Snapshot, index int) (profile.Profile, bool) { func profileAt(snapshot app.Snapshot, index int) (profile.Profile, bool) {
if index < 0 || index >= len(snapshot.Profiles) { if index < 0 || index >= len(snapshot.Profiles) {
return profile.Profile{}, false return profile.Profile{}, false