229 lines
9.8 KiB
Go
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."
|
|
}
|