Merge Active Profile picker with Profile management

This commit is contained in:
proxyctl coordinator
2026-07-17 22:44:55 +08:00
13 changed files with 514 additions and 52 deletions
+241
View File
@@ -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)
}
}
+5 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+8 -3
View File
@@ -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,
}
}
+1
View File
@@ -65,6 +65,7 @@ type presentationState struct {
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
picker profilePickerState
form formState
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
+9 -9
View File
@@ -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)
}
+186
View File
@@ -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()
}
+3 -3
View File
@@ -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())