feat: add dedicated Profile management page
This commit is contained in:
@@ -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