feat: add searchable Active Profile picker
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -48,9 +48,9 @@ Start the Target panel with no arguments:
|
||||
proxyctl
|
||||
```
|
||||
|
||||
Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. 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. Select the Profile and press `a` or Enter to make it the Active Profile.
|
||||
Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. 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 `a` 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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("a"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
+28
-3
@@ -42,6 +42,7 @@ type Model struct {
|
||||
dashboardFocus dashboardPane
|
||||
showUnavailable bool
|
||||
detailsOffset int
|
||||
picker profilePickerState
|
||||
form formState
|
||||
confirm bool
|
||||
status string
|
||||
@@ -74,6 +75,7 @@ func NewModel(application app.InteractiveModule, configure ...ModelOption) Model
|
||||
width: 80,
|
||||
height: 24,
|
||||
operation: newOperationCoordinator(),
|
||||
picker: newProfilePickerState(),
|
||||
detect: probe.Detect,
|
||||
}
|
||||
if targetsChecking(snapshot) {
|
||||
@@ -126,9 +128,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()
|
||||
}
|
||||
@@ -259,6 +261,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 key.String() {
|
||||
case "y":
|
||||
@@ -294,6 +312,12 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
||||
switch key.String() {
|
||||
case "a":
|
||||
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 "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 opening Profile management"}
|
||||
@@ -562,6 +586,7 @@ func (m Model) View() string {
|
||||
dashboardFocus: m.dashboardFocus,
|
||||
showUnavailable: m.showUnavailable,
|
||||
detailsOffset: m.detailsOffset,
|
||||
picker: m.picker,
|
||||
form: m.form,
|
||||
confirm: m.confirm,
|
||||
confirmation: m.operation.confirmation,
|
||||
|
||||
@@ -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 {
|
||||
var details strings.Builder
|
||||
details.WriteString(p.theme.section.Render(model.confirmation.Title))
|
||||
@@ -66,7 +71,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 + "\na: switch Active Profile p: 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"
|
||||
}
|
||||
@@ -80,7 +85,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 + "\na: switch Active Profile p: manage Proxy Profiles r: refresh ?: help q: quit"
|
||||
if model.operationActive {
|
||||
footer = actions + "\n?: help q: defer safe exit; new mutations disabled"
|
||||
}
|
||||
@@ -100,7 +105,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 a: switch Active Profile\np: manage Proxy Profiles r: refresh ?: help q/Escape: back",
|
||||
focusLine: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ type presentationState struct {
|
||||
dashboardFocus dashboardPane
|
||||
showUnavailable bool
|
||||
detailsOffset int
|
||||
picker profilePickerState
|
||||
form formState
|
||||
confirm bool
|
||||
confirmation *app.Confirmation
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) {
|
||||
t.Fatalf("activation did not start exactly once: command=%v intents=%#v", command != nil, module.intents)
|
||||
}
|
||||
|
||||
for _, key := range []string{"p", "r", " "} {
|
||||
for _, key := range []string{"a", "p", "r", " "} {
|
||||
updated, blocked := model.Update(keyMessage(key))
|
||||
model = updated.(Model)
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
||||
@@ -192,7 +192,7 @@ func TestBusyRefreshBlocksTogglePageEntryRefreshAndActivation(t *testing.T) {
|
||||
t.Fatal("refresh did not start")
|
||||
}
|
||||
|
||||
for _, key := range []string{" ", "p", "r"} {
|
||||
for _, key := range []string{" ", "a", "p", "r"} {
|
||||
updated, blocked := model.Update(keyMessage(key))
|
||||
model = updated.(Model)
|
||||
if blocked != nil || model.page != mainPage || len(module.intents) != 1 {
|
||||
|
||||
Reference in New Issue
Block a user