pi-agent: Fix final integrated review
This commit is contained in:
+2
-21
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
+63
-70
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+37
-35
@@ -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")
|
||||
|
||||
+58
-71
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+61
-54
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user