feat: add searchable Active Profile picker

This commit is contained in:
proxyctl coordinator
2026-07-17 22:32:41 +08:00
parent 7c0b4481d7
commit 306b02331f
10 changed files with 483 additions and 22 deletions
@@ -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.
+2 -2
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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=
+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("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
View File
@@ -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,
+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 {
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,
}
}
+1
View File
@@ -62,6 +62,7 @@ type presentationState struct {
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
picker profilePickerState
form formState
confirm bool
confirmation *app.Confirmation
+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()
}
+2 -2
View File
@@ -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 {