Merge Active Profile picker with Profile management
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
"proxyctl/internal/target"
|
||||
)
|
||||
|
||||
func pickerSnapshot(names ...string) app.Snapshot {
|
||||
profiles := make([]profile.Profile, len(names))
|
||||
for i, name := range names {
|
||||
profiles[i] = profile.Profile{Name: name}
|
||||
}
|
||||
return app.Snapshot{
|
||||
ActiveProfile: "Zulu",
|
||||
Profiles: profiles,
|
||||
Targets: []app.TargetSnapshot{
|
||||
{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled},
|
||||
{ID: "docker", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func openProfilePicker(t *testing.T, model Model) Model {
|
||||
t.Helper()
|
||||
updated, command := model.Update(keyMessage("p"))
|
||||
model = updated.(Model)
|
||||
if command != nil {
|
||||
// The Bubbles text input may ask Bubble Tea to start its cursor blink.
|
||||
_ = command
|
||||
}
|
||||
if !strings.Contains(model.View(), "Switch Active Profile") {
|
||||
t.Fatalf("dashboard action did not open the picker:\n%s", model.View())
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerPinsActiveProfileSortsOthersAndFiltersByTyping(t *testing.T) {
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot("beta", "Zulu", "alpha", "qaGamma")}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = openProfilePicker(t, model)
|
||||
|
||||
view := model.View()
|
||||
active := strings.Index(view, "> ★ Zulu — Active Profile")
|
||||
alpha := strings.Index(view, " alpha")
|
||||
beta := strings.Index(view, " beta")
|
||||
gamma := strings.Index(view, " qaGamma")
|
||||
if active < 0 || !(active < alpha && alpha < beta && beta < gamma) {
|
||||
t.Fatalf("picker order is not Active-first then name-sorted:\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "new") || strings.Contains(view, "edit") || strings.Contains(view, "delete") {
|
||||
t.Fatalf("routine picker conflated switching with Profile management:\n%s", view)
|
||||
}
|
||||
|
||||
// q is searchable input while the modal is open, not the dashboard quit key.
|
||||
model = updateKey(model, "q")
|
||||
model = updateRunes(model, "ag")
|
||||
view = model.View()
|
||||
if !strings.Contains(view, "> qaGamma") || strings.Contains(view, " alpha") || strings.Contains(view, " beta") || strings.Contains(view, "Active Profile — Zulu") {
|
||||
t.Fatalf("typed search did not filter and select the match:\n%s", view)
|
||||
}
|
||||
|
||||
model = resize(model, 120, 36)
|
||||
model = resize(model, 80, 24)
|
||||
view = model.View()
|
||||
if !strings.Contains(view, "qag") || !strings.Contains(view, "> qaGamma") {
|
||||
t.Fatalf("resize lost picker search or selected Profile:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerScrollsManyProfilesAndEnterActivatesSelection(t *testing.T) {
|
||||
names := make([]string, 30)
|
||||
for i := range names {
|
||||
names[i] = fmt.Sprintf("profile-%02d", i)
|
||||
}
|
||||
names = append(names, "Zulu")
|
||||
updates := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot(names...), streams: []chan app.OperationUpdate{updates}}
|
||||
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 60, 16))
|
||||
for range 20 {
|
||||
model = updateKey(model, "down")
|
||||
}
|
||||
if view := model.View(); !strings.Contains(view, "> profile-19") {
|
||||
t.Fatalf("selection did not scroll into the short picker viewport:\n%s", view)
|
||||
}
|
||||
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if command == nil || strings.Contains(model.View(), "Switch Active Profile") {
|
||||
t.Fatalf("Enter did not close the picker and start activation:\n%s", model.View())
|
||||
}
|
||||
if len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "profile-19" {
|
||||
t.Fatalf("Enter dispatched %#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerEscapeAndEmptyStatesDoNotMutate(t *testing.T) {
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "alpha")}
|
||||
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24))
|
||||
model = updateKey(model, "esc")
|
||||
if strings.Contains(model.View(), "Switch Active Profile") || len(module.intents) != 0 {
|
||||
t.Fatalf("Escape did not cancel without mutation: intents=%#v\n%s", module.intents, model.View())
|
||||
}
|
||||
|
||||
empty := &interactiveScriptModule{snapshot: app.Snapshot{}}
|
||||
model = openProfilePicker(t, resizedModel(t, empty, themeModeNoColor, 80, 24))
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "No Proxy Profiles are available") || !strings.Contains(view, "Escape") || !strings.Contains(view, "Profile management") {
|
||||
t.Fatalf("empty picker lacks a distinct next action:\n%s", view)
|
||||
}
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if command != nil || len(empty.intents) != 0 || !strings.Contains(model.View(), "No Proxy Profiles are available") {
|
||||
t.Fatalf("Enter mutated the empty picker: intents=%#v", empty.intents)
|
||||
}
|
||||
|
||||
noMatch := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "alpha")}
|
||||
model = openProfilePicker(t, resizedModel(t, noMatch, themeModeNoColor, 80, 24))
|
||||
model = updateRunes(model, "missing")
|
||||
view = model.View()
|
||||
if !strings.Contains(view, `No Proxy Profiles match "missing"`) || !strings.Contains(view, "Edit the search") {
|
||||
t.Fatalf("no-match picker lacks a search-specific next action:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerActivationUsesProgressAndSafeDockerConfirmation(t *testing.T) {
|
||||
confirmation := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "new"), streams: []chan app.OperationUpdate{confirmation}}
|
||||
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 120, 36))
|
||||
model = updateKey(model, "down")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
confirmation <- app.ConfirmationOperationUpdate(module.snapshot, app.Confirmation{
|
||||
Title: "Confirm Active Profile change", Summary: "Docker will restart and running work may be disrupted.",
|
||||
ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true,
|
||||
})
|
||||
close(confirmation)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Confirm Active Profile change") || !strings.Contains(view, "Enter/n/Esc: Cancel") {
|
||||
t.Fatalf("activation did not use safe-default confirmation:\n%s", view)
|
||||
}
|
||||
|
||||
updated, next := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
if next != nil || len(module.intents) != 1 || strings.Contains(model.View(), "Confirm Active Profile change") || model.snapshot.ActiveProfile != "Zulu" {
|
||||
t.Fatalf("confirmation cancellation was not mutation-free: intents=%#v\n%s", module.intents, model.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerConfirmedActivationContinuesThroughUnifiedOperation(t *testing.T) {
|
||||
confirmation := make(chan app.OperationUpdate, 1)
|
||||
completion := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "new"), streams: []chan app.OperationUpdate{confirmation, completion}}
|
||||
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 120, 36))
|
||||
model = updateKey(model, "down")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
confirmation <- app.ConfirmationOperationUpdate(module.snapshot, app.Confirmation{
|
||||
Title: "Confirm Active Profile change", Summary: "Docker will restart.",
|
||||
ConfirmLabel: "Change Profile", CancelLabel: "Cancel", HighImpact: true,
|
||||
})
|
||||
close(confirmation)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
|
||||
updated, command = model.Update(keyMessage("y"))
|
||||
model = updated.(Model)
|
||||
if command == nil || len(module.intents) != 2 || !module.intents[1].Confirm || module.intents[1].Name != "new" {
|
||||
t.Fatalf("confirmation did not continue the selected activation: intents=%#v", module.intents)
|
||||
}
|
||||
completed := pickerSnapshot("Zulu", "new")
|
||||
completed.ActiveProfile = "new"
|
||||
completion <- app.SucceededOperationUpdate(completed, app.OperationStatus{Summary: `Activated Profile "new"`}, nil)
|
||||
close(completion)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
if view := model.View(); !strings.Contains(view, "Active Profile: new") || !strings.Contains(view, `Activated Profile "new"`) {
|
||||
t.Fatalf("confirmed activation did not finish on the dashboard:\n%s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerPartialFailureKeepsProgressClosesPickerAndFocusesFailure(t *testing.T) {
|
||||
updates := make(chan app.OperationUpdate, 2)
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", "new"), streams: []chan app.OperationUpdate{updates}}
|
||||
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 120, 36))
|
||||
model = updateKey(model, "down")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
|
||||
running := pickerSnapshot("Zulu", "new")
|
||||
running.ActiveProfile = "new"
|
||||
updates <- app.RunningOperationUpdate(running,
|
||||
app.OperationStatus{Summary: "Updating Shell Target", TargetID: "shell"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressRunning}})
|
||||
model, command = applyOperationCommand(t, model, command)
|
||||
if view := model.View(); strings.Contains(view, "Switch Active Profile") || !strings.Contains(view, "Applying") || !strings.Contains(view, "Updating Shell Target") {
|
||||
t.Fatalf("activation progress was not shown on the dashboard:\n%s", view)
|
||||
}
|
||||
|
||||
failed := running
|
||||
failed.Targets[0].State = target.Enabled
|
||||
failed.Targets[1].State = target.Drifted
|
||||
updates <- app.FailedOperationUpdate(failed,
|
||||
app.OperationStatus{Summary: "Activated Profile new with 1 Target update failure", TargetID: "docker"},
|
||||
[]app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded}, {TargetID: "docker", Phase: app.TargetProgressFailed}},
|
||||
"docker", errors.New("Docker update failed"))
|
||||
close(updates)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
view := model.View()
|
||||
if model.selectedTargetID != "docker" || !strings.Contains(view, "> ! Failed Docker daemon") || !strings.Contains(view, "Current error: Activated Profile new with 1 Target update failure") || !strings.Contains(view, "Active Profile: new") {
|
||||
t.Fatalf("partial failure was not retained and focused: selected=%q\n%s", model.selectedTargetID, view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelPickerDefensivelyRedactsNamesAndOperationText(t *testing.T) {
|
||||
const secret = "picker-secret"
|
||||
credential := "https://alice:" + secret + "@proxy.example:8443"
|
||||
updates := make(chan app.OperationUpdate, 1)
|
||||
module := &interactiveScriptModule{snapshot: pickerSnapshot("Zulu", credential), streams: []chan app.OperationUpdate{updates}}
|
||||
model := openProfilePicker(t, resizedModel(t, module, themeModeNoColor, 80, 24))
|
||||
if view := model.View(); strings.Contains(view, secret) || !strings.Contains(view, "REDACTED") {
|
||||
t.Fatalf("picker exposed a Profile name credential:\n%s", view)
|
||||
}
|
||||
model = updateKey(model, "down")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
model = updated.(Model)
|
||||
updates <- app.FailedOperationUpdate(module.snapshot,
|
||||
app.OperationStatus{Summary: "failed " + credential}, nil, "", errors.New("detail "+credential))
|
||||
close(updates)
|
||||
model, _ = applyOperationCommand(t, model, command)
|
||||
model = updateKey(model, "e")
|
||||
if view := model.View(); strings.Contains(view, secret) || !strings.Contains(view, "REDACTED") {
|
||||
t.Fatalf("picker operation text exposed a credential:\n%s", view)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
|
||||
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "e")
|
||||
model = updateKey(model, "ctrl+t")
|
||||
for range 4 {
|
||||
@@ -147,7 +147,7 @@ func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) {
|
||||
model = updateKey(model, "?")
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "q")
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
frames = append(frames, model.View())
|
||||
model = updateKey(model, "e")
|
||||
frames = append(frames, model.View())
|
||||
@@ -200,7 +200,8 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl
|
||||
"Docker daemon selected evidence.",
|
||||
"Status: Refresh completed; selected Target remains actionable.",
|
||||
"Space: toggle",
|
||||
"p: manage Proxy Profiles",
|
||||
"p: switch Active Profile",
|
||||
"m: manage Proxy Profiles",
|
||||
)
|
||||
|
||||
model = resize(model, 120, 36)
|
||||
@@ -224,7 +225,7 @@ func TestCompleteModelLongProfileListKeepsLastSelectionDetailsAndActionsVisibleA
|
||||
}
|
||||
module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: profiles[0].Name, Profiles: profiles}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
for range len(profiles) - 1 {
|
||||
model = updateKey(model, "down")
|
||||
}
|
||||
|
||||
+28
-3
@@ -47,6 +47,7 @@ type Model struct {
|
||||
dashboardFocus dashboardPane
|
||||
showUnavailable bool
|
||||
detailsOffset int
|
||||
picker profilePickerState
|
||||
form formState
|
||||
status string
|
||||
operation operationCoordinator
|
||||
@@ -83,6 +84,7 @@ func NewModel(application app.InteractiveModule, configure ...ModelOption) Model
|
||||
width: 80,
|
||||
height: 24,
|
||||
operation: newOperationCoordinator(),
|
||||
picker: newProfilePickerState(),
|
||||
detect: probe.Detect,
|
||||
}
|
||||
model.retainProfileSelection("", 0)
|
||||
@@ -137,9 +139,9 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if key, ok := message.(tea.KeyMsg); ok {
|
||||
// Dashboard q always means exit, even when confirmation is visible.
|
||||
// Escape remains the deterministic cancel/back key.
|
||||
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q")
|
||||
// q exits the unobscured dashboard. While the searchable picker is open,
|
||||
// it is ordinary filter input; Escape remains deterministic cancellation.
|
||||
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && !m.picker.open && key.String() == "q")
|
||||
if exitRequest {
|
||||
return m, m.operation.requestQuit()
|
||||
}
|
||||
@@ -272,6 +274,22 @@ func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, ac
|
||||
}
|
||||
|
||||
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.picker.open {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
m.picker.close()
|
||||
return m, nil
|
||||
case "enter":
|
||||
selected, ok := m.picker.selected(m.snapshot)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
m.picker.close()
|
||||
return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
|
||||
default:
|
||||
return m, m.picker.update(key, m.snapshot)
|
||||
}
|
||||
}
|
||||
if m.operation.confirmation != nil {
|
||||
switch m.operation.confirmationDialog.update(key.String()) {
|
||||
case confirmationAccepted:
|
||||
@@ -308,6 +326,12 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
||||
switch key.String() {
|
||||
case "p":
|
||||
if m.operation.active || m.operation.quitRequested {
|
||||
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
|
||||
} else {
|
||||
return m, m.picker.show(m.snapshot)
|
||||
}
|
||||
case "m":
|
||||
if m.operation.active || m.operation.quitRequested {
|
||||
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Profile management disabled: wait for the current mutation to finish"}
|
||||
} else {
|
||||
@@ -650,6 +674,7 @@ func (m Model) View() string {
|
||||
dashboardFocus: m.dashboardFocus,
|
||||
showUnavailable: m.showUnavailable,
|
||||
detailsOffset: m.detailsOffset,
|
||||
picker: m.picker,
|
||||
form: m.form,
|
||||
confirmation: m.operation.confirmation,
|
||||
confirmationDialog: &m.operation.confirmationDialog,
|
||||
|
||||
+16
-16
@@ -49,7 +49,7 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
|
||||
t.Fatalf("main view missing Active Profile:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "Proxy Profile management") || !strings.Contains(view, "alice:") {
|
||||
t.Fatalf("Profile page did not render selected Profile:\n%s", view)
|
||||
@@ -71,7 +71,7 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
|
||||
func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) {
|
||||
module := &recordingModule{}
|
||||
model := NewModel(module)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -100,7 +100,7 @@ func TestEditingSingleURLExpandsCurrentValueWhenSwitchingToAdvanced(t *testing.T
|
||||
HTTPURL: oldURL, HTTPSURL: oldURL, AllURL: oldURL,
|
||||
}}}}
|
||||
model := NewModel(module)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "e")
|
||||
model = updateKey(model, "tab")
|
||||
for range oldURL {
|
||||
@@ -124,7 +124,7 @@ func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
|
||||
Name: "manual", Mode: profile.SingleMode, SingleURL: "http://alice:do-not-show@proxy.example:bad",
|
||||
}}}}
|
||||
model := NewModel(module)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
if view := model.View(); strings.Contains(view, "do-not-show") {
|
||||
t.Fatalf("malformed proxy URL exposed password:\n%s", view)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestRenderedMalformedProxyURLStillObscuresPassword(t *testing.T) {
|
||||
func TestNoProfileStateStaysOnAccessibleMainPage(t *testing.T) {
|
||||
model := NewModel(&recordingModule{})
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "p: manage Proxy Profiles") {
|
||||
if !strings.Contains(view, "No Active Profile") || !strings.Contains(view, "m: manage Proxy Profiles") {
|
||||
t.Fatalf("no-Profile main view is not actionable:\n%s", view)
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestProtocolProbeDetectsHTTPAndRewritesSingleURL(t *testing.T) {
|
||||
probed = append(probed, address)
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -185,7 +185,7 @@ func TestProtocolProbeFailureLeavesFieldUntouchedAndSavePossible(t *testing.T) {
|
||||
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{}, errors.New("connection refused")
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -216,7 +216,7 @@ func TestProtocolProbeUnrecognizedProtocolLeavesFieldUntouched(t *testing.T) {
|
||||
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -245,7 +245,7 @@ func TestProtocolProbeIgnoresRepeatedCtrlDWhileInFlight(t *testing.T) {
|
||||
calls++
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -276,7 +276,7 @@ func TestProtocolProbeLateResultIgnoredAfterEscLeavesForm(t *testing.T) {
|
||||
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -309,7 +309,7 @@ func TestProtocolProbeLateResultIgnoredAfterSaveLeavesForm(t *testing.T) {
|
||||
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -348,7 +348,7 @@ func TestSaveFormDoesNotTriggerProtocolProbe(t *testing.T) {
|
||||
calls++
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -371,7 +371,7 @@ func TestProtocolProbeOnlyTriggersOnSingleURLField(t *testing.T) {
|
||||
calls++
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
|
||||
@@ -409,7 +409,7 @@ func TestProtocolProbeDetectsDualProtocolEndpointBuildsCompatibleAdvancedProfile
|
||||
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -437,7 +437,7 @@ func TestProtocolProbeDetectsSOCKS5AndRewritesSingleURL(t *testing.T) {
|
||||
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||
return probe.Capabilities{SOCKS5: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "tab")
|
||||
@@ -466,7 +466,7 @@ func TestProtocolProbeInAdvancedModeShowsHintWithoutProbing(t *testing.T) {
|
||||
calls++
|
||||
return probe.Capabilities{HTTP: true}, nil
|
||||
}
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "home")
|
||||
model = updateKey(model, "ctrl+t")
|
||||
|
||||
@@ -41,6 +41,11 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
|
||||
operationProgress: model.operationProgress,
|
||||
},
|
||||
}
|
||||
if model.picker.open {
|
||||
page.dashboard = nil
|
||||
page.primary = model.picker.view(model.snapshot, width, max(5, height-9))
|
||||
page.footer = "↑/↓ PgUp/PgDn: select Enter: activate Esc: cancel"
|
||||
}
|
||||
if model.confirmation != nil {
|
||||
dialog := newConfirmationDialog(*model.confirmation)
|
||||
if model.confirmationDialog != nil {
|
||||
@@ -62,7 +67,7 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t
|
||||
visibility = "u: hide unavailable"
|
||||
}
|
||||
if model.dashboardFocus == targetDetailsPane {
|
||||
footer := "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: 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: quit"
|
||||
if model.operationActive {
|
||||
footer = "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets ?: help\nMutations disabled: wait for the current mutation to finish"
|
||||
}
|
||||
@@ -76,7 +81,7 @@ func (p terminalPresentation) dashboardFooter(model presentationState, catalog t
|
||||
} else {
|
||||
actions = "Space: disabled (" + availability.compactReason + ") " + actions
|
||||
}
|
||||
footer := actions + "\np: manage Proxy Profiles r: refresh ?: help q: quit"
|
||||
footer := actions + "\np: switch Active Profile m: manage Proxy Profiles r: refresh ?: help q: quit"
|
||||
if model.operationActive {
|
||||
footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
|
||||
}
|
||||
@@ -96,7 +101,7 @@ func (p terminalPresentation) helpView(model presentationState) terminalPage {
|
||||
return terminalPage{
|
||||
header: p.theme.title.Render("proxyctl Help — built-in Targets"),
|
||||
primary: strings.TrimRight(body.String(), "\n"),
|
||||
footer: "↑/k ↓/j: navigate Space: toggle p: manage Proxy Profiles\nr: refresh ?: help q/Escape: back",
|
||||
footer: "↑/k ↓/j: navigate Space: toggle p: switch Active Profile\nm: manage Proxy Profiles r: refresh ?: help q/Escape: back",
|
||||
focusLine: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type presentationState struct {
|
||||
dashboardFocus dashboardPane
|
||||
showUnavailable bool
|
||||
detailsOffset int
|
||||
picker profilePickerState
|
||||
form formState
|
||||
confirmation *app.Confirmation
|
||||
confirmationDialog *confirmationDialog
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestCompleteModelProfileManagementScrollsAndReviewsObscuredDetailsAcrossRes
|
||||
}
|
||||
module := &profileManagementModule{snapshot: app.Snapshot{ActiveProfile: "site-00", Profiles: profiles}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
for range 24 {
|
||||
model = updateKey(model, "down")
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestCompleteModelProfileManagementHasCreateAndEditEntryPointsButNoActivatio
|
||||
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
||||
}}}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
view := model.View()
|
||||
assertUsableFrame(t, view, 80, 24, "n: create", "e: edit", "d: delete", "q/Esc: back")
|
||||
if strings.Contains(view, "activate") {
|
||||
@@ -125,7 +125,7 @@ func TestCompleteModelProfileDeletionDialogIsSafeByDefaultAndSupportsExplicitSel
|
||||
Name: "remote", Mode: profile.SingleMode, SingleURL: "http://remote.example:8080",
|
||||
}}}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "d")
|
||||
view := model.View()
|
||||
assertUsableFrame(t, view, 80, 24, "Delete Proxy Profile “office”?", "> Cancel", "Delete", "Enter: choose")
|
||||
@@ -157,7 +157,7 @@ func TestCompleteModelFinalProfileDeletionExplainsDriftAndRequiresExplicitConfir
|
||||
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
||||
}}}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
model = updateKey(model, "d")
|
||||
model = resize(model, 60, 16)
|
||||
view := model.View()
|
||||
@@ -183,7 +183,7 @@ func TestCompleteModelProfileSelectionUsesIdentityAcrossResizeAndReorderedCRUDRe
|
||||
}
|
||||
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: profiles}, reverseUpdate: true}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
for range 17 {
|
||||
model = updateKey(model, "down")
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func TestCompleteModelProfileManagementDoesNotConflateSavedAndActiveProfileState
|
||||
Name: "available", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
||||
}}}}
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
view := model.View()
|
||||
assertUsableFrame(t, view, 80, 24, "> available", "Selected Profile")
|
||||
if strings.Contains(view, "No Active Profile") || strings.Contains(view, "No Proxy Profiles") {
|
||||
@@ -216,7 +216,7 @@ func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *
|
||||
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{Name: "office"}}}}
|
||||
for _, back := range []tea.KeyMsg{{Type: tea.KeyEsc}, {Type: tea.KeyRunes, Runes: []rune{'q'}}} {
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
updated, command := model.Update(back)
|
||||
model = updated.(Model)
|
||||
if command != nil || !strings.Contains(model.View(), "Operations Console") {
|
||||
@@ -225,7 +225,7 @@ func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *
|
||||
}
|
||||
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "p")
|
||||
model = updateKey(model, "m")
|
||||
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
|
||||
if command == nil {
|
||||
t.Fatal("Ctrl+C on Profile management did not follow the global exit contract")
|
||||
@@ -240,7 +240,7 @@ func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *
|
||||
Targets: []app.TargetSnapshot{{ID: "shell", DetectionPhase: app.TargetChecking}},
|
||||
}}
|
||||
busy := resizedModel(t, checking, themeModeNoColor, 80, 24)
|
||||
busy = updateKey(busy, "p")
|
||||
busy = updateKey(busy, "m")
|
||||
if view := busy.View(); !strings.Contains(view, "Profile management disabled: wait for the current mutation to finish") {
|
||||
t.Fatalf("disabled Profile management action did not expose its reason:\n%s", view)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
// profilePickerState is the dashboard's routine Active Profile switching
|
||||
// component. Bubbles owns text editing and viewport clipping; this type only
|
||||
// supplies the product ordering and identity-based selection rules.
|
||||
type profilePickerState struct {
|
||||
open bool
|
||||
search textinput.Model
|
||||
selectedName string
|
||||
}
|
||||
|
||||
func newProfilePickerState() profilePickerState {
|
||||
search := textinput.New()
|
||||
search.Prompt = "Search: "
|
||||
search.Placeholder = "type to filter by name"
|
||||
search.CharLimit = 256
|
||||
return profilePickerState{search: search}
|
||||
}
|
||||
|
||||
func (p *profilePickerState) show(snapshot app.Snapshot) tea.Cmd {
|
||||
p.open = true
|
||||
p.search.SetValue("")
|
||||
p.search.CursorEnd()
|
||||
p.selectedName = ""
|
||||
p.retainSelection(snapshot)
|
||||
return p.search.Focus()
|
||||
}
|
||||
|
||||
func (p *profilePickerState) close() {
|
||||
p.open = false
|
||||
p.search.Blur()
|
||||
}
|
||||
|
||||
func (p *profilePickerState) update(key tea.KeyMsg, snapshot app.Snapshot) tea.Cmd {
|
||||
switch key.String() {
|
||||
case "up", "ctrl+p":
|
||||
p.move(snapshot, -1)
|
||||
return nil
|
||||
case "down", "ctrl+n":
|
||||
p.move(snapshot, 1)
|
||||
return nil
|
||||
case "pgup":
|
||||
p.move(snapshot, -8)
|
||||
return nil
|
||||
case "pgdown":
|
||||
p.move(snapshot, 8)
|
||||
return nil
|
||||
}
|
||||
previous := p.search.Value()
|
||||
var command tea.Cmd
|
||||
p.search, command = p.search.Update(key)
|
||||
if p.search.Value() != previous {
|
||||
p.retainSelection(snapshot)
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
func (p *profilePickerState) move(snapshot app.Snapshot, delta int) {
|
||||
items := p.filtered(snapshot)
|
||||
if len(items) == 0 {
|
||||
p.selectedName = ""
|
||||
return
|
||||
}
|
||||
index := profileIndex(items, p.selectedName)
|
||||
if index < 0 {
|
||||
index = 0
|
||||
}
|
||||
index = max(0, min(len(items)-1, index+delta))
|
||||
p.selectedName = items[index].Name
|
||||
}
|
||||
|
||||
func (p *profilePickerState) retainSelection(snapshot app.Snapshot) {
|
||||
items := p.filtered(snapshot)
|
||||
if profileIndex(items, p.selectedName) >= 0 {
|
||||
return
|
||||
}
|
||||
p.selectedName = ""
|
||||
if len(items) > 0 {
|
||||
p.selectedName = items[0].Name
|
||||
}
|
||||
}
|
||||
|
||||
func (p profilePickerState) selected(snapshot app.Snapshot) (profile.Profile, bool) {
|
||||
items := p.filtered(snapshot)
|
||||
index := profileIndex(items, p.selectedName)
|
||||
if index < 0 {
|
||||
return profile.Profile{}, false
|
||||
}
|
||||
return items[index], true
|
||||
}
|
||||
|
||||
func (p profilePickerState) filtered(snapshot app.Snapshot) []profile.Profile {
|
||||
ordered := orderedProfiles(snapshot)
|
||||
query := strings.ToLower(strings.TrimSpace(p.search.Value()))
|
||||
if query == "" {
|
||||
return ordered
|
||||
}
|
||||
filtered := make([]profile.Profile, 0, len(ordered))
|
||||
for _, item := range ordered {
|
||||
if strings.Contains(strings.ToLower(item.Name), query) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func orderedProfiles(snapshot app.Snapshot) []profile.Profile {
|
||||
items := append([]profile.Profile(nil), snapshot.Profiles...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left, right := strings.ToLower(items[i].Name), strings.ToLower(items[j].Name)
|
||||
if left == right {
|
||||
return items[i].Name < items[j].Name
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
activeIndex := profileIndex(items, snapshot.ActiveProfile)
|
||||
if activeIndex <= 0 {
|
||||
return items
|
||||
}
|
||||
active := items[activeIndex]
|
||||
copy(items[1:activeIndex+1], items[:activeIndex])
|
||||
items[0] = active
|
||||
return items
|
||||
}
|
||||
|
||||
func profileIndex(items []profile.Profile, name string) int {
|
||||
for i := range items {
|
||||
if items[i].Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (p profilePickerState) view(snapshot app.Snapshot, width, height int) string {
|
||||
search := p.search
|
||||
search.Width = max(12, width-len(search.Prompt)-4)
|
||||
|
||||
items := p.filtered(snapshot)
|
||||
lines := make([]string, 0, len(items))
|
||||
selectedIndex := profileIndex(items, p.selectedName)
|
||||
for _, item := range items {
|
||||
cursor := " "
|
||||
if item.Name == p.selectedName {
|
||||
cursor = "> "
|
||||
}
|
||||
active := ""
|
||||
if item.Name == snapshot.ActiveProfile {
|
||||
active = "★ "
|
||||
}
|
||||
line := cursor + active + item.Name
|
||||
if item.Name == snapshot.ActiveProfile {
|
||||
line += " — Active Profile"
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
selectedIndex = 0
|
||||
if len(snapshot.Profiles) == 0 {
|
||||
lines = append(lines, "No Proxy Profiles are available.", "Press Escape, then open Profile management to create one.")
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("No Proxy Profiles match %q.", p.search.Value()), "Edit the search or press Escape to cancel.")
|
||||
}
|
||||
}
|
||||
|
||||
listHeight := max(2, height-5)
|
||||
list := viewport.New(max(1, width), listHeight)
|
||||
list.SetContent(strings.Join(lines, "\n"))
|
||||
if selectedIndex >= listHeight {
|
||||
list.SetYOffset(selectedIndex - listHeight + 1)
|
||||
}
|
||||
return "Switch Active Profile\n" + search.View() + "\n" + paneRule(width) + "\n" + list.View()
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestBusyProfileTransitionBlocksPageEntryAndEveryNewOperation(t *testing.T)
|
||||
t.Fatalf("Profile transition did not start exactly once: command=%v intents=%#v", command != nil, module.intents)
|
||||
}
|
||||
|
||||
for _, key := range []string{"p", "r", " "} {
|
||||
for _, key := range []string{"p", "m", "r", " "} {
|
||||
updated, blocked := model.Update(keyMessage(key))
|
||||
model = updated.(Model)
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
||||
@@ -191,7 +191,7 @@ func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
|
||||
t.Fatal("refresh did not start")
|
||||
}
|
||||
|
||||
for _, key := range []string{" ", "p", "r"} {
|
||||
for _, key := range []string{" ", "p", "m", "r"} {
|
||||
updated, blocked := model.Update(keyMessage(key))
|
||||
model = updated.(Model)
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
||||
@@ -281,7 +281,7 @@ func TestQAndEscapeReturnFromNestedPages(t *testing.T) {
|
||||
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
|
||||
t.Fatalf("help key %q did not return to Operations Console", key.String())
|
||||
}
|
||||
model = updateKey(NewModel(module), "p")
|
||||
model = updateKey(NewModel(module), "m")
|
||||
updated, command = model.Update(key)
|
||||
if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") {
|
||||
t.Fatalf("Profile key %q did not return to Target panel", key.String())
|
||||
|
||||
Reference in New Issue
Block a user