Files
proxy/internal/tui/presentation.go
T

586 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
"unicode/utf8"
"github.com/charmbracelet/bubbles/viewport"
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
const (
minimumWidth = 60
minimumHeight = 16
wideWidth = 100
wideHeight = 28
)
type themeMode uint8
const (
themeModeAuto themeMode = iota
themeModeDark
themeModeLight
themeModeNoColor
)
type modelOptions struct {
theme themeMode
output io.Writer
}
// ModelOption configures terminal capabilities used by the complete TUI Model.
type ModelOption func(*modelOptions)
// WithOutput makes presentation capability detection use the same writer that
// Bubble Tea renders to. Production composition should pass tea.WithOutput's
// writer here as well.
func WithOutput(output io.Writer) ModelOption {
return func(options *modelOptions) { options.output = output }
}
func withTheme(mode themeMode) ModelOption {
return func(options *modelOptions) { options.theme = mode }
}
func defaultModelOptions() modelOptions {
return modelOptions{output: os.Stdout}
}
type presentationState struct {
snapshot app.Snapshot
page page
terminal terminalDimensions
dashboard dashboardPresentationState
profile profilePresentationState
form formPresentationState
help helpPresentationState
operation operationPresentationState
}
type terminalDimensions struct {
width int
height int
}
type dashboardPresentationState struct {
selectedTargetID string
focus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
picker profilePickerState
}
type profilePresentationState struct {
selected int
listOffset int
detailsOffset int
deletion *profileDeleteConfirmation
status string
}
type formPresentationState struct {
state formState
}
type helpPresentationState struct {
dialog helpDialogState
}
type operationPresentationState struct {
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
status app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
active bool
progress map[string]app.TargetProgressPhase
spinnerFrame string
}
// terminalPresentation is the terminal-presentation Module. Application
// coordination supplies one immutable state and calls its single Render
// operation; responsive layout, theming, clipping, and final redaction stay
// inside the module.
type terminalPresentation struct {
theme semanticTheme
}
type terminalPage struct {
header string
primary string
context string
footer string
status string
statusExpanded bool
statusOffset int
focusLine int
primaryOffset int
contextOffset int
contextAnchor int
anchorContext bool
wrapPrimary bool
wrapContext bool
dashboard *dashboardRenderState
}
func (p terminalPresentation) Render(state presentationState) string {
// Application snapshots, adapter diagnostics, operation updates, and form
// input are all untrusted presentation data. Strip their controls before any
// semanticTheme style is applied; otherwise an attacker could inject one of
// the same allowlisted SGR sequences that the theme legitimately emits.
state = sanitizePresentationState(state)
width, height := state.terminal.width, state.terminal.height
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
if width < minimumWidth || height < minimumHeight {
content := profile.RedactText(p.minimumSizeView(width, height))
return strings.TrimRight(viewportContent(content, width, height, 0), " \n") + "\n"
}
var page terminalPage
switch state.page {
case profilesPage:
page = p.profilesView(state)
case formPage:
page = p.formView(state)
default:
page = p.mainView(state, width, height)
}
if state.help.dialog.top(state.operation.confirmation) {
help := p.helpDialogView(state)
// Retain the underlying page header so Help is visibly contextual and
// modal rather than a replacement navigation destination.
page.primary = help.primary
page.context = ""
page.footer = help.footer
page.focusLine = help.focusLine
page.primaryOffset = help.primaryOffset
page.contextOffset = 0
page.dashboard = nil
}
page.status = renderStatus(state)
page.statusExpanded = state.operation.errorExpanded && state.operation.currentError != nil
page.statusOffset = state.operation.errorOffset
page = redactTerminalPage(page)
// Untrusted values were redacted and stripped of controls before semantic
// styling. The final pass therefore removes unexpected terminal controls
// without reparsing clipped, composed prose as if it were a URL.
return sanitizeStyledText(p.renderShell(page, width, height))
}
func renderStatus(state presentationState) string {
var lines []string
operation := state.operation
if operation.exitDeferred {
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
} else if operation.status.Summary != "" {
lines = append(lines, operation.status.Summary)
} else if state.profile.status != "" {
lines = append(lines, state.profile.status)
}
if operation.currentError != nil {
errorText := "Current error: " + operation.currentError.status.Summary
if operation.errorExpanded && operation.currentError.detail != "" {
errorText += "\n" + operation.currentError.detail
}
lines = append(lines, errorText)
}
return strings.Join(lines, "\n")
}
func redactTerminalPage(page terminalPage) terminalPage {
page.header = protectRenderedText(page.header)
page.primary = protectRenderedText(page.primary)
page.context = protectRenderedText(page.context)
page.footer = protectRenderedText(page.footer)
page.status = protectRenderedText(page.status)
return page
}
func protectRenderedText(value string) string {
return sanitizeStyledText(value)
}
func sanitizeUntrustedControls(value string) string {
return sanitizeTerminalText(value, false)
}
func sanitizeUntrustedText(value string) string {
return sanitizeUntrustedControls(profile.RedactText(value))
}
func sanitizeUntrustedUserinfoText(value string) string {
return sanitizeUntrustedControls(profile.RedactUserinfoText(value))
}
func sanitizePresentationState(state presentationState) presentationState {
state.snapshot.ActiveProfile = sanitizeUntrustedText(state.snapshot.ActiveProfile)
state.snapshot.Profiles = append([]profile.Profile(nil), state.snapshot.Profiles...)
for i := range state.snapshot.Profiles {
state.snapshot.Profiles[i] = sanitizePresentationProfile(state.snapshot.Profiles[i])
}
state.snapshot.Targets = append([]app.TargetSnapshot(nil), state.snapshot.Targets...)
for i := range state.snapshot.Targets {
item := &state.snapshot.Targets[i]
item.Description.Name = sanitizeUntrustedText(item.Description.Name)
item.Description.Summary = sanitizeUntrustedText(item.Description.Summary)
item.Description.Scope = sanitizeUntrustedText(item.Description.Scope)
item.Description.Permissions = sanitizeUntrustedText(item.Description.Permissions)
item.Description.Ownership = sanitizeUntrustedText(item.Description.Ownership)
item.Description.RestartRisk = sanitizeUntrustedText(item.Description.RestartRisk)
item.Description.Details = sanitizeUntrustedText(item.Description.Details)
item.AvailabilityEvidence = sanitizeUntrustedText(item.AvailabilityEvidence)
item.Evidence = sanitizeUntrustedText(item.Evidence)
item.DetectionErr = sanitizePresentationError(item.DetectionErr)
item.PrimaryAction.DisabledReason = sanitizeUntrustedText(item.PrimaryAction.DisabledReason)
item.DriftResolutionActions = append([]app.TargetAction(nil), item.DriftResolutionActions...)
for action := range item.DriftResolutionActions {
item.DriftResolutionActions[action].DisabledReason = sanitizeUntrustedText(item.DriftResolutionActions[action].DisabledReason)
}
}
sanitizeStatus := sanitizeUntrustedText
if state.page == formPage {
// Profile editing treats the identity as private as well as the password.
// Apply that stronger policy to every status/error ingress before styling.
sanitizeStatus = sanitizeUntrustedUserinfoText
}
state.profile.status = sanitizeStatus(state.profile.status)
state.operation.status.Summary = sanitizeStatus(state.operation.status.Summary)
if state.operation.currentError != nil {
current := *state.operation.currentError
current.status.Summary = sanitizeStatus(current.status.Summary)
current.detail = sanitizeStatus(current.detail)
state.operation.currentError = &current
}
if state.operation.confirmation != nil {
confirmation := sanitizePresentationConfirmation(*state.operation.confirmation)
state.operation.confirmation = &confirmation
}
if state.operation.confirmationDialog != nil {
dialog := *state.operation.confirmationDialog
dialog.request = sanitizePresentationConfirmation(dialog.request)
state.operation.confirmationDialog = &dialog
}
if state.profile.deletion != nil {
confirmation := *state.profile.deletion
confirmation.name = sanitizeUntrustedText(confirmation.name)
confirmation.dialog.request = sanitizePresentationConfirmation(confirmation.dialog.request)
state.profile.deletion = &confirmation
}
state.dashboard.picker.selectedName = sanitizeUntrustedText(state.dashboard.picker.selectedName)
searchPosition := state.dashboard.picker.search.Position()
state.dashboard.picker.search.SetValue(sanitizeUntrustedText(state.dashboard.picker.search.Value()))
state.dashboard.picker.search.SetCursor(searchPosition)
state.form.state = sanitizePresentationForm(state.form.state)
return state
}
func sanitizePresentationProfile(value profile.Profile) profile.Profile {
value.Name = sanitizeUntrustedText(value.Name)
value.SingleURL = sanitizeUntrustedText(value.SingleURL)
value.HTTPURL = sanitizeUntrustedText(value.HTTPURL)
value.HTTPSURL = sanitizeUntrustedText(value.HTTPSURL)
value.AllURL = sanitizeUntrustedText(value.AllURL)
value.Bypass = append([]string(nil), value.Bypass...)
for i := range value.Bypass {
value.Bypass[i] = sanitizeUntrustedText(value.Bypass[i])
}
return value
}
func sanitizePresentationForm(form formState) formState {
// Preserve credential byte/rune positions in the copied editor state so
// textinput cursor and horizontal viewport calculations stay truthful.
// renderedInput masks userinfo before clipping, and the final form boundary
// redacts it again; this pass only removes terminal controls before styling.
form.originalName = sanitizeUntrustedControls(form.originalName)
form.profile = sanitizePresentationFormProfile(form.profile)
form.inputs = append([]formInput(nil), form.inputs...)
for i := range form.inputs {
form.inputs[i].label = sanitizeUntrustedText(form.inputs[i].label)
position := form.inputs[i].input.Position()
form.inputs[i].input.SetValue(sanitizeUntrustedControls(form.inputs[i].input.Value()))
form.inputs[i].input.SetCursor(position)
}
form.probe.address = sanitizeUntrustedText(form.probe.address)
form.probe.feedback = sanitizeUntrustedText(form.probe.feedback)
form.probe.sourceValue = sanitizeUntrustedText(form.probe.sourceValue)
form.refreshValidation()
return form
}
func sanitizePresentationFormProfile(value profile.Profile) profile.Profile {
value.Name = sanitizeUntrustedControls(value.Name)
value.SingleURL = sanitizeUntrustedControls(value.SingleURL)
value.HTTPURL = sanitizeUntrustedControls(value.HTTPURL)
value.HTTPSURL = sanitizeUntrustedControls(value.HTTPSURL)
value.AllURL = sanitizeUntrustedControls(value.AllURL)
value.Bypass = append([]string(nil), value.Bypass...)
for i := range value.Bypass {
value.Bypass[i] = sanitizeUntrustedControls(value.Bypass[i])
}
return value
}
func sanitizePresentationConfirmation(value app.Confirmation) app.Confirmation {
value.Title = sanitizeUntrustedText(value.Title)
value.Summary = sanitizeUntrustedText(value.Summary)
value.ConfirmLabel = sanitizeUntrustedText(value.ConfirmLabel)
value.CancelLabel = sanitizeUntrustedText(value.CancelLabel)
value.Details = append([]string(nil), value.Details...)
for i := range value.Details {
value.Details[i] = sanitizeUntrustedText(value.Details[i])
}
return value
}
func sanitizePresentationError(err error) error {
if err == nil {
return nil
}
return errors.New(sanitizeUntrustedText(err.Error()))
}
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
// sanitizeStyledText is the final defense after semantic styling. Untrusted
// values have already passed sanitizeUntrustedText, so the only SGR retained
// here is output generated by semanticTheme.
func sanitizeStyledText(value string) string { return sanitizeTerminalText(value, true) }
func sanitizeTerminalText(value string, allowThemeSGR bool) string {
var safe strings.Builder
for i := 0; i < len(value); {
if value[i] == '\x1b' {
if i+1 < len(value) && value[i+1] == '[' {
end := i + 2
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
end++
}
if end < len(value) {
if allowThemeSGR && value[end] == 'm' && safeSGRPattern.MatchString(value[i+2:end]) {
safe.WriteString(value[i : end+1])
}
i = end + 1
continue
}
i = len(value)
continue
}
if i+1 < len(value) && value[i+1] == ']' {
i = skipTerminalString(value, i+2)
continue
}
i++
continue
}
if value[i] == 0x9b {
end := i + 1
for end < len(value) && (value[end] < 0x40 || value[end] > 0x7e) {
end++
}
if end < len(value) {
i = end + 1
} else {
i = len(value)
}
continue
}
if value[i] == 0x9d {
i = skipTerminalString(value, i+1)
continue
}
switch value[i] {
case '\n':
safe.WriteByte('\n')
case '\t':
safe.WriteString(" ")
default:
if value[i] >= utf8.RuneSelf {
r, size := utf8.DecodeRuneInString(value[i:])
if r != utf8.RuneError || size > 1 {
if r != '\ufeff' && !(r >= 0x80 && r <= 0x9f) && !(r >= 0x202a && r <= 0x202e) && !(r >= 0x2066 && r <= 0x2069) {
safe.WriteString(value[i : i+size])
}
i += size
continue
}
} else if value[i] >= 0x20 && value[i] != 0x7f {
safe.WriteByte(value[i])
}
}
i++
}
var visible strings.Builder
for _, r := range safe.String() {
if r == '\ufeff' || (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
continue
}
visible.WriteRune(r)
}
return visible.String()
}
func skipTerminalString(value string, i int) int {
for i < len(value) {
if value[i] == '\a' || value[i] == 0x9c {
return i + 1
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
return i + 2
}
i++
}
return i
}
func (p terminalPresentation) renderShell(page terminalPage, width, height int) string {
header := viewportContent(page.header, width, lineCount(page.header), 0)
footer := viewportContent(page.footer, width, lineCount(page.footer), 0)
headerHeight := lineCount(header)
footerHeight := lineCount(footer)
baseSeparators := 0
if header != "" {
baseSeparators++
}
if footer != "" || page.status != "" {
baseSeparators++
}
status := ""
if page.status != "" {
wrapped := lipgloss.NewStyle().Width(width).Render(p.theme.status.Render("Status: " + page.status))
statusHeight := min(2, lineCount(wrapped))
statusOffset := 0
if page.statusExpanded {
statusHeight = min(lineCount(wrapped), max(3, height/2))
// The compact dashboard needs ten rows to retain both panes. Bound
// expanded errors to the remaining terminal budget instead of
// allowing their viewport to push the frame beyond the terminal.
minimumBodyHeight := 1
if page.dashboard != nil {
minimumBodyHeight = 10
}
statusHeight = min(statusHeight, max(1, height-headerHeight-footerHeight-baseSeparators-minimumBodyHeight))
statusOffset = page.statusOffset
}
status = viewportContent(wrapped, width, statusHeight, statusOffset)
}
statusHeight := lineCount(status)
separators := baseSeparators
bodyHeight := height - headerHeight - footerHeight - statusHeight - separators
if bodyHeight < 1 {
bodyHeight = 1
}
body := renderPageBody(page, width, bodyHeight)
if page.dashboard != nil {
body = p.renderDashboard(*page.dashboard, width, bodyHeight)
}
sections := make([]string, 0, 3)
if header != "" {
sections = append(sections, header)
}
sections = append(sections, body)
bottom := joinNonEmpty(status, footer)
if bottom != "" {
sections = append(sections, bottom)
}
frame := strings.Join(sections, "\n\n")
return strings.TrimRight(frame, " \n") + "\n"
}
func renderPageBody(page terminalPage, width, height int) string {
primaryContent := page.primary
if page.wrapPrimary {
primaryContent = lipgloss.NewStyle().Width(width).Render(primaryContent)
}
if page.context == "" || height < 5 {
return viewportContentAt(primaryContent, width, height, page.primaryOffset, page.focusLine)
}
context := page.context
if page.wrapContext {
context = lipgloss.NewStyle().Width(width).Render(context)
}
contextHeight := min(lineCount(context), max(3, height/2))
primaryHeight := height - contextHeight - 1
if primaryHeight < 1 {
primaryHeight = 1
contextHeight = max(1, height-primaryHeight-1)
}
primary := viewportContentAt(primaryContent, width, primaryHeight, page.primaryOffset, page.focusLine)
contextFocus := -1
if page.anchorContext {
contextFocus = page.contextAnchor
}
context = viewportContentAt(context, width, contextHeight, page.contextOffset, contextFocus)
return primary + "\n\n" + context
}
func viewportContent(content string, width, height, focusLine int) string {
return viewportContentAt(content, width, height, 0, focusLine)
}
func viewportContentAt(content string, width, height, offset, focusLine int) string {
if content == "" || width <= 0 || height <= 0 {
return ""
}
view := viewport.New(width, height)
view.SetContent(strings.TrimRight(content, "\n"))
top := max(0, offset)
if focusLine >= 0 {
if focusLine < top {
top = focusLine
} else if focusLine >= top+height {
top = focusLine - height + 1
}
}
view.SetYOffset(top)
return view.View()
}
func lineCount(content string) int {
if content == "" {
return 0
}
return strings.Count(strings.TrimRight(content, "\n"), "\n") + 1
}
func joinNonEmpty(values ...string) string {
var present []string
for _, value := range values {
if value != "" {
present = append(present, value)
}
}
return strings.Join(present, "\n")
}
func (p terminalPresentation) minimumSizeView(width, height int) string {
return fmt.Sprintf("%s\n\nMinimum size: 60×16\nCurrent size: %d×%d\nResize the terminal to continue.",
p.theme.warning.Render("Terminal too small"), width, height)
}