fix: harden operations and configuration recovery
This commit is contained in:
@@ -139,7 +139,7 @@ func TestCompleteModelDialogResizeAndHelpPreserveMeaningfulFocus(t *testing.T) {
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if command != nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile {
|
||||
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile {
|
||||
t.Fatalf("resize and Help changed confirmation focus: intents=%#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-21
@@ -145,13 +145,24 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.operation.unexpectedClose()
|
||||
return m, m.operation.deferredQuit()
|
||||
}
|
||||
completedIntent := m.operation.intent
|
||||
m.snapshot = progress.update.Snapshot
|
||||
m.retainTargetSelection()
|
||||
m.retainProfileSelection(m.selectedProfileName, m.selected)
|
||||
decoded := progress.update.Decode()
|
||||
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
|
||||
m.selectedTargetID = firstFailed
|
||||
m.detailsOffset = 0
|
||||
}
|
||||
if decoded.IsTerminal() && isProfileMutation(completedIntent.Kind) {
|
||||
if decoded.Kind == app.OperationSucceeded {
|
||||
m.page = profilesPage
|
||||
m.form = formState{}
|
||||
m.status = decoded.Status.Summary
|
||||
} else if decoded.Err != nil {
|
||||
m.status = "Error: " + decoded.Err.Error()
|
||||
}
|
||||
}
|
||||
if m.operation.confirmation != nil {
|
||||
m.page = mainPage
|
||||
if m.help.open {
|
||||
@@ -399,7 +410,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case confirmationAccepted:
|
||||
name := m.profileDelete.name
|
||||
m.profileDelete = nil
|
||||
m.applyProfileMutation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true}, "")
|
||||
return m, m.startOperation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true})
|
||||
case confirmationCancelled:
|
||||
m.profileDelete = nil
|
||||
}
|
||||
@@ -519,13 +530,8 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
normalized := m.form.validation.Normalized
|
||||
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized}
|
||||
result := m.applyProfileMutation(intent, normalized.Name)
|
||||
if result.Err != nil {
|
||||
return m, nil
|
||||
}
|
||||
m.page = profilesPage
|
||||
m.form = formState{}
|
||||
return m, nil
|
||||
m.selectedProfileName = normalized.Name
|
||||
return m, m.startOperation(intent)
|
||||
case tea.KeyCtrlD:
|
||||
address, token, started := m.form.startProbe()
|
||||
if !started {
|
||||
@@ -556,20 +562,13 @@ func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
|
||||
return m, m.startOperation(intent)
|
||||
}
|
||||
|
||||
func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) app.Result {
|
||||
formerName, formerIndex := m.selectedProfileName, m.selected
|
||||
result := m.application.Dispatch(intent)
|
||||
m.snapshot = result.Snapshot
|
||||
if preferredName == "" {
|
||||
preferredName = formerName
|
||||
func isProfileMutation(kind app.IntentKind) bool {
|
||||
switch kind {
|
||||
case app.CreateProfile, app.UpdateProfile, app.DeleteProfile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
m.retainProfileSelection(preferredName, formerIndex)
|
||||
if result.Err != nil {
|
||||
m.status = "Error: " + result.Err.Error()
|
||||
} else {
|
||||
m.status = result.Notice
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Model) selectedProfile() (profile.Profile, bool) {
|
||||
|
||||
@@ -149,7 +149,20 @@ func updateKey(model Model, key string) Model {
|
||||
msg = tea.KeyMsg{Type: keyType}
|
||||
}
|
||||
updated, _ := model.Update(msg)
|
||||
return updated.(Model)
|
||||
model = updated.(Model)
|
||||
// In-memory test modules publish terminal operation updates synchronously.
|
||||
// Drain only updates already available; real in-flight operation tests remain
|
||||
// under their explicit channel control.
|
||||
for model.operation.active && isProfileMutation(model.operation.intent.Kind) {
|
||||
select {
|
||||
case update, open := <-model.operation.updates:
|
||||
updated, _ = model.Update(operationProgress{updates: model.operation.updates, update: update, closed: !open})
|
||||
model = updated.(Model)
|
||||
default:
|
||||
return model
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func updateRunes(model Model, value string) Model {
|
||||
|
||||
@@ -22,6 +22,7 @@ type operationCoordinator struct {
|
||||
active bool
|
||||
intent app.Intent
|
||||
updates <-chan app.OperationUpdate
|
||||
cancel func()
|
||||
progress map[string]app.TargetProgressPhase
|
||||
status app.OperationStatus
|
||||
currentError *operationError
|
||||
@@ -85,6 +86,7 @@ func (o *operationCoordinator) start(application app.InteractiveModule, intent a
|
||||
o.active = true
|
||||
o.intent = intent
|
||||
o.updates = stream.Updates
|
||||
o.cancel = stream.Cancel
|
||||
o.progress = make(map[string]app.TargetProgressPhase)
|
||||
o.spinner = spinner.New(spinner.WithSpinner(applyingSpinner))
|
||||
if intent.TargetID != "" {
|
||||
@@ -100,6 +102,8 @@ func operationStartingSummary(kind app.IntentKind) string {
|
||||
return "Checking Target availability and state…"
|
||||
case app.ActivateProfile:
|
||||
return "Changing Active Profile…"
|
||||
case app.CreateProfile, app.UpdateProfile, app.DeleteProfile:
|
||||
return "Saving Proxy Profile changes…"
|
||||
default:
|
||||
return "Starting Target mutation…"
|
||||
}
|
||||
@@ -171,6 +175,8 @@ func relatedResolution(failed, success app.Intent) bool {
|
||||
return success.Kind == app.RefreshTargets
|
||||
case failed.Kind == app.ActivateProfile:
|
||||
return success.Kind == app.ActivateProfile && failed.Name == success.Name
|
||||
case isProfileMutation(failed.Kind):
|
||||
return isProfileMutation(success.Kind) && failed.Name == success.Name
|
||||
case isTargetMutation(failed.Kind):
|
||||
return isTargetMutation(success.Kind) && failed.TargetID != "" && failed.TargetID == success.TargetID
|
||||
default:
|
||||
@@ -188,9 +194,19 @@ func isTargetMutation(kind app.IntentKind) bool {
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) finish() {
|
||||
// start seeds speculative Running progress before the stream publishes its
|
||||
// first update. A terminal update may contain no replacement progress (most
|
||||
// notably a confirmation request), so never let that transient phase outlive
|
||||
// the operation that created it.
|
||||
for targetID, phase := range o.progress {
|
||||
if phase == app.TargetProgressRunning {
|
||||
delete(o.progress, targetID)
|
||||
}
|
||||
}
|
||||
o.active = false
|
||||
o.intent = app.Intent{}
|
||||
o.updates = nil
|
||||
o.cancel = nil
|
||||
}
|
||||
|
||||
func (o *operationCoordinator) unexpectedClose() {
|
||||
@@ -225,6 +241,7 @@ func (o *operationCoordinator) confirmedIntent() (app.Intent, bool) {
|
||||
}
|
||||
intent := *o.confirmationIntent
|
||||
intent.Confirm = true
|
||||
intent.ConfirmationToken = o.confirmation.Token
|
||||
o.confirmation = nil
|
||||
o.confirmationDialog = confirmationDialog{}
|
||||
o.confirmationIntent = nil
|
||||
@@ -245,6 +262,9 @@ func (o operationCoordinator) deferredQuit() tea.Cmd {
|
||||
func (o *operationCoordinator) requestQuit() tea.Cmd {
|
||||
o.quitRequested = true
|
||||
if o.active {
|
||||
if o.cancel != nil {
|
||||
o.cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return tea.Quit
|
||||
|
||||
@@ -199,8 +199,6 @@ func (f *formState) toggleMode() tea.Cmd {
|
||||
return f.rebuildInputs(0)
|
||||
}
|
||||
|
||||
func (f formState) fieldCount() int { return len(f.inputs) }
|
||||
|
||||
func (f formState) currentField() profile.Field {
|
||||
if f.field < 0 || f.field >= len(f.inputs) {
|
||||
return ""
|
||||
|
||||
@@ -146,8 +146,8 @@ func TestCompleteModelProfileDeletionDialogIsSafeByDefaultAndSupportsExplicitSel
|
||||
model = updateKey(model, "right")
|
||||
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if command != nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
|
||||
t.Fatalf("focused Delete did not dispatch the confirmed mutation: intents=%#v", module.intents)
|
||||
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
|
||||
t.Fatalf("focused Delete did not start the confirmed mutation: intents=%#v", module.intents)
|
||||
}
|
||||
assertUsableFrame(t, model.View(), 80, 24, "> remote", "Proxy: http://remote.example:8080")
|
||||
}
|
||||
|
||||
@@ -76,8 +76,8 @@ func TestTargetRestartConfirmationCancelsWithoutConfirmedMutation(t *testing.T)
|
||||
}
|
||||
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
|
||||
model = updated.(Model)
|
||||
if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") {
|
||||
t.Fatalf("cancel mutated = intents %#v\n%s", module.intents, model.View())
|
||||
if command != nil || len(module.intents) != 1 || strings.Contains(model.View(), "restart immediately") || strings.Contains(model.View(), "Applying") {
|
||||
t.Fatalf("cancel mutated or left stale progress = intents %#v\n%s", module.intents, model.View())
|
||||
}
|
||||
|
||||
updated, command = model.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||
|
||||
Reference in New Issue
Block a user