Files
proxy/internal/tui/help_dialog.go
T

229 lines
9.8 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
)
// helpDialogState is an overlay, not a page. Keeping only its viewport state
// here means opening Help cannot replace the page, selection, focus, or the
// viewport beneath it.
type helpDialogState struct {
open bool
offset int
confirmationAbove bool
}
func (h *helpDialogState) show() {
h.open = true
h.offset = 0
// An explicitly opened Help dialog is newer than any visible confirmation.
h.confirmationAbove = false
}
func (h *helpDialogState) close() { *h = helpDialogState{} }
func (h helpDialogState) top(confirmation *app.Confirmation) bool {
return h.open && (confirmation == nil || !h.confirmationAbove)
}
func (h *helpDialogState) update(key string) {
switch key {
case "up", "k":
h.offset = max(0, h.offset-1)
case "down", "j":
h.offset++
case "pgup":
h.offset = max(0, h.offset-5)
case "pgdown":
h.offset += 5
case "home":
h.offset = 0
case "end":
h.offset = 1 << 20 // the viewport clamps this to the final content line
case "q", "esc":
h.close()
}
}
func (m Model) dashboardQuitAvailable() bool {
return m.page == mainPage && !m.help.open && !m.picker.open &&
m.driftResolutionTargetID == "" && m.operation.confirmation == nil
}
func (p terminalPresentation) helpDialogView(model presentationState) terminalPage {
content := lipgloss.NewStyle().Width(max(1, model.width)).Render(p.contextualHelp(model))
exitHint := "Ctrl+C: exit"
if model.operationActive {
exitHint = "Ctrl+C: defer safe exit"
}
if model.exitDeferred {
exitHint = "Exit pending until safe completion"
}
return terminalPage{
primary: content,
footer: "↑/k ↓/j PgUp/PgDn Home/End: scroll\nq/Esc: close Help " + exitHint,
focusLine: -1,
primaryOffset: model.help.offset,
}
}
func (p terminalPresentation) contextualHelp(model presentationState) string {
var body strings.Builder
body.WriteString(p.theme.section.Render("Contextual Help"))
body.WriteString("\nThis describes the page or dialog beneath Help. While Help is open, only its scrolling and closing keys plus global Ctrl+C are accepted; the underlying state remains unchanged.\n\n")
switch {
case model.confirmation != nil:
body.WriteString(p.theme.section.Render("Safety confirmation"))
fmt.Fprintf(&body, "\n%s\n", model.confirmation.Title)
if model.confirmation.Summary != "" {
fmt.Fprintf(&body, "%s\n", model.confirmation.Summary)
}
body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y confirms. n, q, or Escape cancels. ")
if model.confirmationDialog != nil {
body.WriteByte('\n')
body.WriteString(confirmationFocusDescription(*model.confirmationDialog))
}
body.WriteByte('\n')
case model.profileDelete != nil:
body.WriteString(p.theme.section.Render("Delete confirmation"))
fmt.Fprintf(&body, "\nDelete Proxy Profile %q. Deletion cannot be undone.\n", model.profileDelete.name)
body.WriteString("Left/Right, Tab, or Shift+Tab changes the focused choice. Enter chooses it. y deletes. n, q, or Escape cancels. ")
body.WriteByte('\n')
body.WriteString(confirmationFocusDescription(model.profileDelete.dialog))
body.WriteByte('\n')
case model.picker.open:
body.WriteString(p.theme.section.Render("Active Profile picker"))
body.WriteString("\nType to filter Proxy Profiles; q is ordinary search input here. Up/Down or Ctrl+P/Ctrl+N selects, and PgUp/PgDn moves by a page. Enter activates the selection. Escape cancels.\n")
case model.driftResolutionTargetID != "":
body.WriteString(p.theme.section.Render("Drift Resolution dialog"))
body.WriteString("\nr reapplies the Active Profile. o restores original configuration when ownership evidence permits. Enter, q, or Escape cancels.\n")
if selected, ok := newTargetCatalog(model.snapshot, true, model.driftResolutionTargetID).selected(model.driftResolutionTargetID); ok {
for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} {
action, found := driftResolutionAction(selected, kind)
if found && action.Available {
fmt.Fprintf(&body, "%s: available.\n", targetActionLabel(kind))
} else {
reason := action.DisabledReason
if reason == "" {
reason = "unavailable"
}
fmt.Fprintf(&body, "%s: disabled — %s.\n", targetActionLabel(kind), reason)
}
}
}
case model.page == profilesPage:
body.WriteString(p.theme.section.Render("Profile management"))
if selected, ok := profileAt(model.snapshot, model.selected); ok {
fmt.Fprintf(&body, "\nSelected Profile: %s.\n", selected.Name)
body.WriteString("Up/Down or j/k selects a Profile. PgUp/PgDn scrolls its details. n creates, e or Enter edits, and d opens the delete confirmation. q or Escape returns to the dashboard.\n")
if model.operationActive || model.exitDeferred {
body.WriteString("Profile modifications are disabled until the current mutation completes.\n")
} else {
body.WriteString("Create, Edit selected Profile, and Delete selected Profile are available.\n")
}
} else {
body.WriteString("\nNo Proxy Profiles are saved. n creates one. q or Escape returns to the dashboard.\n")
}
case model.page == formPage:
body.WriteString(p.theme.section.Render("Profile form"))
labels := model.form.labels()
if model.form.field >= 0 && model.form.field < len(labels) {
fmt.Fprintf(&body, "\nFocus: %s field.\n", labels[model.form.field])
}
body.WriteString("Type to append text and Backspace to delete. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode. Ctrl+S saves. q or Escape cancels and returns to Profile management.\n")
if model.form.profile.Mode == "single" && model.form.field == 1 {
if model.form.probing {
body.WriteString("Protocol Probe is in progress; Ctrl+D does not start a duplicate probe.\n")
} else {
body.WriteString("Ctrl+D explicitly runs Protocol Probe for the Single proxy URL field.\n")
}
}
default:
body.WriteString(p.theme.section.Render("Dashboard"))
focus := "Targets focus"
if model.dashboardFocus == targetDetailsPane {
focus = "Target details focus"
}
fmt.Fprintf(&body, "\nContext: %s.\n", focus)
catalog := newTargetCatalog(model.snapshot, model.showUnavailable, model.selectedTargetID)
if selected, ok := catalog.selected(model.selectedTargetID); ok {
fmt.Fprintf(&body, "Selected Target: %s. State: %s.\n", selected.Description.Name, targetStateLabel(selected.State))
availability := targetMutationAvailability(selected, true, model.operationActive || model.exitDeferred)
label := targetActionLabel(selected.PrimaryAction.Kind)
if availability.available {
fmt.Fprintf(&body, "Primary action: %s — available with Space.\n", label)
} else {
fmt.Fprintf(&body, "Primary action: %s — disabled: %s.\n", label, availability.reason)
}
}
if model.dashboardFocus == targetDetailsPane {
body.WriteString("Up/Down, j/k, and PgUp/PgDn scroll Target details. Tab focuses Targets.\n")
} else {
body.WriteString("Up/Down or j/k selects a Target. Tab focuses Target details.\n")
}
if model.operationActive || model.exitDeferred {
body.WriteString("u still shows or hides unavailable Targets. p, m, r, and Space modifications are disabled until the current mutation completes.\n")
} else {
body.WriteString("u shows or hides unavailable Targets. p opens the Active Profile picker. m opens Profile management. r refreshes Targets.\n")
}
}
body.WriteString("\n")
body.WriteString(p.theme.section.Render("Operation state"))
switch {
case model.exitDeferred:
body.WriteString("\nExit is pending. Read-only browsing and Help remain available; all new work is disabled until the mutation completes safely.\n")
case model.operationActive:
body.WriteString("\nMutation in progress. Target selection, details scrolling, and Help remain available.\nConflicting mutations are disabled until completion. Ctrl+C requests a deferred safe exit.\n")
default:
body.WriteString("\nNo mutation is in progress. Ctrl+C exits immediately.\n")
}
if model.currentError != nil && model.page == mainPage && model.confirmation == nil && !model.picker.open && model.driftResolutionTargetID == "" {
body.WriteString("e expands or collapses the current error; x acknowledges it.\n")
}
if model.page == mainPage && model.profileDelete == nil {
body.WriteString("\n")
body.WriteString(p.theme.section.Render("Built-in Target reference"))
body.WriteString("\n")
for _, item := range model.snapshot.Targets {
fmt.Fprintf(&body, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details)
if item.Available {
fmt.Fprintf(&body, "Detected: %s\n\n", item.AvailabilityEvidence)
} else {
fmt.Fprintf(&body, "Not detected: %s\n\n", item.AvailabilityEvidence)
}
}
}
body.WriteString("\n")
body.WriteString(p.theme.section.Render("Dialog and page navigation"))
body.WriteString("\nEscape closes the top dialog. With no dialog open, Escape returns one page level. q closes Help and confirmations, returns from child pages, and exits only from the unobscured dashboard. Picker search accepts q as text.\n")
body.WriteString("Inputs are delivered only to the top dialog; they cannot activate content behind it. A newly requested safety confirmation appears above older Help.\n")
body.WriteString("\n")
body.WriteString(p.theme.section.Render("Global exit safety"))
body.WriteString("\nCtrl+C requests exit from every page and dialog. During a system mutation, proxyctl waits for safe completion and rejects new work before exiting.\n")
return body.String()
}
func confirmationFocusDescription(dialog confirmationDialog) string {
label := dialog.request.CancelLabel
if label == "" {
label = "Cancel"
}
if dialog.confirmFocused {
label = dialog.request.ConfirmLabel
if label == "" {
label = "Confirm"
}
}
return label + " is focused."
}