667 lines
20 KiB
Go
667 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/probe"
|
|
"proxyctl/internal/profile"
|
|
)
|
|
|
|
type page int
|
|
|
|
const (
|
|
mainPage page = iota
|
|
profilesPage
|
|
formPage
|
|
)
|
|
|
|
type Model struct {
|
|
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
|
|
driftResolutionTargetID string
|
|
picker profilePickerState
|
|
form formState
|
|
formSequence uint64
|
|
help helpDialogState
|
|
status string
|
|
operation operationCoordinator
|
|
detect func(context.Context, string) (probe.Capabilities, error)
|
|
}
|
|
|
|
// probeDone carries only detector output and formState's opaque typed token.
|
|
// Source data and all validation/application rules remain owned by formState.
|
|
type probeDone struct {
|
|
caps probe.Capabilities
|
|
err error
|
|
token probeToken
|
|
}
|
|
|
|
type profileDeleteConfirmation struct {
|
|
name string
|
|
dialog confirmationDialog
|
|
}
|
|
|
|
func NewModel(application app.InteractiveModule, configure ...ModelOption) Model {
|
|
options := defaultModelOptions()
|
|
for _, option := range configure {
|
|
option(&options)
|
|
}
|
|
snapshot := application.Snapshot()
|
|
model := Model{
|
|
application: application,
|
|
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
|
snapshot: snapshot,
|
|
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
|
|
width: 80,
|
|
height: 24,
|
|
operation: newOperationCoordinator(),
|
|
picker: newProfilePickerState(),
|
|
detect: probe.Detect,
|
|
}
|
|
model.retainProfileSelection("", 0)
|
|
if targetsChecking(snapshot) {
|
|
model.startOperation(app.Intent{Kind: app.RefreshTargets})
|
|
}
|
|
return model
|
|
}
|
|
|
|
func targetsChecking(snapshot app.Snapshot) bool {
|
|
for _, item := range snapshot.Targets {
|
|
if item.DetectionPhase == app.TargetChecking {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd { return m.operation.commands() }
|
|
|
|
// busy is the single-operation guard for asynchronous Application work. Every
|
|
// operation start checks it, so a completion can only belong to the one current
|
|
// operation rather than racing another operation for shared model state.
|
|
func (m Model) busy() bool { return m.operation.busy() }
|
|
|
|
func (m Model) operationInFlight() bool { return m.operation.active }
|
|
|
|
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
|
|
}
|
|
|
|
// A deferred tea.Quit is queued only after the in-flight completion has
|
|
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
|
|
// terminal so intervening input cannot cancel a confirmation or start a
|
|
// second mutation.
|
|
if m.operation.quitRequested && !m.operationInFlight() {
|
|
return m, nil
|
|
}
|
|
if key, ok := message.(tea.KeyMsg); ok {
|
|
// Ctrl+C is global. q exits only the dashboard when no dialog obscures it;
|
|
// otherwise the top dialog or child page owns q.
|
|
if key.String() == "ctrl+c" || (key.String() == "q" && m.dashboardQuitAvailable()) {
|
|
return m, m.operation.requestQuit()
|
|
}
|
|
// Help is available from every page and dialog. Opening it explicitly
|
|
// places it above the current dialog without changing state underneath.
|
|
if key.String() == "?" {
|
|
if !m.help.top(m.operation.confirmation) {
|
|
m.help.show()
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
if tick, ok := message.(spinner.TickMsg); ok {
|
|
return m, m.operation.tick(tick)
|
|
}
|
|
if progress, ok := message.(operationProgress); ok {
|
|
if !m.operation.active || progress.updates != m.operation.updates {
|
|
return m, nil
|
|
}
|
|
if progress.closed {
|
|
m.snapshot = m.application.Snapshot()
|
|
m.retainTargetSelection()
|
|
m.retainProfileSelection(m.selectedProfileName, m.selected)
|
|
m.operation.unexpectedClose()
|
|
return m, m.operation.deferredQuit()
|
|
}
|
|
completedIntent := m.operation.intent
|
|
m.snapshot = progress.update.Snapshot
|
|
m.retainTargetSelection()
|
|
m.retainProfileSelection(m.selectedProfileName, m.selected)
|
|
decoded := progress.update.Decode()
|
|
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
|
|
m.selectedTargetID = firstFailed
|
|
m.detailsOffset = 0
|
|
}
|
|
if decoded.IsTerminal() && isProfileMutation(completedIntent.Kind) {
|
|
if decoded.Kind == app.OperationSucceeded {
|
|
m.page = profilesPage
|
|
m.form = formState{}
|
|
m.status = decoded.Status.Summary
|
|
} else if decoded.Err != nil {
|
|
m.status = "Error: " + decoded.Err.Error()
|
|
}
|
|
}
|
|
if m.operation.confirmation != nil {
|
|
m.page = mainPage
|
|
if m.help.open {
|
|
// A safety confirmation requested after Help was opened is the new
|
|
// top dialog. Cancelling it reveals the unchanged Help beneath.
|
|
m.help.confirmationAbove = true
|
|
}
|
|
}
|
|
if progress.update.IsTerminal() {
|
|
return m, m.operation.deferredQuit()
|
|
}
|
|
return m, m.operation.wait()
|
|
}
|
|
if done, ok := message.(probeDone); ok {
|
|
return m, m.form.applyProbeResult(done.token, done.caps, done.err)
|
|
}
|
|
key, ok := message.(tea.KeyMsg)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
if m.help.top(m.operation.confirmation) {
|
|
m.help.update(key.String())
|
|
return m, nil
|
|
}
|
|
switch m.page {
|
|
case mainPage:
|
|
return m.updateMain(key)
|
|
case profilesPage:
|
|
return m.updateProfiles(key)
|
|
case formPage:
|
|
return m.updateForm(key)
|
|
default:
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
func (m *Model) startOperation(intent app.Intent) tea.Cmd {
|
|
snapshot, command, started := m.operation.start(m.application, intent)
|
|
if started {
|
|
m.snapshot = snapshot
|
|
m.retainTargetSelection()
|
|
}
|
|
return command
|
|
}
|
|
|
|
type mutationAvailability struct {
|
|
available bool
|
|
reason string
|
|
compactReason string
|
|
}
|
|
|
|
// targetMutationAvailability consumes the Application's structured action
|
|
// policy. It adds only Model-owned serialization state and never derives an
|
|
// action from Target state or display text.
|
|
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, operationBlocked bool) mutationAvailability {
|
|
switch {
|
|
case !selectedOK:
|
|
return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"}
|
|
case !selected.PrimaryAction.Available:
|
|
reason := selected.PrimaryAction.DisabledReason
|
|
if reason == "" {
|
|
reason = "This Target action is unavailable"
|
|
}
|
|
return mutationAvailability{reason: reason, compactReason: "unavailable"}
|
|
case operationBlocked:
|
|
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
|
|
default:
|
|
return mutationAvailability{available: true}
|
|
}
|
|
}
|
|
|
|
func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) {
|
|
for _, action := range selected.DriftResolutionActions {
|
|
if action.Kind == kind {
|
|
return action, true
|
|
}
|
|
}
|
|
return app.TargetAction{Kind: kind}, false
|
|
}
|
|
|
|
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.picker.open {
|
|
switch key.String() {
|
|
case "esc":
|
|
m.picker.close()
|
|
return m, nil
|
|
case "enter":
|
|
selected, ok := m.picker.selected(m.snapshot)
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
m.picker.close()
|
|
return m.beginProfileActivation(app.Intent{Kind: app.ActivateProfile, Name: selected.Name})
|
|
default:
|
|
return m, m.picker.update(key, m.snapshot)
|
|
}
|
|
}
|
|
if m.operation.confirmation != nil {
|
|
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 confirmationCancelled:
|
|
m.operation.cancelConfirmation()
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.driftResolutionTargetID != "" {
|
|
switch key.String() {
|
|
case "enter", "esc", "n", "q":
|
|
m.driftResolutionTargetID = ""
|
|
return m, nil
|
|
case "r", "o":
|
|
kind := app.ReapplyActiveProfileAction
|
|
if key.String() == "o" {
|
|
kind = app.RestoreOriginalAction
|
|
}
|
|
selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID)
|
|
if !ok {
|
|
m.driftResolutionTargetID = ""
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"}
|
|
return m, nil
|
|
}
|
|
action, ok := driftResolutionAction(selected, kind)
|
|
if !ok || !action.Available {
|
|
reason := action.DisabledReason
|
|
if reason == "" {
|
|
reason = "This Drift Resolution action is unavailable"
|
|
}
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason}
|
|
return m, nil
|
|
}
|
|
intent, _ := kind.Intent(selected.ID)
|
|
m.driftResolutionTargetID = ""
|
|
return m, m.startOperation(intent)
|
|
}
|
|
return m, nil
|
|
}
|
|
if key.String() == "e" && m.operation.currentError != nil {
|
|
m.operation.errorExpanded = !m.operation.errorExpanded
|
|
m.operation.errorOffset = 0
|
|
return m, nil
|
|
}
|
|
if key.String() == "x" && m.operation.currentError != nil {
|
|
m.operation.acknowledgeError()
|
|
return m, nil
|
|
}
|
|
if m.operation.errorExpanded {
|
|
switch key.String() {
|
|
case "pgup":
|
|
m.operation.errorOffset = max(0, m.operation.errorOffset-5)
|
|
return m, nil
|
|
case "pgdown":
|
|
m.operation.errorOffset += 5
|
|
return m, nil
|
|
}
|
|
}
|
|
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
|
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 changing the Active Profile"}
|
|
} else {
|
|
return m, m.picker.show(m.snapshot)
|
|
}
|
|
case "m":
|
|
if m.operation.active || m.operation.quitRequested {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Profile management disabled: wait for the current mutation to finish"}
|
|
} else {
|
|
m.page = profilesPage
|
|
m.retainProfileSelection(m.selectedProfileName, m.selected)
|
|
m.status = ""
|
|
if m.operation.status.Kind != app.StatusError {
|
|
m.operation.status = app.OperationStatus{}
|
|
}
|
|
}
|
|
case "r":
|
|
if m.operation.active || m.operation.quitRequested {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"}
|
|
} else {
|
|
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
|
|
}
|
|
case "tab":
|
|
if m.dashboardFocus == targetListPane {
|
|
m.dashboardFocus = targetDetailsPane
|
|
} else {
|
|
m.dashboardFocus = targetListPane
|
|
}
|
|
case "u":
|
|
formerIndex := catalog.index(m.selectedTargetID)
|
|
m.showUnavailable = !m.showUnavailable
|
|
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
|
|
m.detailsOffset = 0
|
|
case "up", "k":
|
|
if m.dashboardFocus == targetDetailsPane {
|
|
m.detailsOffset = max(0, m.detailsOffset-1)
|
|
} else {
|
|
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
|
|
m.detailsOffset = 0
|
|
}
|
|
case "down", "j":
|
|
if m.dashboardFocus == targetDetailsPane {
|
|
m.detailsOffset++
|
|
} else {
|
|
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
|
|
m.detailsOffset = 0
|
|
}
|
|
case "pgup":
|
|
if m.dashboardFocus == targetDetailsPane {
|
|
m.detailsOffset = max(0, m.detailsOffset-5)
|
|
}
|
|
case "pgdown":
|
|
if m.dashboardFocus == targetDetailsPane {
|
|
m.detailsOffset += 5
|
|
}
|
|
case " ":
|
|
selected, ok := catalog.selected(m.selectedTargetID)
|
|
availability := targetMutationAvailability(selected, ok, m.operation.active || m.operation.quitRequested)
|
|
if !availability.available {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason}
|
|
break
|
|
}
|
|
if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction {
|
|
m.driftResolutionTargetID = selected.ID
|
|
break
|
|
}
|
|
intent, ok := selected.PrimaryAction.Kind.Intent(selected.ID)
|
|
if !ok {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"}
|
|
break
|
|
}
|
|
return m, m.startOperation(intent)
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.profileDelete != nil {
|
|
switch m.profileDelete.dialog.update(key.String()) {
|
|
case confirmationAccepted:
|
|
name := m.profileDelete.name
|
|
m.profileDelete = nil
|
|
return m, m.startOperation(app.Intent{Kind: app.DeleteProfile, Name: name, Confirm: true})
|
|
case confirmationCancelled:
|
|
m.profileDelete = nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch key.String() {
|
|
case "q", "esc":
|
|
m.page = mainPage
|
|
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.formSequence++
|
|
m.form = newFormState(m.formSequence, "", profile.Profile{Mode: profile.SingleMode})
|
|
m.page = formPage
|
|
m.status = ""
|
|
case "e", "enter":
|
|
if !m.profileMutationAvailable("editing a Proxy Profile") {
|
|
break
|
|
}
|
|
if selected, ok := m.selectedProfile(); ok {
|
|
m.formSequence++
|
|
m.form = newFormState(m.formSequence, selected.Name, selected)
|
|
m.page = formPage
|
|
m.status = ""
|
|
}
|
|
case "d":
|
|
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) {
|
|
if m.form.currentField() == profile.BypassField {
|
|
switch key.String() {
|
|
case "pgup":
|
|
m.form.scrollBypassPreview(-5)
|
|
return m, nil
|
|
case "pgdown":
|
|
m.form.scrollBypassPreview(5)
|
|
return m, nil
|
|
}
|
|
}
|
|
if key.String() == "q" || key.Type == tea.KeyEsc {
|
|
m.page = profilesPage
|
|
m.status = ""
|
|
m.form = formState{}
|
|
return m, nil
|
|
}
|
|
switch key.Type {
|
|
case tea.KeyCtrlS:
|
|
m.form.invalidateProbe()
|
|
m.form.syncFocusedValue()
|
|
m.form.refreshValidation()
|
|
if err := m.form.validation.Err(); err != nil || m.form.nameConflict(m.snapshot.Profiles) != "" {
|
|
m.status = "Fix the highlighted Profile fields before saving"
|
|
return m, nil
|
|
}
|
|
kind := app.CreateProfile
|
|
if m.form.originalName != "" {
|
|
kind = app.UpdateProfile
|
|
}
|
|
normalized := m.form.validation.Normalized
|
|
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized}
|
|
m.selectedProfileName = normalized.Name
|
|
return m, m.startOperation(intent)
|
|
case tea.KeyCtrlD:
|
|
address, token, started := m.form.startProbe()
|
|
if !started {
|
|
return m, nil
|
|
}
|
|
detect := m.detect
|
|
return m, func() tea.Msg {
|
|
caps, err := detect(context.Background(), address)
|
|
return probeDone{caps: caps, err: err, token: token}
|
|
}
|
|
case tea.KeyCtrlT:
|
|
return m, m.form.toggleMode()
|
|
case tea.KeyTab, tea.KeyDown:
|
|
return m, m.form.move(1)
|
|
case tea.KeyShiftTab, tea.KeyUp:
|
|
return m, m.form.move(-1)
|
|
default:
|
|
return m, m.form.updateInput(key)
|
|
}
|
|
}
|
|
|
|
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
|
|
if m.busy() {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
|
|
return m, nil
|
|
}
|
|
m.page = mainPage
|
|
return m, m.startOperation(intent)
|
|
}
|
|
|
|
func isProfileMutation(kind app.IntentKind) bool {
|
|
switch kind {
|
|
case app.CreateProfile, app.UpdateProfile, app.DeleteProfile:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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) 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 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,
|
|
terminal: terminalDimensions{width: m.width, height: m.height},
|
|
dashboard: dashboardPresentationState{
|
|
selectedTargetID: m.selectedTargetID,
|
|
focus: m.dashboardFocus,
|
|
showUnavailable: m.showUnavailable,
|
|
detailsOffset: m.detailsOffset,
|
|
driftResolutionTargetID: m.driftResolutionTargetID,
|
|
picker: m.picker,
|
|
},
|
|
profile: profilePresentationState{
|
|
selected: m.selected,
|
|
listOffset: m.profileViewportOffset,
|
|
detailsOffset: m.profileDetailsOffset,
|
|
deletion: m.profileDelete,
|
|
status: m.status,
|
|
},
|
|
form: formPresentationState{state: m.form},
|
|
help: helpPresentationState{dialog: m.help},
|
|
operation: operationPresentationState{
|
|
confirmation: m.operation.confirmation,
|
|
confirmationDialog: &m.operation.confirmationDialog,
|
|
status: m.operation.status,
|
|
currentError: m.operation.currentError,
|
|
errorExpanded: m.operation.errorExpanded,
|
|
errorOffset: m.operation.errorOffset,
|
|
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
|
|
active: m.operation.active,
|
|
progress: m.operation.progress,
|
|
spinnerFrame: m.operation.spinner.View(),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (m *Model) retainTargetSelection() {
|
|
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
|
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
|
|
}
|