Files
proxy/internal/tui/presentation.go
T

378 lines
10 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 (
"fmt"
"io"
"os"
"regexp"
"strings"
"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
width int
height int
selected int
profileOffset int
profileDetails int
profileDelete *profileDeleteConfirmation
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
driftResolutionTargetID string
picker profilePickerState
form formState
help helpDialogState
confirmation *app.Confirmation
confirmationDialog *confirmationDialog
status string
operationStatus app.OperationStatus
currentError *operationError
errorExpanded bool
errorOffset int
exitDeferred bool
operationActive bool
operationProgress map[string]app.TargetProgressPhase
}
// 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 {
width, height := state.width, state.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.top(state.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.errorExpanded && state.currentError != nil
page.statusOffset = state.errorOffset
page = redactTerminalPage(page)
// Dashboard composition retains structured Target metadata until pane sizes
// are known. Redact the fully composed frame as the final presentation
// boundary as well, so no render path can bypass credential scrubbing. Form
// frames use the stricter boundary that removes the entire userinfo.
frame := p.renderShell(page, width, height)
if state.page == formPage {
return sanitizeStyledText(profile.RedactUserinfoText(frame))
}
return sanitizeStyledText(profile.RedactText(frame))
}
func renderStatus(state presentationState) string {
var lines []string
if state.exitDeferred {
lines = append(lines, "Exit requested — waiting for the current mutation to finish safely")
} else if state.operationStatus.Summary != "" {
lines = append(lines, state.operationStatus.Summary)
} else if state.status != "" {
lines = append(lines, state.status)
}
if state.currentError != nil {
errorText := "Current error: " + state.currentError.status.Summary
if state.errorExpanded && state.currentError.detail != "" {
errorText += "\n" + state.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(profile.RedactText(value))
}
var safeSGRPattern = regexp.MustCompile(`^(?:0|(?:(?:1;)?(?:4;)?38;5;(?:25|34|75|160|172|235|242|245|252)))$`)
// sanitizeStyledText removes terminal-control injection while retaining only
// the small SGR palette emitted by semanticTheme. OSC title/hyperlink commands,
// cursor movement, concealment, carriage returns, and private C1 controls are
// never meaningful application text and must not reach a rendered frame.
func sanitizeStyledText(value string) 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 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 += 2
for i < len(value) {
if value[i] == '\a' {
i++
break
}
if value[i] == '\x1b' && i+1 < len(value) && value[i+1] == '\\' {
i += 2
break
}
i++
}
continue
}
i++
continue
}
switch value[i] {
case '\n':
safe.WriteByte('\n')
case '\t':
safe.WriteString(" ")
default:
if value[i] >= 0x20 && value[i] != 0x7f {
safe.WriteByte(value[i])
}
}
i++
}
var visible strings.Builder
for _, r := range safe.String() {
if (r >= 0x80 && r <= 0x9f) || (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) {
continue
}
visible.WriteRune(r)
}
return visible.String()
}
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)
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))
statusOffset = page.statusOffset
}
status = viewportContent(wrapped, width, statusHeight, statusOffset)
}
headerHeight := lineCount(header)
footerHeight := lineCount(footer)
statusHeight := lineCount(status)
separators := 0
if header != "" {
separators++
}
if footer != "" || status != "" {
separators++
}
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)
}