Files
2026-08-30 11:17:41 +08:00

679 lines
21 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) updateProfilePicker(key tea.KeyMsg) (tea.Model, tea.Cmd) {
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)
}
}
func (m Model) updateOperationConfirmation(key tea.KeyMsg) (tea.Model, tea.Cmd) {
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
}
func (m Model) updateDriftResolution(key tea.KeyMsg) (tea.Model, tea.Cmd) {
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
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.picker.open {
return m.updateProfilePicker(key)
}
if m.operation.confirmation != nil {
return m.updateOperationConfirmation(key)
}
if m.driftResolutionTargetID != "" {
return m.updateDriftResolution(key)
}
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))
}