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
+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,
"> regional-office-31",
"Proxy: http://proxy-31.example:8080",
"a/Enter: activate",
"n: create",
"d: delete",
"q/Esc: back",
)
}
+165 -77
View File
@@ -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,
})
}
+3 -11
View File
@@ -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)
}
}
+4
View File
@@ -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
View File
@@ -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()
}
+40 -25
View File
@@ -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)
}
}
+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{
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())