feat: add dedicated Profile management page
This commit is contained in:
+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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user