From d34ce0cd6b5424d34e488218ed2988cdc868f2ef Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Fri, 17 Jul 2026 22:37:25 +0800 Subject: [PATCH] feat: add dedicated Profile management page --- .../issues/07-profile-management-page.md | 24 +- README.md | 2 +- internal/tui/confirmation_dialog.go | 71 +++++ internal/tui/foundation_test.go | 3 +- internal/tui/model.go | 242 +++++++++++------ internal/tui/model_test.go | 14 +- internal/tui/operation_coordinator.go | 4 + internal/tui/pages.go | 53 ++-- internal/tui/presentation.go | 65 +++-- internal/tui/profile_management_page_test.go | 247 ++++++++++++++++++ internal/tui/target_test.go | 18 +- 11 files changed, 583 insertions(+), 160 deletions(-) create mode 100644 internal/tui/confirmation_dialog.go create mode 100644 internal/tui/profile_management_page_test.go diff --git a/.scratch/tui-redesign/issues/07-profile-management-page.md b/.scratch/tui-redesign/issues/07-profile-management-page.md index f19fb44..5e90f43 100644 --- a/.scratch/tui-redesign/issues/07-profile-management-page.md +++ b/.scratch/tui-redesign/issues/07-profile-management-page.md @@ -4,16 +4,16 @@ **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. -- [ ] 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. -- [ ] Create, edit, and delete entry points are available through contextual keyboard actions. -- [ ] 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. -- [ ] 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. -- [ ] 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. -- [ ] Complete-Model tests cover long lists, details, create/edit entry, deletion confirmation, final deletion, cancellation, resize, and navigation consistency. +- [x] Profile management is a distinct page from the dashboard activation picker. +- [x] The Profile list scrolls and keeps selection visible with more entries than fit on screen. +- [x] The selected Profile's mode, obscured URLs, and Bypass values are reviewable without exposing credentials. +- [x] Create, edit, and delete entry points are available through contextual keyboard actions. +- [x] Deletion uses a reusable confirmation dialog with cancellation focused by default. +- [x] Deleting the final Proxy Profile clearly explains that existing managed configuration remains and becomes Drifted. +- [x] Escape and `q` return to the previous page; Ctrl+C follows the global exit contract. +- [x] Operations disabled by in-flight work expose a reason rather than silently ignoring input. +- [x] Selection and viewport survive resize and CRUD results whenever the referenced Profile still exists. +- [x] Empty-state copy provides a direct creation path and does not incorrectly claim no Active Profile when non-active Profiles exist. +- [x] Complete-Model tests cover long lists, details, create/edit entry, deletion confirmation, final deletion, cancellation, resize, and navigation consistency. diff --git a/README.md b/README.md index a3096e5..6c3e0b0 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 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, 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. diff --git a/internal/tui/confirmation_dialog.go b/internal/tui/confirmation_dialog.go new file mode 100644 index 0000000..fa86e53 --- /dev/null +++ b/internal/tui/confirmation_dialog.go @@ -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() +} diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index f0537e0..fe9f112 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -234,7 +234,8 @@ func TestCompleteModelLongProfileListKeepsLastSelectionDetailsAndActionsVisibleA assertUsableFrame(t, model.View(), size.width, size.height, "> regional-office-31", "Proxy: http://proxy-31.example:8080", - "a/Enter: activate", + "n: create", + "d: delete", "q/Esc: back", ) } diff --git a/internal/tui/model.go b/internal/tui/model.go index d3bab47..8dfea0b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "context" "errors" + "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -31,22 +32,25 @@ type formState struct { } type Model struct { - application app.InteractiveModule - presentation terminalPresentation - snapshot app.Snapshot - page page - width int - height int - selected int - selectedTargetID string - dashboardFocus dashboardPane - showUnavailable bool - detailsOffset int - form formState - confirm bool - status string - operation operationCoordinator - detect func(context.Context, string) (probe.Capabilities, error) + application app.InteractiveModule + presentation terminalPresentation + snapshot app.Snapshot + page page + width int + height int + selected int + selectedProfileName string + profileViewportOffset int + profileDetailsOffset int + profileDelete *profileDeleteConfirmation + selectedTargetID string + dashboardFocus dashboardPane + showUnavailable bool + detailsOffset int + 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 @@ -60,6 +64,11 @@ type probeDone struct { gen uint64 } +type profileDeleteConfirmation struct { + name string + dialog confirmationDialog +} + func NewModel(application app.InteractiveModule, configure ...ModelOption) Model { options := defaultModelOptions() for _, option := range configure { @@ -76,6 +85,7 @@ func NewModel(application app.InteractiveModule, configure ...ModelOption) Model operation: newOperationCoordinator(), detect: probe.Detect, } + model.retainProfileSelection("", 0) if targetsChecking(snapshot) { 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 { m.width = size.Width m.height = size.Height + m.ensureProfileSelectionVisible() return m, nil } @@ -140,11 +151,13 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if progress.closed { m.snapshot = m.application.Snapshot() m.retainTargetSelection() + m.retainProfileSelection(m.selectedProfileName, m.selected) m.operation.unexpectedClose() return m, m.operation.deferredQuit() } m.snapshot = progress.update.Snapshot m.retainTargetSelection() + m.retainProfileSelection(m.selectedProfileName, m.selected) if firstFailed := m.operation.apply(progress.update); firstFailed != "" { m.selectedTargetID = firstFailed 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) { if m.operation.confirmation != nil { - switch key.String() { - case "y": + switch m.operation.confirmationDialog.update(key.String()) { + case confirmationAccepted: confirmed, ok := m.operation.confirmedIntent() if !ok { return m, nil } m.status = "" return m, m.startOperation(confirmed) - case "enter", "n", "esc": + case confirmationCancelled: m.operation.cancelConfirmation() } return m, nil @@ -296,10 +309,10 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { case "p": if m.operation.active || m.operation.quitRequested { - m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"} + m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Profile management disabled: wait for the current mutation to finish"} } else { m.page = profilesPage - m.selected = 0 + m.retainProfileSelection(m.selectedProfileName, m.selected) m.status = "" if m.operation.status.Kind != app.StatusError { 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) { - if m.confirm { - switch key.String() { - case "y": - if selected, ok := m.selectedProfile(); ok { - m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true}) - } - m.confirm = false - case "n", "esc", "q": - m.confirm = false - } - return m, nil - } - if m.busy() { - if key.String() == "q" || key.String() == "esc" { - m.page = mainPage - m.selected = 0 - m.status = "" + if m.profileDelete != nil { + switch m.profileDelete.dialog.update(key.String()) { + case confirmationAccepted: + name := m.profileDelete.name + m.profileDelete = nil + m.applyProfileMutation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true}, "") + case confirmationCancelled: + m.profileDelete = nil } return m, nil } @@ -394,38 +398,82 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { case "q", "esc": m.page = mainPage - m.selected = 0 m.status = "" case "up", "k": if m.selected > 0 { m.selected-- + m.selectedProfileName = m.snapshot.Profiles[m.selected].Name + m.profileDetailsOffset = 0 + m.ensureProfileSelectionVisible() } case "down", "j": if m.selected+1 < len(m.snapshot.Profiles) { 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": + if !m.profileMutationAvailable("creating a Proxy Profile") { + break + } m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}} m.page = formPage m.status = "" - case "e": + case "e", "enter": + if !m.profileMutationAvailable("editing a Proxy Profile") { + break + } if selected, ok := m.selectedProfile(); ok { m.form = formState{originalName: selected.Name, profile: selected} m.page = formPage m.status = "" } - case "a", "enter": - if selected, ok := m.selectedProfile(); ok { - return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name}) - } case "d": - if _, ok := m.selectedProfile(); ok { - m.confirm = true + if !m.profileMutationAvailable("deleting a Proxy Profile") { + break + } + if selected, ok := m.selectedProfile(); ok { + m.profileDelete = newProfileDeleteConfirmation(selected, len(m.snapshot.Profiles) == 1, selected.Name == m.snapshot.ActiveProfile) } } 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) { switch key.String() { case "esc": @@ -439,17 +487,13 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { kind = app.UpdateProfile } intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile} - result := m.application.Dispatch(intent) - m.snapshot = result.Snapshot + result := m.applyProfileMutation(intent, intent.Profile.Name) 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.probing = false return m, nil } - m.status = result.Notice m.page = profilesPage - m.selectName(intent.Profile.Name) m.form = formState{} return m, nil case "ctrl+d": @@ -510,21 +554,23 @@ func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) { return m, nil } m.page = mainPage - m.selected = 0 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) m.snapshot = result.Snapshot - if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 { - m.selected-- + if preferredName == "" { + preferredName = formerName } + m.retainProfileSelection(preferredName, formerIndex) if result.Err != nil { m.status = "Error: " + result.Err.Error() } else { m.status = result.Notice } + return result } // 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) { + 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) } -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 { - if m.snapshot.Profiles[i].Name == name { + if name != "" && m.snapshot.Profiles[i].Name == name { m.selected = i + m.selectedProfileName = name + m.ensureProfileSelectionVisible() 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 { return m.presentation.Render(presentationState{ - snapshot: m.snapshot, - page: m.page, - width: m.width, - height: m.height, - selected: m.selected, - selectedTargetID: m.selectedTargetID, - dashboardFocus: m.dashboardFocus, - showUnavailable: m.showUnavailable, - detailsOffset: m.detailsOffset, - form: m.form, - confirm: m.confirm, - confirmation: m.operation.confirmation, - status: m.status, - operationStatus: m.operation.status, - currentError: m.operation.currentError, - errorExpanded: m.operation.errorExpanded, - errorOffset: m.operation.errorOffset, - exitDeferred: m.operation.quitRequested && m.operationInFlight(), - operationActive: m.operation.active, - operationProgress: m.operation.progress, + snapshot: m.snapshot, + page: m.page, + width: m.width, + height: m.height, + selected: m.selected, + profileOffset: m.profileViewportOffset, + profileDetails: m.profileDetailsOffset, + profileDelete: m.profileDelete, + selectedTargetID: m.selectedTargetID, + dashboardFocus: m.dashboardFocus, + showUnavailable: m.showUnavailable, + detailsOffset: m.detailsOffset, + form: m.form, + confirmation: m.operation.confirmation, + confirmationDialog: &m.operation.confirmationDialog, + status: m.status, + operationStatus: m.operation.status, + currentError: m.operation.currentError, + errorExpanded: m.operation.errorExpanded, + errorOffset: m.operation.errorOffset, + exitDeferred: m.operation.quitRequested && m.operationInFlight(), + operationActive: m.operation.active, + operationProgress: m.operation.progress, }) } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 731bcf1..1f5184a 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -51,27 +51,19 @@ func TestMainAndProfilePagesMapKeysAndObscurePasswords(t *testing.T) { model = updateKey(model, "p") 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) } if strings.Contains(view, "very-secret") { 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") - 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()) } 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) } } diff --git a/internal/tui/operation_coordinator.go b/internal/tui/operation_coordinator.go index 6b3b2b5..3ca893d 100644 --- a/internal/tui/operation_coordinator.go +++ b/internal/tui/operation_coordinator.go @@ -26,6 +26,7 @@ type operationCoordinator struct { errorExpanded bool errorOffset int confirmation *app.Confirmation + confirmationDialog confirmationDialog confirmationIntent *app.Intent quitRequested bool } @@ -102,6 +103,7 @@ func (o *operationCoordinator) apply(update app.OperationUpdate) (firstFailedTar if request := update.Confirmation(); request != nil { intent := o.intent o.confirmation = request + o.confirmationDialog = newConfirmationDialog(*request) o.confirmationIntent = &intent } if update.IsTerminal() { @@ -154,6 +156,7 @@ func (o *operationCoordinator) acknowledgeError() { func (o *operationCoordinator) cancelConfirmation() { o.confirmation = nil + o.confirmationDialog = confirmationDialog{} o.confirmationIntent = nil o.status = app.OperationStatus{} } @@ -165,6 +168,7 @@ func (o *operationCoordinator) confirmedIntent() (app.Intent, bool) { intent := *o.confirmationIntent intent.Confirm = true o.confirmation = nil + o.confirmationDialog = confirmationDialog{} o.confirmationIntent = nil return intent, true } diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 97f6910..adadead 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -42,16 +42,12 @@ func (p terminalPresentation) mainView(model presentationState, width, height in }, } if model.confirmation != nil { - var details strings.Builder - details.WriteString(p.theme.section.Render(model.confirmation.Title)) - if model.confirmation.Summary != "" { - fmt.Fprintf(&details, "\n%s", model.confirmation.Summary) - } - for _, detail := range model.confirmation.Details { - fmt.Fprintf(&details, "\n• %s", detail) + dialog := newConfirmationDialog(*model.confirmation) + if model.confirmationDialog != nil { + dialog = *model.confirmationDialog } page.dashboard = nil - page.primary = details.String() + page.primary = dialog.view() page.context = "Cancellation is selected by default." } return page @@ -108,7 +104,7 @@ func (p terminalPresentation) helpView(model presentationState) terminalPage { func (p terminalPresentation) profilesView(model presentationState) terminalPage { var list strings.Builder 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 { cursor, active := " ", " " @@ -125,22 +121,26 @@ func (p terminalPresentation) profilesView(model presentationState) terminalPage } context := selectedProfileDetails(p, model) - footer := "n: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back" - switch { - case model.confirm: - footer = "y: confirm n/Esc: cancel" - if len(model.snapshot.Profiles) == 1 { - context = "Delete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted." - } else { - context = "Delete Profile?" + footer := "n: create e: edit Enter: edit d: delete PgUp/PgDn: scroll details\n↑/k ↓/j: select q/Esc: back" + if len(model.snapshot.Profiles) == 0 { + footer = "n: create a Proxy Profile q/Esc: back" + } + if model.profileDelete != nil { + return terminalPage{ + header: p.theme.title.Render("Proxy Profile management"), + primary: model.profileDelete.dialog.view(), + footer: "←/→/Tab: choose Enter: choose y: delete n/q/Esc: cancel", + focusLine: -1, } } return terminalPage{ - header: p.theme.title.Render("Proxy Profiles"), - primary: list.String(), - context: context, - footer: footer, - focusLine: model.selected, + header: p.theme.title.Render("Proxy Profile management"), + primary: list.String(), + context: context, + footer: footer, + 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", 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() } diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index 228f8cb..0d0937e 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -53,26 +53,29 @@ func defaultModelOptions() modelOptions { } type presentationState struct { - snapshot app.Snapshot - page page - width int - height int - selected int - selectedTargetID string - dashboardFocus dashboardPane - showUnavailable bool - detailsOffset int - form formState - confirm bool - confirmation *app.Confirmation - status string - operationStatus app.OperationStatus - currentError *operationError - errorExpanded bool - errorOffset int - exitDeferred bool - operationActive bool - operationProgress map[string]app.TargetProgressPhase + snapshot app.Snapshot + page page + width int + height int + selected int + profileOffset int + profileDetails int + profileDelete *profileDeleteConfirmation + selectedTargetID string + dashboardFocus dashboardPane + showUnavailable bool + detailsOffset int + form formState + confirmation *app.Confirmation + confirmationDialog *confirmationDialog + status string + operationStatus app.OperationStatus + currentError *operationError + errorExpanded bool + errorOffset int + exitDeferred bool + operationActive bool + operationProgress map[string]app.TargetProgressPhase } // terminalPresentation is the terminal-presentation Module. Application @@ -92,6 +95,8 @@ type terminalPage struct { statusExpanded bool statusOffset int focusLine int + primaryOffset int + contextOffset int dashboard *dashboardRenderState } @@ -208,7 +213,7 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int) func renderPageBody(page terminalPage, width, height int) string { 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)) @@ -217,20 +222,30 @@ func renderPageBody(page terminalPage, width, height int) string { primaryHeight = 1 contextHeight = max(1, height-primaryHeight-1) } - primary := viewportContent(page.primary, width, primaryHeight, page.focusLine) - context := viewportContent(page.context, width, contextHeight, 0) + primary := viewportContentAt(page.primary, width, primaryHeight, page.primaryOffset, page.focusLine) + context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, -1) return primary + "\n\n" + context } 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 { return "" } view := viewport.New(width, height) view.SetContent(strings.TrimRight(content, "\n")) - if focusLine >= height { - view.SetYOffset(focusLine - height + 1) + top := max(0, offset) + if focusLine >= 0 { + if focusLine < top { + top = focusLine + } else if focusLine >= top+height { + top = focusLine - height + 1 + } } + view.SetYOffset(top) return view.View() } diff --git a/internal/tui/profile_management_page_test.go b/internal/tui/profile_management_page_test.go new file mode 100644 index 0000000..497fb44 --- /dev/null +++ b/internal/tui/profile_management_page_test.go @@ -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) + } +} diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index 1dc8b4c..669f596 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -142,18 +142,17 @@ func TestRefreshKeyRunsAsynchronouslyAndRendersRefreshingThenCompleted(t *testin } } -func TestBusyActivationBlocksPageEntryAndEveryNewOperation(t *testing.T) { +func TestBusyProfileTransitionBlocksPageEntryAndEveryNewOperation(t *testing.T) { initial := app.Snapshot{ ActiveProfile: "old", Profiles: []profile.Profile{{Name: "new"}}, 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)} - model := updateKey(NewModel(module), "p") - updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) - model = updated.(Model) + model := NewModel(module) + command := model.startOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"}) 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", " "} { @@ -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}}) close(updates) module := &progressiveTargetModule{targetModule: targetModule{snapshot: initial}, updates: updates} - model := updateKey(NewModel(module), "p") - updated, command := model.Update(tea.KeyMsg{Type: tea.KeyEnter}) - model = updated.(Model) + model := NewModel(module) + command := model.startOperation(app.Intent{Kind: app.ActivateProfile, Name: "new"}) 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) if command == nil || !strings.Contains(model.View(), "Applying") || !strings.Contains(model.View(), "Active Profile: new") { t.Fatalf("applying progress:\n%s", model.View())