package tui import ( "fmt" "strings" "github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/lipgloss" "proxyctl/internal/app" "proxyctl/internal/target" ) type dashboardRenderState struct { catalog targetCatalog selectedTargetID string focus dashboardPane detailsOffset int operationProgress map[string]app.TargetProgressPhase } func (p terminalPresentation) renderDashboard(state dashboardRenderState, width, height int) string { if width >= wideWidth && height >= 12 { leftWidth := (width - 1) * 2 / 5 rightWidth := width - leftWidth - 1 left := p.renderTargetListPane(state, leftWidth, height) right := p.renderTargetDetailsPane(state, rightWidth, height) divider := strings.Repeat("│\n", max(0, height-1)) + "│" return lipgloss.JoinHorizontal(lipgloss.Top, left, divider, right) } listHeight := max(5, height/2) detailsHeight := height - listHeight - 1 if detailsHeight < 4 { detailsHeight = 4 listHeight = max(3, height-detailsHeight-1) } return p.renderTargetListPane(state, width, listHeight) + "\n" + p.renderTargetDetailsPane(state, width, detailsHeight) } func (p terminalPresentation) renderTargetListPane(state dashboardRenderState, width, height int) string { heading := paneHeading("Targets", state.focus == targetListPane) bodyHeight := max(1, height-2) lines := make([]string, 0, len(state.catalog.items)) selectedIndex := state.catalog.index(state.selectedTargetID) for _, item := range state.catalog.items { cursor := " " if item.ID == state.selectedTargetID { cursor = "> " } badge := p.targetBadge(item, state.operationProgress[item.ID]) lines = append(lines, cursor+badge+" "+item.Description.Name) } if len(lines) == 0 { lines = append(lines, "No Targets are currently Available.") selectedIndex = 0 } offset := 0 if selectedIndex >= bodyHeight { offset = selectedIndex - bodyHeight + 1 } body := fixedViewport(protectRenderedText(strings.Join(lines, "\n")), width, bodyHeight, offset, false) return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height) } func (p terminalPresentation) renderTargetDetailsPane(state dashboardRenderState, width, height int) string { heading := paneHeading("Target details", state.focus == targetDetailsPane) bodyHeight := max(1, height-2) selected, ok := state.catalog.selected(state.selectedTargetID) content := "Select a Target to review its details." if ok { content = p.targetDetails(selected) } body := fixedViewport(content, width, bodyHeight, state.detailsOffset, true) return fitPane(heading+"\n"+paneRule(width)+"\n"+body, width, height) } func paneHeading(label string, focused bool) string { if focused { return "▶ " + label + " [focused]" } return " " + label } func paneRule(width int) string { return strings.Repeat("─", max(1, width)) } func fitPane(content string, width, height int) string { return lipgloss.NewStyle().Width(width).Height(height).MaxWidth(width).MaxHeight(height).Render(content) } func fixedViewport(content string, width, height, offset int, wrap bool) string { if wrap { content = lipgloss.NewStyle().Width(width).Render(content) } view := viewport.New(width, height) view.SetContent(strings.TrimRight(content, "\n")) view.SetYOffset(max(0, offset)) return view.View() } func (p terminalPresentation) targetBadge(item app.TargetSnapshot, progress app.TargetProgressPhase) string { switch progress { case app.TargetProgressRunning: return p.theme.renderTargetState(item.State, true) case app.TargetProgressFailed: return p.theme.checkFailed.Render("! Failed") } switch item.DetectionPhase { case app.TargetChecking: return p.theme.checking.Render("… Checking") case app.TargetDetectionFailed: return p.theme.checkFailed.Render("! Check failed") } if !item.Available { return p.theme.unavailable.Render("○ Unavailable") } return p.theme.renderTargetState(item.State, false) } func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { state := p.targetBadge(item, 0) evidence := item.Evidence switch item.DetectionPhase { case app.TargetChecking: evidence = "Availability and configuration inspection are in progress." case app.TargetDetectionFailed: if evidence == "" { evidence = item.AvailabilityEvidence } default: if !item.Available { evidence = item.AvailabilityEvidence } } if evidence == "" { evidence = "No detection evidence reported." } var details strings.Builder details.WriteString(p.theme.section.Render(item.Description.Name)) fmt.Fprintf(&details, "\nState: %s\nEvidence: %s", state, evidence) fmt.Fprintf(&details, "\nAction: %s", targetActionLabel(item.PrimaryAction.Kind)) if !item.PrimaryAction.Available && item.PrimaryAction.DisabledReason != "" { fmt.Fprintf(&details, "\nAction unavailable: %s", item.PrimaryAction.DisabledReason) } switch item.PrimaryAction.Kind { case app.ApplyProfileTargetAction: details.WriteString("\nApply Profile means proxyctl will begin managing this configuration while preserving restorable state.") case app.ResolveDriftTargetAction: details.WriteString("\nResolve opens Drift Resolution without choosing a recovery direction for you.") } if item.DetectionPhase == app.TargetDetectionFailed && item.DetectionErr != nil { fmt.Fprintf(&details, "\nCheck error: %s", item.DetectionErr) } facts := []struct { label string value string }{ {"Affects", item.Description.Scope}, {"Permissions", item.Description.Permissions}, {"Ownership & restoration", item.Description.Ownership}, {"Restart risk", item.Description.RestartRisk}, } for _, fact := range facts { if fact.value != "" { fmt.Fprintf(&details, "\n%s: %s", fact.label, fact.value) } } if item.Description.Summary != "" { fmt.Fprintf(&details, "\n\n%s", item.Description.Summary) } if item.Description.Details != "" { fmt.Fprintf(&details, "\n\n%s", item.Description.Details) } return protectRenderedText(details.String()) } func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string { counts := countAvailableTargetStates(snapshot) summary := fmt.Sprintf("%s %d %s %d %s %d %s %d", p.theme.renderTargetState(target.Enabled, false), counts.enabled, p.theme.renderTargetState(target.Disabled, false), counts.disabled, p.theme.renderTargetState(target.External, false), counts.external, p.theme.renderTargetState(target.Drifted, false), counts.drifted) if counts.checking > 0 { summary += fmt.Sprintf(" %s %d", p.theme.checking.Render("… Checking"), counts.checking) } if counts.failed > 0 { summary += fmt.Sprintf(" %s %d", p.theme.checkFailed.Render("! Check failed"), counts.failed) } return summary }