feat: add dedicated Profile management page

This commit is contained in:
proxyctl coordinator
2026-07-17 22:37:25 +08:00
parent 7c0b4481d7
commit d34ce0cd6b
11 changed files with 583 additions and 160 deletions
@@ -4,16 +4,16 @@
**Blocked by:** 01 — Build the responsive TUI foundation; 04 — Unify operation coordination and status feedback. **Blocked by:** 01 — Build the responsive TUI foundation; 04 — Unify operation coordination and status feedback.
**Status:** ready-for-agent **Status:** done
- [ ] Profile management is a distinct page from the dashboard activation picker. - [x] Profile management is a distinct page from the dashboard activation picker.
- [ ] The Profile list scrolls and keeps selection visible with more entries than fit on screen. - [x] The Profile list scrolls and keeps selection visible with more entries than fit on screen.
- [ ] The selected Profile's mode, obscured URLs, and Bypass values are reviewable without exposing credentials. - [x] The selected Profile's mode, obscured URLs, and Bypass values are reviewable without exposing credentials.
- [ ] Create, edit, and delete entry points are available through contextual keyboard actions. - [x] Create, edit, and delete entry points are available through contextual keyboard actions.
- [ ] Deletion uses a reusable confirmation dialog with cancellation focused by default. - [x] Deletion uses a reusable confirmation dialog with cancellation focused by default.
- [ ] Deleting the final Proxy Profile clearly explains that existing managed configuration remains and becomes Drifted. - [x] Deleting the final Proxy Profile clearly explains that existing managed configuration remains and becomes Drifted.
- [ ] Escape and `q` return to the previous page; Ctrl+C follows the global exit contract. - [x] Escape and `q` return to the previous page; Ctrl+C follows the global exit contract.
- [ ] Operations disabled by in-flight work expose a reason rather than silently ignoring input. - [x] Operations disabled by in-flight work expose a reason rather than silently ignoring input.
- [ ] Selection and viewport survive resize and CRUD results whenever the referenced Profile still exists. - [x] Selection and viewport survive resize and CRUD results whenever the referenced Profile still exists.
- [ ] Empty-state copy provides a direct creation path and does not incorrectly claim no Active Profile when non-active Profiles exist. - [x] Empty-state copy provides a direct creation path and does not incorrectly claim no Active Profile when non-active Profiles exist.
- [ ] Complete-Model tests cover long lists, details, create/edit entry, deletion confirmation, final deletion, cancellation, resize, and navigation consistency. - [x] Complete-Model tests cover long lists, details, create/edit entry, deletion confirmation, final deletion, cancellation, resize, and navigation consistency.
+1 -1
View File
@@ -48,7 +48,7 @@ Start the Target panel with no arguments:
proxyctl 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, 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 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.
+71
View File
@@ -0,0 +1,71 @@
package tui
import (
"fmt"
"strings"
"proxyctl/internal/app"
)
type confirmationResult uint8
const (
confirmationPending confirmationResult = iota
confirmationAccepted
confirmationCancelled
)
// confirmationDialog is the reusable safe-default interaction used for
// destructive Profile deletion and Application-requested confirmations.
// confirmFocused is deliberately false in every new dialog.
type confirmationDialog struct {
request app.Confirmation
confirmFocused bool
}
func newConfirmationDialog(request app.Confirmation) confirmationDialog {
return confirmationDialog{request: request}
}
func (d *confirmationDialog) update(key string) confirmationResult {
switch key {
case "left", "right", "tab", "shift+tab":
d.confirmFocused = !d.confirmFocused
case "y":
return confirmationAccepted
case "n", "esc", "q":
return confirmationCancelled
case "enter":
if d.confirmFocused {
return confirmationAccepted
}
return confirmationCancelled
}
return confirmationPending
}
func (d confirmationDialog) view() string {
confirmLabel := d.request.ConfirmLabel
if confirmLabel == "" {
confirmLabel = "Confirm"
}
cancelLabel := d.request.CancelLabel
if cancelLabel == "" {
cancelLabel = "Cancel"
}
confirm, cancel := " "+confirmLabel, "> "+cancelLabel
if d.confirmFocused {
confirm, cancel = "> "+confirmLabel, " "+cancelLabel
}
var body strings.Builder
body.WriteString(d.request.Title)
if d.request.Summary != "" {
fmt.Fprintf(&body, "\n%s", d.request.Summary)
}
for _, detail := range d.request.Details {
fmt.Fprintf(&body, "\n• %s", detail)
}
fmt.Fprintf(&body, "\n\n%s %s", cancel, confirm)
return body.String()
}
+2 -1
View File
@@ -234,7 +234,8 @@ func TestCompleteModelLongProfileListKeepsLastSelectionDetailsAndActionsVisibleA
assertUsableFrame(t, model.View(), size.width, size.height, assertUsableFrame(t, model.View(), size.width, size.height,
"> regional-office-31", "> regional-office-31",
"Proxy: http://proxy-31.example:8080", "Proxy: http://proxy-31.example:8080",
"a/Enter: activate", "n: create",
"d: delete",
"q/Esc: back", "q/Esc: back",
) )
} }
+165 -77
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -31,22 +32,25 @@ type formState struct {
} }
type Model struct { type Model struct {
application app.InteractiveModule application app.InteractiveModule
presentation terminalPresentation presentation terminalPresentation
snapshot app.Snapshot snapshot app.Snapshot
page page page page
width int width int
height int height int
selected int selected int
selectedTargetID string selectedProfileName string
dashboardFocus dashboardPane profileViewportOffset int
showUnavailable bool profileDetailsOffset int
detailsOffset int profileDelete *profileDeleteConfirmation
form formState selectedTargetID string
confirm bool dashboardFocus dashboardPane
status string showUnavailable bool
operation operationCoordinator detailsOffset int
detect func(context.Context, string) (probe.Capabilities, error) form formState
status string
operation operationCoordinator
detect func(context.Context, string) (probe.Capabilities, error)
} }
// probeDone carries one finished Protocol Probe back to the form. address is // probeDone carries one finished Protocol Probe back to the form. address is
@@ -60,6 +64,11 @@ type probeDone struct {
gen uint64 gen uint64
} }
type profileDeleteConfirmation struct {
name string
dialog confirmationDialog
}
func NewModel(application app.InteractiveModule, configure ...ModelOption) Model { func NewModel(application app.InteractiveModule, configure ...ModelOption) Model {
options := defaultModelOptions() options := defaultModelOptions()
for _, option := range configure { for _, option := range configure {
@@ -76,6 +85,7 @@ func NewModel(application app.InteractiveModule, configure ...ModelOption) Model
operation: newOperationCoordinator(), operation: newOperationCoordinator(),
detect: probe.Detect, detect: probe.Detect,
} }
model.retainProfileSelection("", 0)
if targetsChecking(snapshot) { if targetsChecking(snapshot) {
model.startOperation(app.Intent{Kind: app.RefreshTargets}) model.startOperation(app.Intent{Kind: app.RefreshTargets})
} }
@@ -115,6 +125,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok { if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width m.width = size.Width
m.height = size.Height m.height = size.Height
m.ensureProfileSelectionVisible()
return m, nil return m, nil
} }
@@ -140,11 +151,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if progress.closed { if progress.closed {
m.snapshot = m.application.Snapshot() m.snapshot = m.application.Snapshot()
m.retainTargetSelection() m.retainTargetSelection()
m.retainProfileSelection(m.selectedProfileName, m.selected)
m.operation.unexpectedClose() m.operation.unexpectedClose()
return m, m.operation.deferredQuit() return m, m.operation.deferredQuit()
} }
m.snapshot = progress.update.Snapshot m.snapshot = progress.update.Snapshot
m.retainTargetSelection() m.retainTargetSelection()
m.retainProfileSelection(m.selectedProfileName, m.selected)
if firstFailed := m.operation.apply(progress.update); firstFailed != "" { if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
m.selectedTargetID = firstFailed m.selectedTargetID = firstFailed
m.detailsOffset = 0 m.detailsOffset = 0
@@ -260,15 +273,15 @@ func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, ac
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.operation.confirmation != nil { if m.operation.confirmation != nil {
switch key.String() { switch m.operation.confirmationDialog.update(key.String()) {
case "y": case confirmationAccepted:
confirmed, ok := m.operation.confirmedIntent() confirmed, ok := m.operation.confirmedIntent()
if !ok { if !ok {
return m, nil return m, nil
} }
m.status = "" m.status = ""
return m, m.startOperation(confirmed) return m, m.startOperation(confirmed)
case "enter", "n", "esc": case confirmationCancelled:
m.operation.cancelConfirmation() m.operation.cancelConfirmation()
} }
return m, nil return m, nil
@@ -296,10 +309,10 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() { switch key.String() {
case "p": case "p":
if m.operation.active || m.operation.quitRequested { 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"} m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Profile management disabled: wait for the current mutation to finish"}
} else { } else {
m.page = profilesPage m.page = profilesPage
m.selected = 0 m.retainProfileSelection(m.selectedProfileName, m.selected)
m.status = "" m.status = ""
if m.operation.status.Kind != app.StatusError { if m.operation.status.Kind != app.StatusError {
m.operation.status = app.OperationStatus{} m.operation.status = app.OperationStatus{}
@@ -370,23 +383,14 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.confirm { if m.profileDelete != nil {
switch key.String() { switch m.profileDelete.dialog.update(key.String()) {
case "y": case confirmationAccepted:
if selected, ok := m.selectedProfile(); ok { name := m.profileDelete.name
m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true}) m.profileDelete = nil
} m.applyProfileMutation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true}, "")
m.confirm = false case confirmationCancelled:
case "n", "esc", "q": m.profileDelete = nil
m.confirm = false
}
return m, nil
}
if m.busy() {
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
m.selected = 0
m.status = ""
} }
return m, nil return m, nil
} }
@@ -394,38 +398,82 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() { switch key.String() {
case "q", "esc": case "q", "esc":
m.page = mainPage m.page = mainPage
m.selected = 0
m.status = "" m.status = ""
case "up", "k": case "up", "k":
if m.selected > 0 { if m.selected > 0 {
m.selected-- m.selected--
m.selectedProfileName = m.snapshot.Profiles[m.selected].Name
m.profileDetailsOffset = 0
m.ensureProfileSelectionVisible()
} }
case "down", "j": case "down", "j":
if m.selected+1 < len(m.snapshot.Profiles) { if m.selected+1 < len(m.snapshot.Profiles) {
m.selected++ m.selected++
m.selectedProfileName = m.snapshot.Profiles[m.selected].Name
m.profileDetailsOffset = 0
m.ensureProfileSelectionVisible()
} }
case "pgup":
m.profileDetailsOffset = max(0, m.profileDetailsOffset-5)
case "pgdown":
m.profileDetailsOffset += 5
case "n": case "n":
if !m.profileMutationAvailable("creating a Proxy Profile") {
break
}
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}} m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
m.page = formPage m.page = formPage
m.status = "" m.status = ""
case "e": case "e", "enter":
if !m.profileMutationAvailable("editing a Proxy Profile") {
break
}
if selected, ok := m.selectedProfile(); ok { if selected, ok := m.selectedProfile(); ok {
m.form = formState{originalName: selected.Name, profile: selected} m.form = formState{originalName: selected.Name, profile: selected}
m.page = formPage m.page = formPage
m.status = "" m.status = ""
} }
case "a", "enter":
if selected, ok := m.selectedProfile(); ok {
return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
}
case "d": case "d":
if _, ok := m.selectedProfile(); ok { if !m.profileMutationAvailable("deleting a Proxy Profile") {
m.confirm = true break
}
if selected, ok := m.selectedProfile(); ok {
m.profileDelete = newProfileDeleteConfirmation(selected, len(m.snapshot.Profiles) == 1, selected.Name == m.snapshot.ActiveProfile)
} }
} }
return m, nil return m, nil
} }
func (m *Model) profileMutationAvailable(action string) bool {
if !m.busy() {
return true
}
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Cannot continue " + action + "; wait for the current mutation to finish"}
return false
}
func newProfileDeleteConfirmation(selected profile.Profile, final, active bool) *profileDeleteConfirmation {
details := []string{"The Proxy Profile will be removed permanently."}
if active {
details = append(details, "This is the Active Profile; deleting it leaves no Active Profile selected.")
}
if final {
details = append(details,
"This is the final Proxy Profile.",
"Existing managed configuration remains in place.",
"Affected Targets become Drifted.")
}
request := app.Confirmation{
Title: fmt.Sprintf("Delete Proxy Profile “%s”?", selected.Name),
Summary: "Deletion cannot be undone.",
Details: details,
ConfirmLabel: "Delete",
CancelLabel: "Cancel",
HighImpact: true,
}
return &profileDeleteConfirmation{name: selected.Name, dialog: newConfirmationDialog(request)}
}
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() { switch key.String() {
case "esc": case "esc":
@@ -439,17 +487,13 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
kind = app.UpdateProfile kind = app.UpdateProfile
} }
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile} intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
result := m.application.Dispatch(intent) result := m.applyProfileMutation(intent, intent.Profile.Name)
m.snapshot = result.Snapshot
if result.Err != nil { if result.Err != nil {
m.status = "Error: " + result.Err.Error()
m.form.probeGen++ // discard any in-flight probe result while staying on the form m.form.probeGen++ // discard any in-flight probe result while staying on the form
m.form.probing = false m.form.probing = false
return m, nil return m, nil
} }
m.status = result.Notice
m.page = profilesPage m.page = profilesPage
m.selectName(intent.Profile.Name)
m.form = formState{} m.form = formState{}
return m, nil return m, nil
case "ctrl+d": case "ctrl+d":
@@ -510,21 +554,23 @@ func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.page = mainPage m.page = mainPage
m.selected = 0
return m, m.startOperation(intent) return m, m.startOperation(intent)
} }
func (m *Model) apply(intent app.Intent) { func (m *Model) applyProfileMutation(intent app.Intent, preferredName string) app.Result {
formerName, formerIndex := m.selectedProfileName, m.selected
result := m.application.Dispatch(intent) result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot m.snapshot = result.Snapshot
if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 { if preferredName == "" {
m.selected-- preferredName = formerName
} }
m.retainProfileSelection(preferredName, formerIndex)
if result.Err != nil { if result.Err != nil {
m.status = "Error: " + result.Err.Error() m.status = "Error: " + result.Err.Error()
} else { } else {
m.status = result.Notice m.status = result.Notice
} }
return result
} }
// probeAddress extracts a bare host:port from the typed endpoint for dialing // probeAddress extracts a bare host:port from the typed endpoint for dialing
@@ -539,40 +585,82 @@ func probeAddress(raw string) (string, error) {
} }
func (m Model) selectedProfile() (profile.Profile, bool) { func (m Model) selectedProfile() (profile.Profile, bool) {
if m.selectedProfileName != "" {
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == m.selectedProfileName {
return m.snapshot.Profiles[i], true
}
}
}
return profileAt(m.snapshot, m.selected) return profileAt(m.snapshot, m.selected)
} }
func (m *Model) selectName(name string) { func (m *Model) retainProfileSelection(name string, fallbackIndex int) {
if len(m.snapshot.Profiles) == 0 {
m.selected = 0
m.selectedProfileName = ""
m.profileViewportOffset = 0
m.profileDetailsOffset = 0
return
}
for i := range m.snapshot.Profiles { for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == name { if name != "" && m.snapshot.Profiles[i].Name == name {
m.selected = i m.selected = i
m.selectedProfileName = name
m.ensureProfileSelectionVisible()
return return
} }
} }
m.selected = min(max(0, fallbackIndex), len(m.snapshot.Profiles)-1)
m.selectedProfileName = m.snapshot.Profiles[m.selected].Name
m.profileDetailsOffset = 0
m.ensureProfileSelectionVisible()
}
func (m *Model) ensureProfileSelectionVisible() {
if len(m.snapshot.Profiles) == 0 {
m.profileViewportOffset = 0
return
}
// The details pane and footer consume variable height. A conservative
// capacity keeps the selected identity visible without coupling Model state
// to Lip Gloss measurements, while retaining the viewport top across resize.
capacity := max(1, m.height/2)
if m.selected < m.profileViewportOffset {
m.profileViewportOffset = m.selected
}
if m.selected >= m.profileViewportOffset+capacity {
m.profileViewportOffset = m.selected - capacity + 1
}
maximum := max(0, len(m.snapshot.Profiles)-capacity)
m.profileViewportOffset = min(maximum, max(0, m.profileViewportOffset))
} }
func (m Model) View() string { func (m Model) View() string {
return m.presentation.Render(presentationState{ return m.presentation.Render(presentationState{
snapshot: m.snapshot, snapshot: m.snapshot,
page: m.page, page: m.page,
width: m.width, width: m.width,
height: m.height, height: m.height,
selected: m.selected, selected: m.selected,
selectedTargetID: m.selectedTargetID, profileOffset: m.profileViewportOffset,
dashboardFocus: m.dashboardFocus, profileDetails: m.profileDetailsOffset,
showUnavailable: m.showUnavailable, profileDelete: m.profileDelete,
detailsOffset: m.detailsOffset, selectedTargetID: m.selectedTargetID,
form: m.form, dashboardFocus: m.dashboardFocus,
confirm: m.confirm, showUnavailable: m.showUnavailable,
confirmation: m.operation.confirmation, detailsOffset: m.detailsOffset,
status: m.status, form: m.form,
operationStatus: m.operation.status, confirmation: m.operation.confirmation,
currentError: m.operation.currentError, confirmationDialog: &m.operation.confirmationDialog,
errorExpanded: m.operation.errorExpanded, status: m.status,
errorOffset: m.operation.errorOffset, operationStatus: m.operation.status,
exitDeferred: m.operation.quitRequested && m.operationInFlight(), currentError: m.operation.currentError,
operationActive: m.operation.active, errorExpanded: m.operation.errorExpanded,
operationProgress: m.operation.progress, errorOffset: m.operation.errorOffset,
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
operationActive: m.operation.active,
operationProgress: m.operation.progress,
}) })
} }
+3 -11
View File
@@ -51,27 +51,19 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) {
model = updateKey(model, "p") model = updateKey(model, "p")
view := model.View() view := model.View()
if !strings.Contains(view, "Proxy Profiles") || !strings.Contains(view, "alice:") { if !strings.Contains(view, "Proxy Profile management") || !strings.Contains(view, "alice:") {
t.Fatalf("Profile page did not render selected Profile:\n%s", view) t.Fatalf("Profile page did not render selected Profile:\n%s", view)
} }
if strings.Contains(view, "very-secret") { if strings.Contains(view, "very-secret") {
t.Fatalf("Profile page exposed proxy password:\n%s", view) t.Fatalf("Profile page exposed proxy password:\n%s", view)
} }
updated, command := model.Update(keyMessage("a"))
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile || module.intents[0].Name != "office" {
t.Fatalf("activate key dispatched %#v", module.intents)
}
updated, _ = model.Update(command())
model = updated.(Model)
model = updateKey(model, "p")
model = updateKey(model, "d") model = updateKey(model, "d")
if !strings.Contains(model.View(), "Delete Profile") { if !strings.Contains(model.View(), "Delete Proxy Profile") {
t.Fatalf("delete key did not request confirmation:\n%s", model.View()) t.Fatalf("delete key did not request confirmation:\n%s", model.View())
} }
model = updateKey(model, "y") model = updateKey(model, "y")
if len(module.intents) != 2 || module.intents[1].Kind != app.DeleteProfile || !module.intents[1].Confirm { if len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || !module.intents[0].Confirm {
t.Fatalf("confirmed delete dispatched %#v", module.intents) t.Fatalf("confirmed delete dispatched %#v", module.intents)
} }
} }
+4
View File
@@ -26,6 +26,7 @@ type operationCoordinator struct {
errorExpanded bool errorExpanded bool
errorOffset int errorOffset int
confirmation *app.Confirmation confirmation *app.Confirmation
confirmationDialog confirmationDialog
confirmationIntent *app.Intent confirmationIntent *app.Intent
quitRequested bool quitRequested bool
} }
@@ -102,6 +103,7 @@ func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTar
if request := update.Confirmation(); request != nil { if request := update.Confirmation(); request != nil {
intent := o.intent intent := o.intent
o.confirmation = request o.confirmation = request
o.confirmationDialog = newConfirmationDialog(*request)
o.confirmationIntent = &intent o.confirmationIntent = &intent
} }
if update.IsTerminal() { if update.IsTerminal() {
@@ -154,6 +156,7 @@ func (o *operationCoordinator) acknowledgeError() {
func (o *operationCoordinator) cancelConfirmation() { func (o *operationCoordinator) cancelConfirmation() {
o.confirmation = nil o.confirmation = nil
o.confirmationDialog = confirmationDialog{}
o.confirmationIntent = nil o.confirmationIntent = nil
o.status = app.OperationStatus{} o.status = app.OperationStatus{}
} }
@@ -165,6 +168,7 @@ func (o *operationCoordinator) confirmedIntent() (app.Intent, bool) {
intent := *o.confirmationIntent intent := *o.confirmationIntent
intent.Confirm = true intent.Confirm = true
o.confirmation = nil o.confirmation = nil
o.confirmationDialog = confirmationDialog{}
o.confirmationIntent = nil o.confirmationIntent = nil
return intent, true return intent, true
} }
+30 -23
View File
@@ -42,16 +42,12 @@ func (p terminalPresentation) mainView(model presentationState, width, height in
}, },
} }
if model.confirmation != nil { if model.confirmation != nil {
var details strings.Builder dialog := newConfirmationDialog(*model.confirmation)
details.WriteString(p.theme.section.Render(model.confirmation.Title)) if model.confirmationDialog != nil {
if model.confirmation.Summary != "" { dialog = *model.confirmationDialog
fmt.Fprintf(&details, "\n%s", model.confirmation.Summary)
}
for _, detail := range model.confirmation.Details {
fmt.Fprintf(&details, "\n• %s", detail)
} }
page.dashboard = nil page.dashboard = nil
page.primary = details.String() page.primary = dialog.view()
page.context = "Cancellation is selected by default." page.context = "Cancellation is selected by default."
} }
return page return page
@@ -108,7 +104,7 @@ func (p terminalPresentation) helpView(model presentationState) terminalPage {
func (p terminalPresentation) profilesView(model presentationState) terminalPage { func (p terminalPresentation) profilesView(model presentationState) terminalPage {
var list strings.Builder var list strings.Builder
if len(model.snapshot.Profiles) == 0 { if len(model.snapshot.Profiles) == 0 {
list.WriteString("No Proxy Profiles. Press n to create one.") list.WriteString("No Proxy Profiles saved. Press n to create one.")
} }
for i, item := range model.snapshot.Profiles { for i, item := range model.snapshot.Profiles {
cursor, active := " ", " " cursor, active := " ", " "
@@ -125,22 +121,26 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage
} }
context := selectedProfileDetails(p, model) context := selectedProfileDetails(p, model)
footer := "n: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back" footer := "n: create e: edit Enter: edit d: delete PgUp/PgDn: scroll details\n↑/k ↓/j: select q/Esc: back"
switch { if len(model.snapshot.Profiles) == 0 {
case model.confirm: footer = "n: create a Proxy Profile q/Esc: back"
footer = "y: confirm n/Esc: cancel" }
if len(model.snapshot.Profiles) == 1 { if model.profileDelete != nil {
context = "Delete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted." return terminalPage{
} else { header: p.theme.title.Render("Proxy Profile management"),
context = "Delete Profile?" primary: model.profileDelete.dialog.view(),
footer: "←/→/Tab: choose Enter: choose y: delete n/q/Esc: cancel",
focusLine: -1,
} }
} }
return terminalPage{ return terminalPage{
header: p.theme.title.Render("Proxy Profiles"), header: p.theme.title.Render("Proxy Profile management"),
primary: list.String(), primary: list.String(),
context: context, context: context,
footer: footer, footer: footer,
focusLine: model.selected, focusLine: model.selected,
primaryOffset: model.profileOffset,
contextOffset: model.profileDetails,
} }
} }
@@ -158,7 +158,14 @@ func selectedProfileDetails(p terminalPresentation, model presentationState) str
fmt.Fprintf(&details, "\nHTTP: %s\nHTTPS: %s\nALL/SOCKS: %s", fmt.Fprintf(&details, "\nHTTP: %s\nHTTPS: %s\nALL/SOCKS: %s",
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL)) profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
} }
fmt.Fprintf(&details, "\nBypass: %s", strings.Join(selected.Bypass, ", ")) details.WriteString("\nBypass:")
if len(selected.Bypass) == 0 {
details.WriteString(" (none)")
} else {
for _, item := range selected.Bypass {
fmt.Fprintf(&details, "\n • %s", item)
}
}
return details.String() return details.String()
} }
+40 -25
View File
@@ -53,26 +53,29 @@ func defaultModelOptions() modelOptions {
} }
type presentationState struct { type presentationState struct {
snapshot app.Snapshot snapshot app.Snapshot
page page page page
width int width int
height int height int
selected int selected int
selectedTargetID string profileOffset int
dashboardFocus dashboardPane profileDetails int
showUnavailable bool profileDelete *profileDeleteConfirmation
detailsOffset int selectedTargetID string
form formState dashboardFocus dashboardPane
confirm bool showUnavailable bool
confirmation *app.Confirmation detailsOffset int
status string form formState
operationStatus app.OperationStatus confirmation *app.Confirmation
currentError *operationError confirmationDialog *confirmationDialog
errorExpanded bool status string
errorOffset int operationStatus app.OperationStatus
exitDeferred bool currentError *operationError
operationActive bool errorExpanded bool
operationProgress map[string]app.TargetProgressPhase errorOffset int
exitDeferred bool
operationActive bool
operationProgress map[string]app.TargetProgressPhase
} }
// terminalPresentation is the terminal-presentation Module. Application // terminalPresentation is the terminal-presentation Module. Application
@@ -92,6 +95,8 @@ type terminalPage struct {
statusExpanded bool statusExpanded bool
statusOffset int statusOffset int
focusLine int focusLine int
primaryOffset int
contextOffset int
dashboard *dashboardRenderState dashboard *dashboardRenderState
} }
@@ -208,7 +213,7 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int)
func renderPageBody(page terminalPage, width, height int) string { func renderPageBody(page terminalPage, width, height int) string {
if page.context == "" || height < 5 { if page.context == "" || height < 5 {
return viewportContent(page.primary, width, height, page.focusLine) return viewportContentAt(page.primary, width, height, page.primaryOffset, page.focusLine)
} }
contextHeight := min(lineCount(page.context), max(3, height/2)) contextHeight := min(lineCount(page.context), max(3, height/2))
@@ -217,20 +222,30 @@ func renderPageBody(page terminalPage, width, height int) string {
primaryHeight = 1 primaryHeight = 1
contextHeight = max(1, height-primaryHeight-1) contextHeight = max(1, height-primaryHeight-1)
} }
primary := viewportContent(page.primary, width, primaryHeight, page.focusLine) primary := viewportContentAt(page.primary, width, primaryHeight, page.primaryOffset, page.focusLine)
context := viewportContent(page.context, width, contextHeight, 0) context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, -1)
return primary + "\n\n" + context return primary + "\n\n" + context
} }
func viewportContent(content string, width, height, focusLine int) string { func viewportContent(content string, width, height, focusLine int) string {
return viewportContentAt(content, width, height, 0, focusLine)
}
func viewportContentAt(content string, width, height, offset, focusLine int) string {
if content == "" || width <= 0 || height <= 0 { if content == "" || width <= 0 || height <= 0 {
return "" return ""
} }
view := viewport.New(width, height) view := viewport.New(width, height)
view.SetContent(strings.TrimRight(content, "\n")) view.SetContent(strings.TrimRight(content, "\n"))
if focusLine >= height { top := max(0, offset)
view.SetYOffset(focusLine - height + 1) if focusLine >= 0 {
if focusLine < top {
top = focusLine
} else if focusLine >= top+height {
top = focusLine - height + 1
}
} }
view.SetYOffset(top)
return view.View() return view.View()
} }
@@ -0,0 +1,247 @@
package tui
import (
"fmt"
"slices"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
// profileManagementModule is an in-memory Application seam used to drive the
// same complete Model as production while making CRUD results observable.
type profileManagementModule struct {
snapshot app.Snapshot
intents []app.Intent
reverseUpdate bool
}
func (m *profileManagementModule) Snapshot() app.Snapshot { return m.snapshot }
func (m *profileManagementModule) Dispatch(intent app.Intent) app.Result {
m.intents = append(m.intents, intent)
switch intent.Kind {
case app.CreateProfile:
m.snapshot.Profiles = append(m.snapshot.Profiles, intent.Profile)
case app.UpdateProfile:
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == intent.Name {
m.snapshot.Profiles[i] = intent.Profile
break
}
}
if m.snapshot.ActiveProfile == intent.Name {
m.snapshot.ActiveProfile = intent.Profile.Name
}
if m.reverseUpdate {
slices.Reverse(m.snapshot.Profiles)
}
case app.DeleteProfile:
for i := range m.snapshot.Profiles {
if m.snapshot.Profiles[i].Name == intent.Name {
m.snapshot.Profiles = append(m.snapshot.Profiles[:i], m.snapshot.Profiles[i+1:]...)
break
}
}
if m.snapshot.ActiveProfile == intent.Name {
m.snapshot.ActiveProfile = ""
}
}
return app.Result{Snapshot: m.snapshot, Notice: "done"}
}
func (m *profileManagementModule) StartOperation(intent app.Intent) app.OperationStream {
result := m.Dispatch(intent)
updates := make(chan app.OperationUpdate, 1)
updates <- app.SucceededOperationUpdate(result.Snapshot, app.OperationStatus{Summary: result.Notice}, nil)
close(updates)
return app.OperationStream{Snapshot: result.Snapshot, Updates: updates}
}
func TestCompleteModelProfileManagementScrollsAndReviewsObscuredDetailsAcrossResize(t *testing.T) {
profiles := make([]profile.Profile, 30)
for i := range profiles {
profiles[i] = profile.Profile{
Name: fmt.Sprintf("site-%02d", i),
Mode: profile.SingleMode,
SingleURL: fmt.Sprintf("https://operator:secret-%02d@proxy-%02d.example:8443", i, i),
Bypass: []string{fmt.Sprintf("internal-%02d.example", i), "localhost"},
}
}
module := &profileManagementModule{snapshot: app.Snapshot{ActiveProfile: "site-00", Profiles: profiles}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
for range 24 {
model = updateKey(model, "down")
}
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {60, 16}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height,
"> site-24", "Mode: single", "https://operator:REDACTED@proxy-24.example:8443", "internal-24.example")
if strings.Contains(view, "secret-24") {
t.Fatalf("Profile details exposed credentials after resize to %dx%d:\n%s", size.width, size.height, view)
}
if size.width == 60 && size.height == 16 {
model = updateKey(model, "pgdown")
assertUsableFrame(t, model.View(), 60, 16, "localhost")
model = updateKey(model, "pgup")
}
}
}
func TestCompleteModelProfileManagementHasCreateAndEditEntryPointsButNoActivationAction(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
}}}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
view := model.View()
assertUsableFrame(t, view, 80, 24, "n: create", "e: edit", "d: delete", "q/Esc: back")
if strings.Contains(view, "activate") {
t.Fatalf("management page mixed in Active Profile switching:\n%s", view)
}
model = updateKey(model, "n")
if view := model.View(); !strings.Contains(view, "Create Proxy Profile") {
t.Fatalf("create entry point did not open the existing editor:\n%s", view)
}
model = updateKey(model, "esc")
model = updateKey(model, "e")
if view := model.View(); !strings.Contains(view, "Edit Proxy Profile") || !strings.Contains(view, "office") {
t.Fatalf("edit entry point did not open the existing editor:\n%s", view)
}
}
func TestCompleteModelProfileDeletionDialogIsSafeByDefaultAndSupportsExplicitSelection(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
}, {
Name: "remote", Mode: profile.SingleMode, SingleURL: "http://remote.example:8080",
}}}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
model = updateKey(model, "d")
view := model.View()
assertUsableFrame(t, view, 80, 24, "Delete Proxy Profile “office”?", "> Cancel", "Delete", "Enter: choose")
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(module.intents) != 0 || strings.Contains(model.View(), "Delete Proxy Profile “office”?") {
t.Fatalf("safe-default Enter did not cancel deletion: intents=%#v\n%s", module.intents, model.View())
}
model = updateKey(model, "d")
model = updateKey(model, "q")
if len(module.intents) != 0 || strings.Contains(model.View(), "Delete Proxy Profile “office”?") {
t.Fatalf("q did not cancel deletion without mutation: intents=%#v\n%s", module.intents, model.View())
}
model = updateKey(model, "d")
model = updateKey(model, "right")
updated, command = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(Model)
if command != nil || len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
t.Fatalf("focused Delete did not dispatch the confirmed mutation: intents=%#v", module.intents)
}
assertUsableFrame(t, model.View(), 80, 24, "> remote", "Proxy: http://remote.example:8080")
}
func TestCompleteModelFinalProfileDeletionExplainsDriftAndRequiresExplicitConfirmation(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{ActiveProfile: "office", Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
}}}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
model = updateKey(model, "d")
model = resize(model, 60, 16)
view := model.View()
assertUsableFrame(t, view, 60, 16,
"final Proxy Profile", "managed configuration remains", "become Drifted", "> Cancel")
model = updateKey(model, "y")
if len(module.intents) != 1 || module.intents[0].Kind != app.DeleteProfile || module.intents[0].Name != "office" || !module.intents[0].Confirm {
t.Fatalf("explicit confirmation dispatched %#v", module.intents)
}
model = resize(model, 80, 24)
view = model.View()
assertUsableFrame(t, view, 80, 24, "No Proxy Profiles saved.", "Press n to create one.")
if strings.Contains(view, "No Active Profile") {
t.Fatalf("management empty state conflated saved Profiles with Active Profile state:\n%s", view)
}
}
func TestCompleteModelProfileSelectionUsesIdentityAcrossResizeAndReorderedCRUDResult(t *testing.T) {
profiles := make([]profile.Profile, 22)
for i := range profiles {
profiles[i] = profile.Profile{Name: fmt.Sprintf("profile-%02d", i), Mode: profile.SingleMode, SingleURL: fmt.Sprintf("http://proxy-%02d.example:8080", i)}
}
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: profiles}, reverseUpdate: true}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
for range 17 {
model = updateKey(model, "down")
}
model = resize(model, 120, 36)
model = resize(model, 60, 16)
model = updateKey(model, "e")
model = updateKey(model, "ctrl+s")
view := model.View()
assertUsableFrame(t, view, 60, 16, "> profile-17", "Proxy: http://proxy-17.example:8080")
if len(module.intents) != 1 || module.intents[0].Kind != app.UpdateProfile || module.intents[0].Name != "profile-17" {
t.Fatalf("edit dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileManagementDoesNotConflateSavedAndActiveProfileState(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "available", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
}}}}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
view := model.View()
assertUsableFrame(t, view, 80, 24, "> available", "Selected Profile")
if strings.Contains(view, "No Active Profile") || strings.Contains(view, "No Proxy Profiles") {
t.Fatalf("management page misreported a non-active saved Profile as empty:\n%s", view)
}
}
func TestCompleteModelProfileManagementNavigationAndBusyReasonsAreConsistent(t *testing.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")
updated, command := model.Update(back)
model = updated.(Model)
if command != nil || !strings.Contains(model.View(), "Operations Console") {
t.Fatalf("%q did not return to the dashboard:\n%s", back.String(), model.View())
}
}
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "p")
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")
}
if _, ok := command().(tea.QuitMsg); !ok {
t.Fatal("Ctrl+C on Profile management did not emit tea.Quit")
}
_ = updated
checking := &profileManagementModule{snapshot: app.Snapshot{
Profiles: []profile.Profile{{Name: "office"}},
Targets: []app.TargetSnapshot{{ID: "shell", DetectionPhase: app.TargetChecking}},
}}
busy := resizedModel(t, checking, themeModeNoColor, 80, 24)
busy = updateKey(busy, "p")
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)
}
}
+8 -10
View File
@@ -142,18 +142,17 @@ func TestRefreshKeyRunsAsynchronouslyAndRendersRefreshingThenCompleted(t *testin
} }
} }
func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) { func TestBusyProfileTransitionBlocksPageEntryAndEveryNewOperation(t *testing.T) {
initial := app.Snapshot{ initial := app.Snapshot{
ActiveProfile: "old", ActiveProfile: "old",
Profiles: []profile.Profile{{Name: "new"}}, Profiles: []profile.Profile{{Name: "new"}},
Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}}, Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Enabled}},
} }
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.OperationUpdate)} module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: make(chan app.OperationUpdate)}
model := updateKey(NewModel(module), "p") model := NewModel(module)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) command := model.startOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"})
model = updated.(Model)
if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile { if command == nil || len(module.intents) != 1 || module.intents[0].Kind != app.ActivateProfile {
t.Fatalf("activation did not start exactly once: command=%v intents=%#v", command != nil, module.intents) 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", "r", " "} {
@@ -309,13 +308,12 @@ func TestActiveProfileTransitionRendersPerTargetProgress(t *testing.T) {
updates <- app.SucceededOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Activated Profile new"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}}) updates <- app.SucceededOperationUpdate(completedSnapshot, app.OperationStatus{Summary: "Activated Profile new"}, []app.TargetProgress{{TargetID: "shell", Phase: app.TargetProgressSucceeded, State: target.Enabled}})
close(updates) close(updates)
module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates} module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates}
model := updateKey(NewModel(module), "p") model := NewModel(module)
updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) command := model.startOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"})
model = updated.(Model)
if command == nil { if command == nil {
t.Fatal("activation did not start progressive operation") t.Fatal("Profile transition did not start progressive operation")
} }
updated, command = model.Update(command()) updated, command := model.Update(command())
model = updated.(Model) model = updated.(Model)
if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") { if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") {
t.Fatalf("applying progress:\n%s", model.View()) t.Fatalf("applying progress:\n%s", model.View())