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