diff --git a/.scratch/tui-redesign/issues/06-active-profile-picker.md b/.scratch/tui-redesign/issues/06-active-profile-picker.md index b3e5038..af466cf 100644 --- a/.scratch/tui-redesign/issues/06-active-profile-picker.md +++ b/.scratch/tui-redesign/issues/06-active-profile-picker.md @@ -4,16 +4,16 @@ **Blocked by:** 02 — Deliver the Operations Console dashboard; 04 — Unify operation coordination and status feedback. -**Status:** ready-for-agent +**Status:** done -- [ ] A dashboard action opens a keyboard-only modal Profile picker. -- [ ] The Active Profile is pinned first and all remaining Profiles are sorted by name. -- [ ] The picker supports filtering and scrolling across more Profiles than fit in the terminal. -- [ ] Enter activates the selected Profile and Escape closes the picker without mutation. -- [ ] Activation uses the unified progressive operation contract and displays per-Target progress on the dashboard. -- [ ] Docker restart confirmation appears when required, with cancellation as the safe default. -- [ ] Partial failure retains successful Target updates, closes or preserves the picker predictably, and focuses the first failed Target. -- [ ] Search text and selection survive resize when the selected Profile still exists. -- [ ] Empty and no-match states provide clear next actions without conflating switching with creation. -- [ ] Profile names and all operation text are defensively redacted before rendering. -- [ ] Complete-Model tests cover ordering, search, keyboard selection, resize, activation, confirmation, cancellation, partial failure, and empty states. +- [x] A dashboard action opens a keyboard-only modal Profile picker. +- [x] The Active Profile is pinned first and all remaining Profiles are sorted by name. +- [x] The picker supports filtering and scrolling across more Profiles than fit in the terminal. +- [x] Enter activates the selected Profile and Escape closes the picker without mutation. +- [x] Activation uses the unified progressive operation contract and displays per-Target progress on the dashboard. +- [x] Docker restart confirmation appears when required, with cancellation as the safe default. +- [x] Partial failure retains successful Target updates, closes or preserves the picker predictably, and focuses the first failed Target. +- [x] Search text and selection survive resize when the selected Profile still exists. +- [x] Empty and no-match states provide clear next actions without conflating switching with creation. +- [x] Profile names and all operation text are defensively redacted before rendering. +- [x] Complete-Model tests cover ordering, search, keyboard selection, resize, activation, confirmation, cancellation, partial failure, and empty states. diff --git a/README.md b/README.md index 6c3e0b0..162f8d8 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ Start the Target panel with no arguments: proxyctl ``` -Press `p` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. +Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. -The main panel shows the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` or Escape to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled. +The dashboard shows the Active Profile. Press `p` to open the searchable Active Profile picker, type to filter, use arrows to select, and press Enter to activate; Escape cancels without changing the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled. Profiles are human-readable under the user's XDG configuration directory (normally `~/.config/proxyctl/profiles.json`) and are stored owner-only because proxy URLs may contain credentials. diff --git a/go.mod b/go.mod index cecbd1e..ce44a2f 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect diff --git a/go.sum b/go.sum index d1a20cb..ee425d3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= diff --git a/internal/tui/active_profile_picker_test.go b/internal/tui/active_profile_picker_test.go new file mode 100644 index 0000000..89f5a84 --- /dev/null +++ b/internal/tui/active_profile_picker_test.go @@ -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) + } +} diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index fe9f112..dbcc3a3 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -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") } diff --git a/internal/tui/model.go b/internal/tui/model.go index 8dfea0b..c436308 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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, diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 1f5184a..c1dd106 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -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") diff --git a/internal/tui/pages.go b/internal/tui/pages.go index adadead..dc34afb 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -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, } } diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index 0d0937e..f424ad7 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -65,6 +65,7 @@ type presentationState struct { dashboardFocus dashboardPane showUnavailable bool detailsOffset int + picker profilePickerState form formState confirmation *app.Confirmation confirmationDialog *confirmationDialog diff --git a/internal/tui/profile_management_page_test.go b/internal/tui/profile_management_page_test.go index 497fb44..932ffbf 100644 --- a/internal/tui/profile_management_page_test.go +++ b/internal/tui/profile_management_page_test.go @@ -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) } diff --git a/internal/tui/profile_picker.go b/internal/tui/profile_picker.go new file mode 100644 index 0000000..c1ee291 --- /dev/null +++ b/internal/tui/profile_picker.go @@ -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() +} diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index 669f596..abf2ed2 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -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())