From 652d7eba2b2c1ad0fdacff7c06974997e0f4ec78 Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Sat, 18 Jul 2026 02:37:54 +0800 Subject: [PATCH] pi-agent: Fix final integrated review --- internal/app/app.go | 23 +--- internal/app/coordination_test.go | 16 +-- internal/app/maintenance_test.go | 2 +- internal/app/operation.go | 133 +++++++++---------- internal/app/operation_stream_test.go | 12 +- internal/app/progressive_detection_test.go | 2 +- internal/app/target_test.go | 6 +- internal/tui/active_profile_picker_test.go | 2 +- internal/tui/dashboard_test.go | 17 +++ internal/tui/dashboard_view.go | 36 ++++-- internal/tui/foundation_test.go | 31 +++++ internal/tui/help_dialog.go | 72 ++++++----- internal/tui/model.go | 129 +++++++++---------- internal/tui/operation_coordination_test.go | 85 +++++++++++- internal/tui/operation_coordinator.go | 136 ++++++++++++++------ internal/tui/pages.go | 115 +++++++++-------- internal/tui/presentation.go | 132 +++++++++++-------- internal/tui/snapshot.go | 10 -- 18 files changed, 566 insertions(+), 393 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 156534d..0ab1083 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,7 +19,6 @@ const ( UpdateProfile DeleteProfile ActivateProfile - SetTargetDesired // retained for synchronous compatibility; interactive callers use the explicit intents below. RefreshTargets EnableTarget DisableTarget @@ -34,7 +33,6 @@ type Intent struct { Profile profile.Profile Confirm bool TargetID string - Enabled bool } // TargetActionKind identifies a Target action independently of display labels. @@ -726,9 +724,6 @@ func (a *Application) refreshInspections() { } func isTargetMutationIntent(kind IntentKind) bool { - if kind == SetTargetDesired { - return true - } for _, policy := range targetActionPolicies { if policy.intent == kind { return true @@ -737,10 +732,7 @@ func isTargetMutationIntent(kind IntentKind) bool { return false } -func requestedTargetAction(intent Intent, selected TargetSnapshot) (TargetActionKind, bool) { - if intent.Kind == SetTargetDesired { - return legacyTargetAction(intent.Enabled, selected.State) - } +func requestedTargetAction(intent Intent) (TargetActionKind, bool) { for _, policy := range targetActionPolicies { if policy.intent == intent.Kind { return policy.kind, true @@ -749,17 +741,6 @@ func requestedTargetAction(intent Intent, selected TargetSnapshot) (TargetAction 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) { if selected.PrimaryAction.Kind == kind { return selected.PrimaryAction, true @@ -773,7 +754,7 @@ func structuredTargetAction(selected TargetSnapshot, kind TargetActionKind) (Tar } func validateTargetAction(intent Intent, selected TargetSnapshot) (targetActionPolicy, error) { - kind, ok := requestedTargetAction(intent, selected) + kind, ok := requestedTargetAction(intent) if !ok { return targetActionPolicy{}, fmt.Errorf("Target %q action is no longer available because its state is %s", selected.ID, selected.State) } diff --git a/internal/app/coordination_test.go b/internal/app/coordination_test.go index e8deed0..f71d419 100644 --- a/internal/app/coordination_test.go +++ b/internal/app/coordination_test.go @@ -92,7 +92,7 @@ func TestSecondApplicationUsesPersistedActiveProfileChangedByFirstApplication(t if result := first.Dispatch(app.Intent{Kind: app.ActivateProfile, Name: "new"}); result.Err != nil { 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 { t.Fatalf("second Application enables Target: %v", result.Err) } @@ -122,7 +122,7 @@ func TestActiveProfileSwitchCoordinatesEnabledTargetsWithoutRollback(t *testing. } refreshApplicationTargets(t, application) 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) } } @@ -192,7 +192,7 @@ func TestApplicationCoordinatesMultipleRealAndFakeHermeticAdaptersThroughPartial } refreshApplicationTargets(t, application) 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) } } @@ -233,7 +233,7 @@ func TestActiveProfileProgressReusesAvailabilityCache(t *testing.T) { t.Fatal(result.Err) } 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) } @@ -269,7 +269,7 @@ func TestActiveProfileSwitchStreamsApplyingAndCompletionForEachTarget(t *testing t.Fatal(result.Err) } 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) } @@ -315,7 +315,7 @@ func TestDeletingFinalProfileLeavesManagedTargetsDriftedAndDisableStillRestoresE t.Fatal(activated.Err) } 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) } @@ -327,11 +327,11 @@ func TestDeletingFinalProfileLeavesManagedTargetsDriftedAndDisableStillRestoresE if deleted.Err != nil || deleted.Snapshot.ActiveProfile != "" || deleted.Snapshot.Targets[0].State != target.Drifted { 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") { 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 { t.Fatalf("disable after final delete = %#v", disabled) } diff --git a/internal/app/maintenance_test.go b/internal/app/maintenance_test.go index 9827154..7a28d86 100644 --- a/internal/app/maintenance_test.go +++ b/internal/app/maintenance_test.go @@ -127,7 +127,7 @@ func TestApplicationSerializesAndLogsTargetMutations(t *testing.T) { adapter.inspection = target.Inspection{State: target.Enabled} refreshApplicationTargets(t, application) 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 { t.Fatal("Target failure reported success") } diff --git a/internal/app/operation.go b/internal/app/operation.go index 994c1a7..89a0409 100644 --- a/internal/app/operation.go +++ b/internal/app/operation.go @@ -42,6 +42,34 @@ const ( // exactly one of running, confirmation, succeeded, failed, or closed. type OperationState interface { 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 { @@ -50,10 +78,18 @@ type operationRunning struct { } func (operationRunning) operationState() {} +func (state operationRunning) decode() DecodedOperationUpdate { + return DecodedOperationUpdate{Kind: OperationRunning, Status: state.status, Progress: cloneProgress(state.progress)} +} type operationConfirmation struct{ request Confirmation } 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 { status OperationStatus @@ -61,6 +97,9 @@ type operationSucceeded struct { } func (operationSucceeded) operationState() {} +func (state operationSucceeded) decode() DecodedOperationUpdate { + return DecodedOperationUpdate{Kind: OperationSucceeded, Status: state.status, Progress: cloneProgress(state.progress)} +} type operationFailed struct { status OperationStatus @@ -70,10 +109,19 @@ type operationFailed struct { } 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 } func (operationClosed) operationState() {} +func (state operationClosed) decode() DecodedOperationUpdate { + return DecodedOperationUpdate{Kind: OperationClosed, Status: state.status} +} // OperationUpdate is the common progressive contract used by interactive // detection, Target mutation, and Active Profile transition operations. Build @@ -116,79 +164,24 @@ func cloneProgress(progress []TargetProgress) []TargetProgress { return append([]TargetProgress(nil), progress...) } -func (u OperationUpdate) Status() OperationStatus { - switch state := u.State.(type) { - case operationRunning: - return state.status - case operationSucceeded: - return state.status - case operationFailed: - return state.status - case operationClosed: - return state.status - default: - return OperationStatus{} +// Decode centralizes the closed-set state decoding for all consumers. +func (u OperationUpdate) Decode() DecodedOperationUpdate { + if u.State == nil { + return DecodedOperationUpdate{} } + return u.State.decode() } -func (u OperationUpdate) Progress() []TargetProgress { - switch state := u.State.(type) { - case operationRunning: - return cloneProgress(state.progress) - case operationSucceeded: - return cloneProgress(state.progress) - case operationFailed: - return cloneProgress(state.progress) - default: - return nil - } -} - -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() } +func (u OperationUpdate) Status() OperationStatus { return u.Decode().Status } +func (u OperationUpdate) Progress() []TargetProgress { return u.Decode().Progress } +func (u OperationUpdate) Confirmation() *Confirmation { return u.Decode().Confirmation } +func (u OperationUpdate) Err() error { return u.Decode().Err } +func (u OperationUpdate) FirstFailedTargetID() string { return u.Decode().FirstFailedTargetID } +func (u OperationUpdate) IsRunning() bool { return u.Decode().Kind == OperationRunning } +func (u OperationUpdate) IsConfirmation() bool { return u.Decode().Kind == OperationConfirmation } +func (u OperationUpdate) IsSucceeded() bool { return u.Decode().Kind == OperationSucceeded } +func (u OperationUpdate) IsFailed() bool { return u.Decode().Kind == OperationFailed } +func (u OperationUpdate) IsTerminal() bool { return u.Decode().IsTerminal() } // OperationStream exposes the useful initial frame synchronously and all // subsequent structured changes through Updates. Updates closes after a diff --git a/internal/app/operation_stream_test.go b/internal/app/operation_stream_test.go index f4fb329..471cb21 100644 --- a/internal/app/operation_stream_test.go +++ b/internal/app/operation_stream_test.go @@ -97,7 +97,7 @@ func TestInteractiveOperationStreamSerializesMutationAndPublishesStructuredProgr } 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 runningProgress := running.Progress() 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 - 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 if !blockedUpdate.IsTerminal() || blockedUpdate.Status().Kind != app.StatusBlocked || !strings.Contains(blockedUpdate.Status().Summary, "already in progress") { t.Fatalf("blocked update = %#v", blockedUpdate) @@ -148,7 +148,7 @@ func TestDetectionOperationSerializesWithSynchronousMutation(t *testing.T) { mutationDone := make(chan app.Result, 1) go func() { 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 close(adapter.detectionRelease) @@ -182,7 +182,7 @@ func TestStartOperationReturnsBeforeDispatchSerializationIsAvailable(t *testing. mutationDone := make(chan app.Result, 1) 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.detectionStarted = make(chan struct{}) @@ -221,7 +221,7 @@ func TestInteractiveOperationStreamUsesStructuredConfirmation(t *testing.T) { } 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 confirmation := update.Confirmation() if !update.IsConfirmation() || confirmation == nil || confirmation.Title == "" || confirmation.CancelLabel == "" { @@ -249,7 +249,7 @@ func TestInteractiveProfileTransitionReportsFirstFailureWithoutRollback(t *testi } refreshApplicationTargets(t, application) 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) } } diff --git a/internal/app/progressive_detection_test.go b/internal/app/progressive_detection_test.go index 9b4b8c9..9cac31b 100644 --- a/internal/app/progressive_detection_test.go +++ b/internal/app/progressive_detection_test.go @@ -220,7 +220,7 @@ func TestDispatchMutationWaitsForInFlightTargetDetection(t *testing.T) { <-adapter.started mutationDone := make(chan app.Result, 1) 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) for range detection.Updates { diff --git a/internal/app/target_test.go b/internal/app/target_test.go index 84185d1..2a322a3 100644 --- a/internal/app/target_test.go +++ b/internal/app/target_test.go @@ -60,20 +60,20 @@ func TestApplicationTargetSeamRequiresActiveProfileAndReturnsTruthfulResults(t * 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 { t.Fatalf("enable without Active Profile = %#v, calls=%v", result, adapter.calls) } 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 { t.Fatalf("enable result = %#v", result) } adapter.setErr = errors.New("permission denied") 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" { t.Fatalf("failed operation not truthful: %#v", result) } diff --git a/internal/tui/active_profile_picker_test.go b/internal/tui/active_profile_picker_test.go index e809d40..652b7da 100644 --- a/internal/tui/active_profile_picker_test.go +++ b/internal/tui/active_profile_picker_test.go @@ -213,7 +213,7 @@ func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFai close(updates) model, _ = applyOperationCommand(t, model, command) 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) } } diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 3c104cc..57d3636 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -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) { module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{ {ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled, Evidence: "fragment absent"}, diff --git a/internal/tui/dashboard_view.go b/internal/tui/dashboard_view.go index 6646dc2..e955f68 100644 --- a/internal/tui/dashboard_view.go +++ b/internal/tui/dashboard_view.go @@ -17,10 +17,13 @@ type dashboardRenderState struct { focus dashboardPane detailsOffset int operationProgress map[string]app.TargetProgressPhase + applyingFrame string + terminalWidth int + terminalHeight int } 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 rightWidth := width - leftWidth - 1 left := p.renderTargetListPane(state, leftWidth, height) @@ -49,7 +52,7 @@ func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, w if item.ID == state.selectedTargetID { 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) } if len(lines) == 0 { @@ -70,7 +73,7 @@ func (p terminalPresentation) renderTargetDetailsPane(state dashboardRenderState selected, ok := state.catalog.selected(state.selectedTargetID) content := "Select a Target to review its details." if ok { - content = p.targetDetails(selected) + content = p.targetDetails(selected, state.operationProgress[selected.ID], state.applyingFrame) } body := fixedViewport(content, width, bodyHeight, state.detailsOffset, true) 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() } -func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase) string { - switch progress { - case app.TargetProgressRunning: - return p.theme.renderTargetState(item.State, true) - case app.TargetProgressFailed: - return p.theme.checkFailed.Render("! Failed") - } +func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string { switch item.DetectionPhase { case app.TargetChecking: return p.theme.checking.Render("… Checking") case app.TargetDetectionFailed: - return p.theme.checkFailed.Render("! Check failed") + return p.theme.checkFailed.Render("! Failed") } if !item.Available { 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 { - state := p.targetBadge(item, 0) +func (p terminalPresentation) targetDetails(item app.TargetSnapshot, progress app.TargetProgressPhase, applyingFrame string) string { + state := p.targetBadge(item, progress, applyingFrame) evidence := item.Evidence switch item.DetectionPhase { case app.TargetChecking: diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index 8207e22..598e6f4 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -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) { module := responsiveLayoutModule() model := resizedModel(t, module, themeModeNoColor, 120, 36) diff --git a/internal/tui/help_dialog.go b/internal/tui/help_dialog.go index 57f5923..9f154bc 100644 --- a/internal/tui/help_dialog.go +++ b/internal/tui/help_dialog.go @@ -57,54 +57,56 @@ func (m Model) dashboardQuitAvailable() bool { } 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" - if model.operationActive { + if model.operation.active { exitHint = "Ctrl+C: defer safe exit" } - if model.exitDeferred { + if model.operation.exitDeferred { exitHint = "Exit pending until safe completion" } return terminalPage{ primary: content, footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint, focusLine: -1, - primaryOffset: model.help.offset, + primaryOffset: model.help.dialog.offset, } } func (p terminalPresentation) contextualHelp(model presentationState) string { + dashboard, operation := model.dashboard, model.operation + profiles, form := model.profile, model.form.state var body strings.Builder 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") switch { - case model.confirmation != nil: + case operation.confirmation != nil: body.WriteString(p.theme.section.Render("Safety confirmation")) - fmt.Fprintf(&body, "\n%s\n", model.confirmation.Title) - if model.confirmation.Summary != "" { - fmt.Fprintf(&body, "%s\n", model.confirmation.Summary) + fmt.Fprintf(&body, "\n%s\n", operation.confirmation.Title) + if operation.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. ") - if model.confirmationDialog != nil { + if operation.confirmationDialog != nil { body.WriteByte('\n') - body.WriteString(confirmationFocusDescription(*model.confirmationDialog)) + body.WriteString(confirmationFocusDescription(*operation.confirmationDialog)) } body.WriteByte('\n') - case model.profileDelete != nil: + case profiles.deletion != nil: 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.WriteByte('\n') - body.WriteString(confirmationFocusDescription(model.profileDelete.dialog)) + body.WriteString(confirmationFocusDescription(profiles.deletion.dialog)) body.WriteByte('\n') - case model.picker.open: + case dashboard.picker.open: 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") - case model.driftResolutionTargetID != "": + case dashboard.driftResolutionTargetID != "": 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") - 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} { action, found := driftResolutionAction(selected, kind) if found && action.Available { @@ -120,10 +122,10 @@ func (p terminalPresentation) contextualHelp(model presentationState) string { } case model.page == profilesPage: 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) 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") } else { 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: body.WriteString(p.theme.section.Render("Profile form")) - if model.form.field >= 0 && model.form.field < len(model.form.inputs) { - fmt.Fprintf(&body, "\nFocus: %s field.\n", model.form.inputs[model.form.field].label) + if form.field >= 0 && form.field < len(form.inputs) { + 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") - 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") } - if model.form.profile.Mode == profile.SingleMode { + if form.profile.Mode == profile.SingleMode { 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") - 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") default: 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") } default: body.WriteString(p.theme.section.Render("Dashboard")) focus := "Targets focus" - if model.dashboardFocus == targetDetailsPane { + if dashboard.focus == targetDetailsPane { focus = "Target details focus" } fmt.Fprintf(&body, "\nContext: %s.\n", focus) - catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID) - if selected, ok := catalog.selected(model.selectedTargetID); ok { + catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID) + if selected, ok := catalog.selected(dashboard.selectedTargetID); ok { 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) if availability.available { 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) } } - if model.dashboardFocus == targetDetailsPane { + if dashboard.focus == targetDetailsPane { body.WriteString("Up/Down, j/k, and PgUp/PgDn scroll Target details. Tab focuses Targets.\n") } else { 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") } else { 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(p.theme.section.Render("Operation state")) 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") - 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") default: 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") } - if model.page == mainPage && model.profileDelete == nil { + if model.page == mainPage && profiles.deletion == nil { body.WriteString("\n") body.WriteString(p.theme.section.Render("Built-in Target reference")) body.WriteString("\n") diff --git a/internal/tui/model.go b/internal/tui/model.go index 1c284d3..553071b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" @@ -91,12 +92,7 @@ func targetsChecking(snapshot app.Snapshot) bool { return false } -func (m Model) Init() tea.Cmd { - if m.operation.updates != nil { - return waitForOperation(m.operation.updates) - } - return nil -} +func (m Model) Init() tea.Cmd { return m.operation.wait() } // 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 @@ -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) 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) { if size, ok := message.(tea.WindowSizeMsg); ok { m.width = size.Width @@ -141,6 +131,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } + if tick, ok := message.(spinner.TickMsg); ok { + return m, m.operation.tick(tick) + } if progress, ok := message.(operationProgress); ok { if !m.operation.active || progress.updates != m.operation.updates { return m, nil @@ -170,7 +163,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if progress.update.IsTerminal() { return m, m.operation.deferredQuit() } - return m, waitForOperation(progress.updates) + return m, m.operation.wait() } if done, ok := message.(probeDone); ok { 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}) } case "tab": - if m.dashboardNavigationAvailable() { - if m.dashboardFocus == targetListPane { - m.dashboardFocus = targetDetailsPane - } else { - m.dashboardFocus = targetListPane - } + if m.dashboardFocus == targetListPane { + m.dashboardFocus = targetDetailsPane + } else { + m.dashboardFocus = targetListPane } case "u": - if m.dashboardNavigationAvailable() { - formerIndex := catalog.index(m.selectedTargetID) - m.showUnavailable = !m.showUnavailable - m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex) + formerIndex := catalog.index(m.selectedTargetID) + m.showUnavailable = !m.showUnavailable + 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 } - 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": - if m.dashboardNavigationAvailable() { - if m.dashboardFocus == targetDetailsPane { - m.detailsOffset++ - } else { - m.selectedTargetID = catalog.move(m.selectedTargetID, 1) - m.detailsOffset = 0 - } + if m.dashboardFocus == targetDetailsPane { + m.detailsOffset++ + } else { + m.selectedTargetID = catalog.move(m.selectedTargetID, 1) + m.detailsOffset = 0 } case "pgup": - if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { + if m.dashboardFocus == targetDetailsPane { m.detailsOffset = max(0, m.detailsOffset-5) } case "pgdown": - if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane { + if m.dashboardFocus == targetDetailsPane { m.detailsOffset += 5 } case " ": @@ -641,39 +626,41 @@ func (m *Model) ensureProfileSelectionVisible() { func (m Model) View() string { return m.presentation.Render(presentationState{ - snapshot: m.snapshot, - page: m.page, - width: m.width, - height: m.height, - selected: m.selected, - profileOffset: m.profileViewportOffset, - profileDetails: m.profileDetailsOffset, - profileDelete: m.profileDelete, - selectedTargetID: m.selectedTargetID, - dashboardFocus: m.dashboardFocus, - showUnavailable: m.showUnavailable, - detailsOffset: m.detailsOffset, - driftResolutionTargetID: m.driftResolutionTargetID, - picker: m.picker, - form: m.form, - help: m.help, - confirmation: m.operation.confirmation, - confirmationDialog: &m.operation.confirmationDialog, - status: m.status, - operationStatus: m.operation.status, - currentError: m.operation.currentError, - errorExpanded: m.operation.errorExpanded, - errorOffset: m.operation.errorOffset, - exitDeferred: m.operation.quitRequested && m.operationInFlight(), - operationActive: m.operation.active, - operationProgress: m.operation.progress, + snapshot: m.snapshot, + page: m.page, + terminal: terminalDimensions{width: m.width, height: m.height}, + dashboard: dashboardPresentationState{ + selectedTargetID: m.selectedTargetID, + focus: m.dashboardFocus, + showUnavailable: m.showUnavailable, + detailsOffset: m.detailsOffset, + driftResolutionTargetID: m.driftResolutionTargetID, + picker: m.picker, + }, + profile: profilePresentationState{ + selected: m.selected, + listOffset: m.profileViewportOffset, + detailsOffset: m.profileDetailsOffset, + deletion: m.profileDelete, + status: m.status, + }, + form: formPresentationState{state: m.form}, + help: helpPresentationState{dialog: m.help}, + operation: operationPresentationState{ + confirmation: m.operation.confirmation, + confirmationDialog: &m.operation.confirmationDialog, + status: m.operation.status, + currentError: m.operation.currentError, + 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() { catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID) m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID)) diff --git a/internal/tui/operation_coordination_test.go b/internal/tui/operation_coordination_test.go index e30febf..434d17c 100644 --- a/internal/tui/operation_coordination_test.go +++ b/internal/tui/operation_coordination_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" @@ -50,6 +51,35 @@ func applyOperationCommand(t *testing.T, model Model, command tea.Cmd) (Model, t 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) { updates := make(chan app.OperationUpdate, 2) 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) resolution := make(chan app.OperationUpdate, 1) 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}) model = updated.(Model) 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}}) close(resolution) model, _ = applyOperationCommand(t, model, command) if strings.Contains(model.View(), "Current error:") { - t.Fatalf("explicit related resolution did not clear the current error:\n%s", model.View()) + 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()) } } diff --git a/internal/tui/operation_coordinator.go b/internal/tui/operation_coordinator.go index 3ca893d..2627154 100644 --- a/internal/tui/operation_coordinator.go +++ b/internal/tui/operation_coordinator.go @@ -1,8 +1,9 @@ package tui import ( - "errors" + "time" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" @@ -11,11 +12,12 @@ import ( type operationError struct { status app.OperationStatus detail string + intent app.Intent } // operationCoordinator owns the complete interactive operation lifecycle. The // root Model delegates stream consumption, progress, confirmation, current -// error, and deferred-exit state here instead of mirroring those states. +// error, spinner, and deferred-exit state here instead of mirroring them. type operationCoordinator struct { active bool intent app.Intent @@ -29,6 +31,7 @@ type operationCoordinator struct { confirmationDialog confirmationDialog confirmationIntent *app.Intent quitRequested bool + spinner spinner.Model } type operationProgress struct { @@ -37,20 +40,50 @@ type operationProgress struct { closed bool } -func newOperationCoordinator() operationCoordinator { - return operationCoordinator{progress: make(map[string]app.TargetProgressPhase)} +var applyingSpinner = spinner.Spinner{ + Frames: []string{"|", "/", "-", "\\"}, + FPS: 90 * time.Millisecond, } -func waitForOperation(updates <-chan app.OperationUpdate) tea.Cmd { - return func() tea.Msg { - update, ok := <-updates - if !ok { - return operationProgress{updates: updates, closed: true} - } - return operationProgress{update: update, updates: updates} +func newOperationCoordinator() operationCoordinator { + return operationCoordinator{ + progress: make(map[string]app.TargetProgressPhase), + spinner: spinner.New(spinner.WithSpinner(applyingSpinner)), } } +// 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) { 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"} @@ -61,11 +94,12 @@ func (o *operationCoordinator) start(application app.InteractiveModule, intent a o.intent = intent o.updates = stream.Updates o.progress = make(map[string]app.TargetProgressPhase) + o.spinner = spinner.New(spinner.WithSpinner(applyingSpinner)) if intent.TargetID != "" { o.progress[intent.TargetID] = app.TargetProgressRunning } o.status = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)} - return stream.Snapshot, waitForOperation(stream.Updates), true + return stream.Snapshot, o.wait(), true } 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) { - status := update.Status() - progress := update.Progress() - for _, item := range progress { + decoded := update.Decode() + for _, item := range decoded.Progress { o.progress[item.TargetID] = item.Phase - if item.Phase == app.TargetProgressSucceeded { - o.resolveTargetError(item.TargetID) - } } - if status.Summary != "" { - o.status = status + if decoded.Status.Summary != "" { + o.status = decoded.Status } - if update.IsSucceeded() { - o.resolveTargetError(status.TargetID) - } - if update.IsFailed() { - o.currentError = &operationError{status: status, detail: errorDetail(update.Err())} + switch decoded.Kind { + case app.OperationSucceeded: + o.resolveError(o.intent) + case app.OperationFailed: + o.currentError = &operationError{status: decoded.Status, detail: errorDetail(decoded.Err), intent: o.intent} o.errorExpanded = false o.errorOffset = 0 - firstFailedTargetID = update.FirstFailedTargetID() - } - if request := update.Confirmation(); request != nil { + firstFailedTargetID = decoded.FirstFailedTargetID + case app.OperationConfirmation: intent := o.intent - o.confirmation = request - o.confirmationDialog = newConfirmationDialog(*request) + o.confirmation = decoded.Confirmation + o.confirmationDialog = newConfirmationDialog(*decoded.Confirmation) o.confirmationIntent = &intent } - if update.IsTerminal() { + if decoded.IsTerminal() { o.finish() } return firstFailedTargetID @@ -119,8 +157,8 @@ func errorDetail(err error) string { return err.Error() } -func (o *operationCoordinator) resolveTargetError(targetID string) { - if targetID == "" || o.currentError == nil || o.currentError.status.TargetID != targetID { +func (o *operationCoordinator) resolveError(success app.Intent) { + if o.currentError == nil || !relatedResolution(o.currentError.intent, success) { return } o.currentError = nil @@ -128,6 +166,28 @@ func (o *operationCoordinator) resolveTargetError(targetID string) { 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() { o.active = false o.intent = app.Intent{} @@ -139,6 +199,7 @@ func (o *operationCoordinator) unexpectedClose() { o.currentError = &operationError{ status: o.status, detail: "The Application operation stream closed before publishing a terminal result.", + intent: o.intent, } o.errorExpanded = false o.errorOffset = 0 @@ -191,12 +252,3 @@ func (o *operationCoordinator) requestQuit() tea.Cmd { } 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() -} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index eb42f1b..6e8c45b 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -9,6 +9,7 @@ import ( ) func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage { + dashboard, operation := model.dashboard, model.operation activeProfile := model.snapshot.ActiveProfile if activeProfile == "" { 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)) } - catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID) + catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID) footer := p.dashboardFooter(model, catalog) - if model.currentError != nil { + if operation.currentError != nil { footer += "\ne: expand/collapse current error x: acknowledge error" - if model.errorExpanded { + if operation.errorExpanded { footer += " PgUp/PgDn: scroll error details" } } @@ -33,22 +34,25 @@ func (p terminalPresentation) mainView(model presentationState, width, height in footer: footer, dashboard: &dashboardRenderState{ catalog: catalog, - selectedTargetID: model.selectedTargetID, - focus: model.dashboardFocus, - detailsOffset: model.detailsOffset, - operationProgress: model.operationProgress, + selectedTargetID: dashboard.selectedTargetID, + focus: dashboard.focus, + detailsOffset: dashboard.detailsOffset, + operationProgress: operation.progress, + applyingFrame: operation.spinnerFrame, + terminalWidth: width, + terminalHeight: height, }, } - if model.picker.open { + if dashboard.picker.open { 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" if width < 70 { page.footer = "Type: search ↑/↓: select Enter: activate\nEsc: cancel ?: help" } } - if model.driftResolutionTargetID != "" && model.confirmation == nil { - if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok { + if dashboard.driftResolutionTargetID != "" && operation.confirmation == nil { + if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok { page.dashboard = nil page.primary, page.context = p.driftResolutionView(selected) 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" } } - if model.confirmation != nil { - dialog := newConfirmationDialog(*model.confirmation) - if model.confirmationDialog != nil { - dialog = *model.confirmationDialog + if operation.confirmation != nil { + dialog := newConfirmationDialog(*operation.confirmation) + if operation.confirmationDialog != nil { + dialog = *operation.confirmationDialog } page.dashboard = nil page.primary = dialog.view() @@ -93,71 +97,73 @@ func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) ( } func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { + dashboard, operation, width := model.dashboard, model.operation, model.terminal.width visibility := "u: show unavailable" - if model.showUnavailable { + if dashboard.showUnavailable { visibility = "u: hide unavailable" } - if model.dashboardFocus == targetDetailsPane { - if model.exitDeferred { + if dashboard.focus == targetDetailsPane { + if operation.exitDeferred { return "↑/k ↓/j PgUp/PgDn: browse Tab: focus Targets " + visibility + " ?: help\nExit pending; new work disabled until safe completion" } - if model.width < wideWidth { - if model.operationActive { + if width < wideWidth { + if operation.active { 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 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" - 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" } return footer } 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" } - selected, ok := catalog.selected(model.selectedTargetID) - availability := targetMutationAvailability(selected, ok, model.operationActive || model.exitDeferred) + selected, ok := catalog.selected(dashboard.selectedTargetID) + availability := targetMutationAvailability(selected, ok, operation.active || operation.exitDeferred) label := targetActionLabel(selected.PrimaryAction.Kind) if availability.available { actions = "Space: " + label + " " + actions } else { actions = "Space: " + label + " disabled (" + availability.compactReason + ") " + actions } - if model.width < wideWidth { + if width < wideWidth { if availability.available { actions = "Space: " + label + " ↑/k ↓/j: select Tab: focus details" } else { 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" } - if model.width < 70 { + if width < 70 { return actions + "\np: switch Profile m: manage ?: 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" - if model.operationActive { + if operation.active { footer = actions + "\n?: help q: defer safe exit; new mutations disabled" } return footer } func (p terminalPresentation) profilesView(model presentationState) terminalPage { + state := model.profile var list strings.Builder if len(model.snapshot.Profiles) == 0 { list.WriteString("No Proxy Profiles saved. Press n to create one.") } for i, item := range model.snapshot.Profiles { cursor, active := " ", " " - if i == model.selected { + if i == state.selected { cursor = "> " } if item.Name == model.snapshot.ActiveProfile { @@ -171,17 +177,17 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage 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" - 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" } if len(model.snapshot.Profiles) == 0 { footer = "n: create a Proxy Profile ?: help q/Esc: back" } - if model.profileDelete != nil { + if state.deletion != nil { return terminalPage{ header: p.theme.title.Render("Proxy Profile management"), - primary: model.profileDelete.dialog.view(), - primaryOffset: model.profileDelete.dialog.offset, + primary: state.deletion.dialog.view(), + primaryOffset: state.deletion.dialog.offset, wrapPrimary: true, footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help", focusLine: -1, @@ -192,9 +198,9 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage primary: list.String(), context: context, footer: footer, - focusLine: model.selected, - primaryOffset: model.profileOffset, - contextOffset: model.profileDetails, + focusLine: state.selected, + primaryOffset: state.listOffset, + contextOffset: state.detailsOffset, wrapContext: true, } } @@ -204,7 +210,7 @@ func sanitizeFormText(value string) 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 { return "" } @@ -233,36 +239,37 @@ func selectedProfileDetails(p terminalPresentation, model presentationState) str } func (p terminalPresentation) formView(model presentationState) terminalPage { + form, width := model.form.state, model.terminal.width action := "Create" - if model.form.originalName != "" { + if form.originalName != "" { action = "Edit" } var fields, bypassPreview strings.Builder focusLine, currentLine := 0, 0 - for i, item := range model.form.inputs { + for i, item := range form.inputs { if fields.Len() > 0 { fields.WriteByte('\n') currentLine++ } cursor, focus := " ", "" - if i == model.form.field { + if i == form.field { cursor, focus = "> ", " [focused]" focusLine = currentLine } - fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, model.form.renderedInput(i, model.width)) - if probeText := model.form.probeText(item.field); probeText != "" { + fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, form.renderedInput(i, width)) + if probeText := form.probeText(item.field); probeText != "" { fmt.Fprintf(&fields, "\n %s", probeText) currentLine++ } - if item.field == profile.NameField && model.form.hasProbeSourceAttachment() { - fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(model.form.probe.sourceValue)) - fmt.Fprintf(&fields, "\n %s", model.form.probe.feedback) + if item.field == profile.NameField && form.hasProbeSourceAttachment() { + fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(form.probe.sourceValue)) + fmt.Fprintf(&fields, "\n %s", form.probe.feedback) fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:") currentLine += 3 } - validation := model.form.validationText(i) + validation := form.validationText(i) if item.field == profile.NameField { - if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" { + if conflict := form.nameConflict(model.snapshot.Profiles); conflict != "" { validation = conflict } } @@ -272,7 +279,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { currentLine++ } if item.field == profile.BypassField { - analysis := model.form.bypassAnalysis() + analysis := form.bypassAnalysis() bypassPreview.WriteString("Normalized Bypass preview:") if len(analysis) == 0 { bypassPreview.WriteString(" (none)") @@ -284,24 +291,24 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { } } secondaryHints := "?: help q/Esc: cancel" - if model.form.currentField() == profile.BypassField { + if form.currentField() == profile.BypassField { 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" - if model.form.probe.active { + if form.probe.active { probeHint = "Protocol Probe in progress" } secondaryHints = probeHint + " " + secondaryHints } footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints 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(), context: bypassPreview.String(), footer: footer, focusLine: focusLine, - contextAnchor: model.form.bypassPreviewLine, + contextAnchor: form.bypassPreviewLine, anchorContext: true, } } diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index 142e012..b8ddab5 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -56,32 +56,57 @@ func defaultModelOptions() modelOptions { } type presentationState struct { - snapshot app.Snapshot - page page - width int - height int - selected int - profileOffset int - profileDetails int - profileDelete *profileDeleteConfirmation + snapshot app.Snapshot + page page + terminal terminalDimensions + dashboard dashboardPresentationState + profile profilePresentationState + form formPresentationState + help helpPresentationState + operation operationPresentationState +} + +type terminalDimensions struct { + width int + height int +} + +type dashboardPresentationState struct { selectedTargetID string - dashboardFocus dashboardPane + focus dashboardPane showUnavailable bool detailsOffset int driftResolutionTargetID string picker profilePickerState - form formState - help helpDialogState - confirmation *app.Confirmation - confirmationDialog *confirmationDialog - status string - operationStatus app.OperationStatus - currentError *operationError - errorExpanded bool - errorOffset int - exitDeferred bool - operationActive bool - operationProgress map[string]app.TargetProgressPhase +} + +type profilePresentationState struct { + selected int + listOffset int + detailsOffset int + deletion *profileDeleteConfirmation + status string +} + +type formPresentationState struct { + 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 @@ -116,7 +141,7 @@ func (p terminalPresentation) Render(state presentationState) string { // semanticTheme style is applied; otherwise an attacker could inject one of // the same allowlisted SGR sequences that the theme legitimately emits. state = sanitizePresentationState(state) - width, height := state.width, state.height + width, height := state.terminal.width, state.terminal.height if width <= 0 { width = 80 } @@ -138,7 +163,7 @@ func (p terminalPresentation) Render(state presentationState) string { default: page = p.mainView(state, width, height) } - if state.help.top(state.confirmation) { + if state.help.dialog.top(state.operation.confirmation) { help := p.helpDialogView(state) // Retain the underlying page header so Help is visibly contextual and // modal rather than a replacement navigation destination. @@ -151,8 +176,8 @@ func (p terminalPresentation) Render(state presentationState) string { page.dashboard = nil } page.status = renderStatus(state) - page.statusExpanded = state.errorExpanded && state.currentError != nil - page.statusOffset = state.errorOffset + page.statusExpanded = state.operation.errorExpanded && state.operation.currentError != nil + page.statusOffset = state.operation.errorOffset page = redactTerminalPage(page) // Untrusted values were redacted and stripped of controls before semantic // 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 { 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") - } else if state.operationStatus.Summary != "" { - lines = append(lines, state.operationStatus.Summary) - } else if state.status != "" { - lines = append(lines, state.status) + } else if operation.status.Summary != "" { + lines = append(lines, operation.status.Summary) + } else if state.profile.status != "" { + lines = append(lines, state.profile.status) } - if state.currentError != nil { - errorText := "Current error: " + state.currentError.status.Summary - if state.errorExpanded && state.currentError.detail != "" { - errorText += "\n" + state.currentError.detail + if operation.currentError != nil { + errorText := "Current error: " + operation.currentError.status.Summary + if operation.errorExpanded && operation.currentError.detail != "" { + errorText += "\n" + operation.currentError.detail } lines = append(lines, errorText) } @@ -236,35 +262,35 @@ func sanitizePresentationState(state presentationState) presentationState { // Apply that stronger policy to every status/error ingress before styling. sanitizeStatus = sanitizeUntrustedUserinfoText } - state.status = sanitizeStatus(state.status) - state.operationStatus.Summary = sanitizeStatus(state.operationStatus.Summary) - if state.currentError != nil { - current := *state.currentError + state.profile.status = sanitizeStatus(state.profile.status) + state.operation.status.Summary = sanitizeStatus(state.operation.status.Summary) + if state.operation.currentError != nil { + current := *state.operation.currentError current.status.Summary = sanitizeStatus(current.status.Summary) current.detail = sanitizeStatus(current.detail) - state.currentError = ¤t + state.operation.currentError = ¤t } - if state.confirmation != nil { - confirmation := sanitizePresentationConfirmation(*state.confirmation) - state.confirmation = &confirmation + if state.operation.confirmation != nil { + confirmation := sanitizePresentationConfirmation(*state.operation.confirmation) + state.operation.confirmation = &confirmation } - if state.confirmationDialog != nil { - dialog := *state.confirmationDialog + if state.operation.confirmationDialog != nil { + dialog := *state.operation.confirmationDialog dialog.request = sanitizePresentationConfirmation(dialog.request) - state.confirmationDialog = &dialog + state.operation.confirmationDialog = &dialog } - if state.profileDelete != nil { - confirmation := *state.profileDelete + if state.profile.deletion != nil { + confirmation := *state.profile.deletion confirmation.name = sanitizeUntrustedText(confirmation.name) confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request) - state.profileDelete = &confirmation + state.profile.deletion = &confirmation } - state.picker.selectedName = sanitizeUntrustedText(state.picker.selectedName) - searchPosition := state.picker.search.Position() - state.picker.search.SetValue(sanitizeUntrustedText(state.picker.search.Value())) - state.picker.search.SetCursor(searchPosition) - state.form = sanitizePresentationForm(state.form) + state.dashboard.picker.selectedName = sanitizeUntrustedText(state.dashboard.picker.selectedName) + searchPosition := state.dashboard.picker.search.Position() + state.dashboard.picker.search.SetValue(sanitizeUntrustedText(state.dashboard.picker.search.Value())) + state.dashboard.picker.search.SetCursor(searchPosition) + state.form.state = sanitizePresentationForm(state.form.state) return state } diff --git a/internal/tui/snapshot.go b/internal/tui/snapshot.go index c33a7cb..613e8fe 100644 --- a/internal/tui/snapshot.go +++ b/internal/tui/snapshot.go @@ -5,16 +5,6 @@ import ( "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) { if index < 0 || index >= len(snapshot.Profiles) { return profile.Profile{}, false