feat: add dedicated Profile management page
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
+165
-77
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+30
-23
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user