Files
proxy/internal/tui/model.go
T

758 lines
24 KiB
Go

package tui
import (
"context"
"errors"
"fmt"
"strings"
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 one finished Protocol Probe back to the form. Endpoint and
// input text deliberately stay in formState: an asynchronous result cannot
// introduce userinfo into status, and formID plus generation reject stale work.
type probeDone struct {
caps probe.Capabilities
err error
formID uint64
generation uint64
}
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 {
if m.operation.updates != nil {
return waitForOperation(m.operation.updates)
}
return nil
}
// 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) dashboardNavigationAvailable() bool {
// A deferred exit rejects new work, not read-only browsing. Selection,
// details, and Help remain useful until the current mutation is safe.
return true
}
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 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()
}
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
}
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, waitForOperation(progress.updates)
}
if done, ok := message.(probeDone); ok {
if m.page != formPage || !m.form.probe.active || done.formID != m.form.id || done.generation != m.form.probe.generation {
return m, nil
}
m.form.probe.active = false
address := m.form.probe.address
switch {
case done.err != nil:
// Detector errors are intentionally not rendered. Production errors are
// based on the credential-free dial endpoint, but this boundary also
// protects frames if a lower layer returns hostile credential text.
m.form.probe.feedback = "Protocol Probe failed for " + address
case !done.caps.HTTP && !done.caps.SOCKS5:
m.form.probe.feedback = "Protocol Probe: unrecognized protocol at " + address
default:
raw := m.form.profile.SingleURL
if done.caps.HTTP && done.caps.SOCKS5 {
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
m.form.profile.Mode = profile.AdvancedMode
m.form.profile.HTTPURL = httpURL
m.form.profile.HTTPSURL = httpURL
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
m.form.advancedInitialized = true
m.form.probe.field = profile.HTTPURLField
m.form.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address
m.form.rebuildInputs(1)
return m, nil
}
rewritten, err := probe.URL(raw, done.caps)
if err != nil {
m.form.probe.feedback = "Protocol Probe could not apply the detected capabilities"
return m, nil
}
m.form.profile.SingleURL = rewritten
m.form.probe.field = profile.SingleURLField
kind := "SOCKS5"
if done.caps.HTTP {
kind = "HTTP"
}
m.form.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address
m.form.rebuildInputs(1)
}
return m, nil
}
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.dashboardNavigationAvailable() {
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
m.dashboardFocus = targetListPane
}
}
case "u":
if m.dashboardNavigationAvailable() {
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.dashboardNavigationAvailable() {
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.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.detailsOffset = 0
}
}
case "pgup":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5)
}
case "pgdown":
if m.dashboardNavigationAvailable() && 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
m.applyProfileMutation(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}
result := m.applyProfileMutation(intent, normalized.Name)
if result.Err != nil {
return m, nil
}
m.page = profilesPage
m.form = formState{}
return m, nil
case tea.KeyCtrlD:
if m.form.profile.Mode != profile.SingleMode || m.form.currentField() != profile.SingleURLField || m.form.probe.active {
return m, nil
}
m.form.syncFocusedValue()
m.form.invalidateProbe()
m.form.probe.field = profile.SingleURLField
address, err := probeAddress(m.form.profile.SingleURL)
if err != nil {
m.form.probe.feedback = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.probe.active = true
m.form.probe.address = address
m.form.probe.feedback = "Protocol Probe: checking " + address + "…"
detect := m.detect
formID := m.form.id
generation := m.form.probe.generation
return m, func() tea.Msg {
caps, err := detect(context.Background(), address)
return probeDone{caps: caps, err: err, formID: formID, generation: generation}
}
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 (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 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
// and status text, dropping any userinfo so credentials are never displayed
// or sent anywhere.
func probeAddress(raw string) (string, error) {
u, err := profile.ParseProxyURL(raw)
if err != nil {
return "", errors.New("Single proxy URL must include host:port")
}
return u.Host, nil
}
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,
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,
driftResolutionTargetID: m.driftResolutionTargetID,
picker: m.picker,
form: m.form,
help: m.help,
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,
})
}
func (m Model) availableTargets() []app.TargetSnapshot {
return availableTargets(m.snapshot)
}
func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
}