diff --git a/internal/apt/apt.go b/internal/apt/apt.go index 3f0a6d3..d0b1b40 100644 --- a/internal/apt/apt.go +++ b/internal/apt/apt.go @@ -116,9 +116,13 @@ func New(configuration Configuration, files FileSystem, runner Runner) *Adapter func (a *Adapter) ID() string { return "apt" } func (a *Adapter) Description() target.Description { return target.Description{ - Name: "APT", - Summary: "Persistently configures APT proxying across Shell and privilege boundaries.", - Details: "APT supports proxy environment variables; this Target affects persistent APT package operations across Shell and privilege boundaries; it does not change Shell, desktop, Docker, or other application proxy settings. proxyctl owns only an isolated apt.conf.d overlay, leaving earlier configuration underneath so disabling restores any External setting. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges, and no service restart is performed.", + Name: "APT", + Summary: "Persistently configures APT proxying across Shell and privilege boundaries.", + Scope: "APT package operations; not Shell, desktop, Docker, or other applications", + Permissions: "pre-existing authority to write /etc/apt; proxyctl never escalates privileges", + Ownership: "isolated apt.conf.d overlay; disabling reveals any earlier External configuration", + RestartRisk: "none", + Details: "APT supports proxy environment variables; this Target affects persistent APT package operations across Shell and privilege boundaries; it does not change Shell, desktop, Docker, or other application proxy settings. proxyctl owns only an isolated apt.conf.d overlay, leaving earlier configuration underneath so disabling restores any External setting. Writing /etc/apt normally requires pre-existing root authority; proxyctl never escalates privileges, and no service restart is performed.", } } diff --git a/internal/dockerclient/dockerclient.go b/internal/dockerclient/dockerclient.go index 1c386d1..d35a272 100644 --- a/internal/dockerclient/dockerclient.go +++ b/internal/dockerclient/dockerclient.go @@ -170,9 +170,13 @@ func New(configuration Configuration, files FileSystem) *Adapter { func (a *Adapter) ID() string { return "docker-containers" } func (a *Adapter) Description() target.Description { return target.Description{ - Name: "Docker containers/builds", - Summary: "Sets proxy defaults injected into future Docker containers and builds.", - Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers or container networking and is not Docker daemon registry access. proxyctl owns selected proxy keys and recovery metadata while preserving other config.json content and restoring prior External values on disable. User-file write permission is required; it does not contact or restart the daemon.", + Name: "Docker containers/builds", + Summary: "Sets proxy defaults injected into future Docker containers and builds.", + Scope: "future containers and builds; not existing containers, networking, or daemon registry access", + Permissions: "write access to the owning user's Docker client configuration", + Ownership: "managed proxy keys and recovery metadata; other config.json content and prior External values are preserved", + RestartRisk: "none; the Docker daemon is not contacted", + Details: "Affects new containers and builds through the owning user's Docker client configuration; it does not affect existing containers or container networking and is not Docker daemon registry access. proxyctl owns selected proxy keys and recovery metadata while preserving other config.json content and restoring prior External values on disable. User-file write permission is required; it does not contact or restart the daemon.", } } func (a *Adapter) Confirmation(bool) string { return "" } diff --git a/internal/dockerdaemon/dockerdaemon.go b/internal/dockerdaemon/dockerdaemon.go index df8e0fa..4f80620 100644 --- a/internal/dockerdaemon/dockerdaemon.go +++ b/internal/dockerdaemon/dockerdaemon.go @@ -112,9 +112,13 @@ func New(configuration Configuration, files FileSystem, runner CommandRunner, au func (a *Adapter) ID() string { return "docker-daemon" } func (a *Adapter) Description() target.Description { return target.Description{ - Name: "Docker daemon", - Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.", - Details: "Affects Docker daemon network operations such as registry access; it does not change container networking, existing containers, or Docker container/build defaults. proxyctl owns managed proxy keys plus recovery metadata and restores the original daemon.json values on disable. System-file and systemd service permissions are required; every change reloads systemd and immediately restarts Docker, which may disrupt running work. proxyctl reports permission errors and never initiates privilege escalation.", + Name: "Docker daemon", + Summary: "Configures proxying for Docker daemon operations such as registry access, not container network traffic or container/build defaults.", + Scope: "daemon network operations such as registry access; not container networking or container/build defaults", + Permissions: "system-file and systemd service authority; proxyctl never escalates privileges", + Ownership: "managed proxy keys and recovery metadata; disabling restores original daemon.json values", + RestartRisk: "high; every change reloads systemd and immediately restarts Docker", + Details: "Affects Docker daemon network operations such as registry access; it does not change container networking, existing containers, or Docker container/build defaults. proxyctl owns managed proxy keys plus recovery metadata and restores the original daemon.json values on disable. System-file and systemd service permissions are required; every change reloads systemd and immediately restarts Docker, which may disrupt running work. proxyctl reports permission errors and never initiates privilege escalation.", } } func (a *Adapter) Confirmation(bool) string { diff --git a/internal/gnome/gnome.go b/internal/gnome/gnome.go index 6aad705..adb5df8 100644 --- a/internal/gnome/gnome.go +++ b/internal/gnome/gnome.go @@ -276,9 +276,13 @@ func New(configuration Configuration, runner CommandRunner, state StateStore) *A func (a *Adapter) ID() string { return "gnome" } func (a *Adapter) Description() target.Description { return target.Description{ - Name: "GNOME Desktop", - Summary: "Configures the GNOME desktop system proxy.", - Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings, and it does not affect Shell commands or system services. proxyctl records the complete original desktop proxy values before temporarily owning the managed settings and restores them on disable. The settings belong to the desktop user and require that user's usable session bus and schema permissions; changes take effect immediately without a service restart.", + Name: "GNOME Desktop", + Summary: "Configures the GNOME desktop system proxy.", + Scope: "applications that consume GNOME system network settings; not Shell commands or system services", + Permissions: "the desktop user's usable session bus and schema permissions", + Ownership: "complete original desktop values are recorded and restored when management is disabled", + RestartRisk: "none; changes take effect immediately", + Details: "Affects applications that consume GNOME system network settings; not every graphical application consumes these settings, and it does not affect Shell commands or system services. proxyctl records the complete original desktop proxy values before temporarily owning the managed settings and restores them on disable. The settings belong to the desktop user and require that user's usable session bus and schema permissions; changes take effect immediately without a service restart.", } } diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 9bdaeab..c276a6a 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -173,7 +173,15 @@ func New(configuration Configuration, files FileSystem, environment Environment) func (a *Adapter) ID() string { return "shell" } func (a *Adapter) Description() target.Description { - return target.Description{Name: "Shell", Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.", Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell."} + return target.Description{ + Name: "Shell", + Summary: "Exports proxy variables for commands started by integrated Bash and Zsh sessions.", + Scope: "future commands in the integrated Shell; not services, desktop settings, or existing processes", + Permissions: "write access to the user's Shell files", + Ownership: "generated environment fragment; the one-time startup hook remains when management is disabled", + RestartRisk: "none; the installed wrapper reloads exports into the parent Shell after proxyctl exits", + Details: "Affects commands started by the integrated Shell; it does not configure system services, desktop settings, or already-running processes. proxyctl owns a generated environment fragment while its one-time startup hook remains in the user's Shell file. User-file write permission is required; after proxyctl exits, the installed wrapper reloads exports and unsets into the parent Shell.", + } } func (a *Adapter) Availability(context.Context) target.Availability { switch filepath.Base(a.configuration.Shell) { diff --git a/internal/target/target.go b/internal/target/target.go index d87d0bd..eb13281 100644 --- a/internal/target/target.go +++ b/internal/target/target.go @@ -17,10 +17,17 @@ const ( Drifted State = "Drifted" ) +// Description is display metadata for a Target. The structured operational +// facts let presentation layers establish a stable hierarchy without parsing +// prose; they do not participate in configuration or restoration behavior. type Description struct { - Name string - Summary string - Details string + Name string + Summary string + Scope string + Permissions string + Ownership string + RestartRisk string + Details string } type Availability struct { diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go new file mode 100644 index 0000000..3574fac --- /dev/null +++ b/internal/tui/dashboard.go @@ -0,0 +1,107 @@ +package tui + +import ( + "proxyctl/internal/app" + "proxyctl/internal/target" +) + +type dashboardPane uint8 + +const ( + targetListPane dashboardPane = iota + targetDetailsPane +) + +// targetCatalog owns the dashboard's product-ordered Target projection. It is +// the only place where availability filtering and identity-based selection are +// combined, so rendering and input handling cannot accidentally select by a +// transient row number. +type targetCatalog struct { + items []app.TargetSnapshot +} + +func newTargetCatalog(snapshot app.Snapshot, includeUnavailable bool) targetCatalog { + items := make([]app.TargetSnapshot, 0, len(snapshot.Targets)) + for _, item := range snapshot.Targets { + if item.Available || includeUnavailable { + items = append(items, item) + } + } + return targetCatalog{items: items} +} + +func (c targetCatalog) firstID() string { + if len(c.items) == 0 { + return "" + } + return c.items[0].ID +} + +func (c targetCatalog) index(id string) int { + for i := range c.items { + if c.items[i].ID == id { + return i + } + } + return -1 +} + +func (c targetCatalog) selected(id string) (app.TargetSnapshot, bool) { + index := c.index(id) + if index < 0 { + return app.TargetSnapshot{}, false + } + return c.items[index], true +} + +func (c targetCatalog) move(id string, delta int) string { + if len(c.items) == 0 { + return "" + } + index := c.index(id) + if index < 0 { + index = 0 + } + index = max(0, min(len(c.items)-1, index+delta)) + return c.items[index].ID +} + +// retain keeps an identity when possible and otherwise chooses the product row +// nearest its former position. This is used after refresh and visibility +// changes; terminal resize never needs to touch selection at all. +func (c targetCatalog) retain(id string, formerIndex int) string { + if c.index(id) >= 0 { + return id + } + if len(c.items) == 0 { + return "" + } + return c.items[max(0, min(len(c.items)-1, formerIndex))].ID +} + +type targetStateCounts struct { + enabled int + disabled int + external int + drifted int +} + +func countAvailableTargetStates(snapshot app.Snapshot) targetStateCounts { + var counts targetStateCounts + for _, item := range snapshot.Targets { + if !item.Available { + continue + } + switch item.State { + case target.Enabled: + counts.enabled++ + case target.Disabled: + counts.disabled++ + case target.External: + counts.external++ + case target.Drifted: + counts.drifted++ + } + } + return counts +} diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go new file mode 100644 index 0000000..3c104cc --- /dev/null +++ b/internal/tui/dashboard_test.go @@ -0,0 +1,109 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + "proxyctl/internal/app" + "proxyctl/internal/target" +) + +func TestCompleteModelWideOperationsConsoleShowsStableAvailableTargetDashboard(t *testing.T) { + module := &recordingModule{snapshot: app.Snapshot{ + ActiveProfile: "office", + Targets: []app.TargetSnapshot{ + {ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Enabled, Evidence: "matches Active Profile"}, + {ID: "apt", Description: target.Description{Name: "APT"}, Available: false, AvailabilityEvidence: "apt-config was not detected"}, + {ID: "docker-client", Description: target.Description{Name: "Docker client"}, Available: true, State: target.External, Evidence: "existing settings"}, + {ID: "gnome", Description: target.Description{Name: "GNOME"}, Available: true, State: target.Disabled, Evidence: "proxy mode is none"}, + {ID: "docker-daemon", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Drifted, Evidence: "managed values differ"}, + }, + }} + + view := resizedModel(t, module, themeModeNoColor, 120, 36).View() + assertUsableFrame(t, view, 120, 36, + "proxyctl", "Active Profile: office", + "● Enabled 1", "○ Disabled 1", "◇ External 1", "! Drifted 1", + "> ● Enabled", "Shell", "State: ● Enabled", "Evidence: matches Active Profile", + ) + if strings.Contains(view, "APT") || strings.Contains(view, "apt-config was not detected") { + t.Fatalf("default dashboard included an unavailable Target:\n%s", view) + } + if !sameLine(view, "Targets", "Target details") { + t.Fatalf("wide dashboard did not compose list and details side by side:\n%s", view) + } + if !(strings.Index(view, "Shell") < strings.Index(view, "Docker client") && strings.Index(view, "Docker client") < strings.Index(view, "GNOME") && strings.Index(view, "GNOME") < strings.Index(view, "Docker daemon")) { + t.Fatalf("available Target product order changed:\n%s", view) + } +} + +func TestCompleteModelDashboardRevealsUnavailableEvidenceAndStructuredDetails(t *testing.T) { + module := &recordingModule{snapshot: app.Snapshot{Targets: []app.TargetSnapshot{ + {ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled, Evidence: "fragment absent"}, + {ID: "apt", Description: target.Description{ + Name: "APT", Summary: "Persistent package proxy.", Details: "Longer APT explanation.", + Scope: "package operations across privilege boundaries", Permissions: "pre-existing root write authority", + Ownership: "isolated overlay; disabling reveals the previous configuration", RestartRisk: "none", + }, Available: false, AvailabilityEvidence: "apt-config was not detected"}, + {ID: "docker", Description: target.Description{Name: "Docker daemon"}, Available: true, State: target.Enabled, Evidence: "managed values match"}, + }}} + model := resizedModel(t, module, themeModeNoColor, 120, 36) + model = updateKey(model, "u") + model = updateKey(model, "down") + + view := model.View() + assertUsableFrame(t, view, 120, 36, + "> ○ Unavailable APT", "State: ○ Unavailable", "Evidence: apt-config was not detected", + "Affects: package operations across privilege boundaries", + "Permissions: pre-existing root write authority", + "Ownership & restoration: isolated overlay; disabling reveals the", "previous configuration", + "Restart risk: none", "Persistent package proxy.", "Longer APT explanation.", + "u: hide unavailable", + ) + if !(strings.Index(view, "Shell") < strings.Index(view, "APT") && strings.Index(view, "APT") < strings.Index(view, "Docker daemon")) { + t.Fatalf("revealing unavailable Targets changed product order:\n%s", view) + } + if strings.Contains(view, "Space: toggle") { + t.Fatalf("unavailable Target exposed a mutation action:\n%s", view) + } +} + +func TestCompleteModelNarrowDashboardFocusAndIndependentScrollingRetainTargetIdentity(t *testing.T) { + targets := make([]app.TargetSnapshot, 28) + for i := range targets { + targets[i] = app.TargetSnapshot{ + ID: fmt.Sprintf("target-%02d", i), Description: target.Description{Name: fmt.Sprintf("Product Target %02d", i)}, + Available: true, State: target.Disabled, Evidence: fmt.Sprintf("evidence-%02d", i), + } + } + targets[22].Description.Summary = "Selected identity summary" + targets[22].Description.Details = strings.Repeat("Scrollable detail line. ", 45) + "END OF TARGET DETAILS" + module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: "office", Targets: targets}} + model := resizedModel(t, module, themeModeNoColor, 80, 24) + for range 22 { + model = updateKey(model, "down") + } + + view := model.View() + assertUsableFrame(t, view, 80, 24, "> ○ Disabled Product Target 22", "Selected identity summary", "▶ Targets [focused]") + + model = updateKey(model, "tab") + for range 70 { + model = updateKey(model, "down") + } + view = model.View() + assertUsableFrame(t, view, 80, 24, + "> ○ Disabled Product Target 22", "▶ Target details [focused]", "END OF", "TARGET DETAILS", + "↑/k ↓/j PgUp/PgDn: scroll", "Tab: focus Targets", + ) + + model = resize(model, 120, 36) + view = model.View() + assertUsableFrame(t, view, 120, 36, "> ○ Disabled Product Target 22", "END OF TARGET DETAILS", "▶ Target details [focused]") + if !sameLine(view, "Targets", "Target details") { + t.Fatalf("resized dashboard did not become side by side:\n%s", view) + } + model = resize(model, 80, 24) + assertUsableFrame(t, model.View(), 80, 24, "> ○ Disabled Product Target 22", "END OF", "TARGET DETAILS", "▶ Target details [focused]") +} diff --git a/internal/tui/dashboard_view.go b/internal/tui/dashboard_view.go new file mode 100644 index 0000000..cd2f080 --- /dev/null +++ b/internal/tui/dashboard_view.go @@ -0,0 +1,159 @@ +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 + applying bool + profileTransition bool + progressTarget string +} + +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 i, item := range state.catalog.items { + cursor := " " + if item.ID == state.selectedTargetID { + cursor = "> " + } + applying := (state.progressTarget != "" && item.ID == state.progressTarget) || + (!state.profileTransition && state.progressTarget == "" && state.applying && item.ID == state.selectedTargetID) + badge := p.targetBadge(item, applying) + lines = append(lines, cursor+badge+" "+item.Description.Name) + _ = i + } + 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(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, applying bool) string { + if !item.Available { + return p.theme.unavailable.Render("○ Unavailable") + } + return p.theme.renderTargetState(item.State, applying) +} + +func (p terminalPresentation) targetDetails(item app.TargetSnapshot) string { + state := p.targetBadge(item, false) + evidence := item.Evidence + 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) + 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 details.String() +} + +func (p terminalPresentation) stateCountSummary(snapshot app.Snapshot) string { + counts := countAvailableTargetStates(snapshot) + return 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) +} diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index a414f49..ec3f29d 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -51,11 +51,11 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) { model := resizedModel(t, module, themeModeNoColor, 120, 36) model = updateKey(model, "down") model = resize(model, 80, 24) - if view := model.View(); !strings.Contains(view, "> [External ] APT") || !strings.Contains(view, "APT summary") { + if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") { t.Fatalf("Target selection did not survive wide-to-narrow resize:\n%s", view) } model = resize(model, 120, 36) - if view := model.View(); !strings.Contains(view, "> [External ] APT") || !strings.Contains(view, "APT summary") { + if view := model.View(); !strings.Contains(view, "> ◇ External APT") || !strings.Contains(view, "APT summary") { t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view) } @@ -193,9 +193,9 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl model = updated.(Model) assertUsableFrame(t, model.View(), 80, 24, - "> [Drifted ] Docker daemon", - "Docker daemon selected summary", - "Docker daemon selected detail.", + "> ! Drifted Docker daemon", + "State: ! Drifted", + "Docker daemon selected evidence.", "Status: Refresh completed; selected Target remains actionable.", "Space: toggle", "p: manage Proxy Profiles", @@ -204,9 +204,9 @@ func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisibl model = resize(model, 120, 36) model = resize(model, 80, 24) assertUsableFrame(t, model.View(), 80, 24, - "> [Drifted ] Docker daemon", - "Docker daemon selected summary", - "Docker daemon selected detail.", + "> ! Drifted Docker daemon", + "State: ! Drifted", + "Docker daemon selected evidence.", "Space: toggle", ) } diff --git a/internal/tui/model.go b/internal/tui/model.go index ddf01b8..2926714 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -38,6 +38,10 @@ type Model struct { width int height int selected int + selectedTargetID string + dashboardFocus dashboardPane + showUnavailable bool + detailsOffset int form formState confirm bool targetConfirm *app.Intent @@ -91,13 +95,15 @@ func NewModel(application app.Module, configure ...ModelOption) Model { for _, option := range configure { option(&options) } + snapshot := application.Snapshot() return Model{ - application: application, - presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)}, - snapshot: application.Snapshot(), - width: 80, - height: 24, - detect: probe.Detect, + application: application, + presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)}, + snapshot: snapshot, + selectedTargetID: newTargetCatalog(snapshot, false).firstID(), + width: 80, + height: 24, + detect: probe.Detect, } } @@ -137,20 +143,15 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.refreshing = false + formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable) + formerIndex := formerCatalog.index(m.selectedTargetID) m.snapshot = completed.result.Snapshot + m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex) if completed.result.Err != nil { m.status = "Error: " + completed.result.Err.Error() } else { m.status = completed.result.Notice } - targetCount := len(m.availableTargets()) - if targetCount == 0 { - m.selected = 0 - } else if m.selected < 0 { - m.selected = 0 - } else if m.selected >= targetCount { - m.selected = targetCount - 1 - } return m, m.deferredQuit() } if progress, ok := message.(profileOperationProgress); ok { @@ -158,6 +159,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.snapshot = progress.result.Snapshot + m.retainTargetSelection() m.progressTarget = "" if len(progress.result.Progress) > 0 { item := progress.result.Progress[len(progress.result.Progress)-1] @@ -194,6 +196,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } m.applying = false m.snapshot = completed.result.Snapshot + m.retainTargetSelection() if completed.result.Confirmation != "" { intent := app.Intent{Kind: app.SetTargetDesired} if m.targetConfirm != nil { @@ -307,7 +310,7 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil } - targets := m.availableTargets() + catalog := newTargetCatalog(m.snapshot, m.showUnavailable) switch key.String() { case "p": if !m.busy() { @@ -328,17 +331,53 @@ func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { return refreshDone{result: m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})} } } + case "tab": + if !m.busy() { + if m.dashboardFocus == targetListPane { + m.dashboardFocus = targetDetailsPane + } else { + m.dashboardFocus = targetListPane + } + } + case "u": + if !m.busy() { + formerIndex := catalog.index(m.selectedTargetID) + m.showUnavailable = !m.showUnavailable + m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable).retain(m.selectedTargetID, formerIndex) + m.detailsOffset = 0 + } case "up", "k": - if !m.busy() && m.selected > 0 { - m.selected-- + if !m.busy() { + if m.dashboardFocus == targetDetailsPane { + m.detailsOffset = max(0, m.detailsOffset-1) + } else { + m.selectedTargetID = catalog.move(m.selectedTargetID, -1) + m.detailsOffset = 0 + } } case "down", "j": - if !m.busy() && m.selected+1 < len(targets) { - m.selected++ + if !m.busy() { + if m.dashboardFocus == targetDetailsPane { + m.detailsOffset++ + } else { + m.selectedTargetID = catalog.move(m.selectedTargetID, 1) + m.detailsOffset = 0 + } + } + case "pgup": + if !m.busy() && m.dashboardFocus == targetDetailsPane { + m.detailsOffset = max(0, m.detailsOffset-5) + } + case "pgdown": + if !m.busy() && m.dashboardFocus == targetDetailsPane { + m.detailsOffset += 5 } case " ": - if !m.busy() && m.selected >= 0 && m.selected < len(targets) { - selected := targets[m.selected] + if !m.busy() { + selected, ok := catalog.selected(m.selectedTargetID) + if !ok || !selected.Available { + break + } desired := selected.State == target.Disabled || selected.State == target.External m.applying = true m.status = "" @@ -571,6 +610,10 @@ func (m Model) View() string { width: m.width, height: m.height, selected: m.selected, + selectedTargetID: m.selectedTargetID, + dashboardFocus: m.dashboardFocus, + showUnavailable: m.showUnavailable, + detailsOffset: m.detailsOffset, form: m.form, confirm: m.confirm, targetConfirm: m.targetConfirm != nil, @@ -586,6 +629,11 @@ func (m Model) availableTargets() []app.TargetSnapshot { return availableTargets(m.snapshot) } +func (m *Model) retainTargetSelection() { + catalog := newTargetCatalog(m.snapshot, m.showUnavailable) + m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID)) +} + func (f formState) labels() []string { if f.profile.Mode == profile.AdvancedMode { return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"} diff --git a/internal/tui/pages.go b/internal/tui/pages.go index d3f6f29..04fab60 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -4,70 +4,54 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" - "proxyctl/internal/profile" ) func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage { - var header strings.Builder - header.WriteString(p.theme.title.Render("proxyctl — Target panel")) - header.WriteString("\n\n") - if model.snapshot.ActiveProfile == "" { - header.WriteString(p.theme.warning.Render("No Active Profile")) - header.WriteString("\nCreate and activate a Proxy Profile before enabling Targets.") - } else { - fmt.Fprintf(&header, "Active Profile: %s", model.snapshot.ActiveProfile) + activeProfile := model.snapshot.ActiveProfile + if activeProfile == "" { + activeProfile = "No Active Profile" + } + header := fmt.Sprintf("%s Operations Console\nActive Profile: %s Targets: %s", + p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot)) + if width < wideWidth { + header = fmt.Sprintf("%s Operations Console\nActive Profile: %s\nTargets: %s", + p.theme.title.Render("proxyctl"), activeProfile, p.stateCountSummary(model.snapshot)) } - list, details, focusLine := p.targetRegions(model) - page := terminalPage{ - header: header.String(), - primary: list, - context: details, - footer: "Space: toggle ↑/k ↓/j: select p: manage Proxy Profiles\nr: refresh ?: help q/Esc: quit", - focusLine: focusLine, - } - if width >= wideWidth && height >= wideHeight { - leftWidth := (width - 2) * 2 / 5 - rightWidth := width - leftWidth - 2 - left := lipgloss.NewStyle().Width(leftWidth).Render(list) - right := lipgloss.NewStyle().Width(rightWidth).Render(details) - page.primary = lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) - page.context = "" - } + catalog := newTargetCatalog(model.snapshot, model.showUnavailable) + footer := p.dashboardFooter(model, catalog) if model.targetConfirm && !model.applying { - page.footer = "y: confirm n/Esc: cancel" + footer = "y: confirm n/Esc: cancel" + } + return terminalPage{ + header: header, + footer: footer, + dashboard: &dashboardRenderState{ + catalog: catalog, + selectedTargetID: model.selectedTargetID, + focus: model.dashboardFocus, + detailsOffset: model.detailsOffset, + applying: model.applying, + profileTransition: model.profileTransition, + progressTarget: model.progressTarget, + }, } - return page } -func (p terminalPresentation) targetRegions(model presentationState) (listView, detailsView string, focusLine int) { - targets := availableTargets(model.snapshot) - var list strings.Builder - list.WriteString(p.theme.selected.Render("Targets")) - if len(targets) == 0 { - list.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.") +func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string { + visibility := "u: show unavailable" + if model.showUnavailable { + visibility = "u: hide unavailable" } - for i, item := range targets { - cursor := " " - if i == model.selected { - cursor = "> " - } - applying := (model.progressTarget != "" && item.ID == model.progressTarget) || - (!model.profileTransition && model.progressTarget == "" && model.applying && i == model.selected) - fmt.Fprintf(&list, "\n%s[%s] %s", cursor, p.theme.renderTargetState(item.State, applying), item.Description.Name) + if model.dashboardFocus == targetDetailsPane { + return "↑/k ↓/j PgUp/PgDn: scroll Tab: focus Targets " + visibility + "\np: manage Proxy Profiles ?: help q/Esc: quit" } - - var details strings.Builder - details.WriteString(p.theme.section.Render("Target details")) - if model.selected >= 0 && model.selected < len(targets) { - selected := targets[model.selected] - fmt.Fprintf(&details, "\n%s\n%s\n%s", selected.Description.Summary, selected.Description.Details, selected.Evidence) - } else { - details.WriteString("\nSelect an Available Target to review its details.") + actions := "↑/k ↓/j: select Tab: focus details " + visibility + if selected, ok := catalog.selected(model.selectedTargetID); ok && selected.Available { + actions = "Space: toggle " + actions } - return list.String(), details.String(), model.selected + 1 + return actions + "\np: manage Proxy Profiles r: refresh ?: help q/Esc: quit" } func (p terminalPresentation) helpView(model presentationState) terminalPage { diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index c3582a4..f6ba6fd 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -58,6 +58,10 @@ type presentationState struct { width int height int selected int + selectedTargetID string + dashboardFocus dashboardPane + showUnavailable bool + detailsOffset int form formState confirm bool targetConfirm bool @@ -83,6 +87,7 @@ type terminalPage struct { footer string status string focusLine int + dashboard *dashboardRenderState } func (p terminalPresentation) Render(state presentationState) string { @@ -112,7 +117,10 @@ func (p terminalPresentation) Render(state presentationState) string { } page.status = state.status page = redactTerminalPage(page) - return p.renderShell(page, width, height) + // 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. + return profile.RedactText(p.renderShell(page, width, height)) } func redactTerminalPage(page terminalPage) terminalPage { @@ -149,6 +157,9 @@ func (p terminalPresentation) renderShell(page terminalPage, width, height int) 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 != "" { diff --git a/internal/tui/target_test.go b/internal/tui/target_test.go index b1dfc10..526b8a5 100644 --- a/internal/tui/target_test.go +++ b/internal/tui/target_test.go @@ -94,7 +94,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) { }}} model := NewModel(module) view := model.View() - if strings.Contains(view, "APT") || !strings.Contains(view, "affects future Shell commands") || !strings.Contains(view, "integration installed") { + if strings.Contains(view, "APT") || !strings.Contains(view, "summary") || !strings.Contains(view, "integration installed") { t.Fatalf("main Target panel:\n%s", view) } model = updateKey(model, "?") @@ -103,7 +103,7 @@ func TestHelpListsEveryTargetAndMainListsOnlyAvailableTargets(t *testing.T) { t.Fatalf("Target help:\n%s", view) } model = updateKey(model, "esc") - if !strings.Contains(model.View(), "Target panel") { + if !strings.Contains(model.View(), "Operations Console") { t.Fatalf("Escape did not return from help:\n%s", model.View()) } } @@ -423,13 +423,13 @@ func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) { refreshed := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "one", Description: target.Description{Name: "One"}, Available: true}}} module := &targetModule{snapshot: initial, result: app.Result{Snapshot: refreshed}} model := NewModel(module) - model.selected = 3 + model.selectedTargetID = "four" updated, command := model.Update(keyMessage("r")) model = updated.(Model) updated, _ = model.Update(command()) model = updated.(Model) - if model.selected != 0 || !strings.Contains(model.View(), "> [ ] One") { - t.Fatalf("selection after multi-Target reduction = %d\n%s", model.selected, model.View()) + if model.selectedTargetID != "one" || !strings.Contains(model.View(), "> ? Unknown One") { + t.Fatalf("selection after multi-Target reduction = %q\n%s", model.selectedTargetID, model.View()) } module.result = app.Result{Snapshot: app.Snapshot{}} @@ -437,8 +437,8 @@ func TestRefreshClampsSelectionAfterMultipleTargetsDisappear(t *testing.T) { model = updated.(Model) updated, _ = model.Update(command()) model = updated.(Model) - if model.selected != 0 { - t.Fatalf("selection with no Available Targets = %d, want 0", model.selected) + if model.selectedTargetID != "" { + t.Fatalf("selection with no Available Targets = %q, want empty", model.selectedTargetID) } } @@ -458,12 +458,12 @@ func TestTargetPanelNavigationKeysAndQuitCombinations(t *testing.T) { model := NewModel(module) updated, _ := model.Update(down) model = updated.(Model) - if !strings.Contains(model.View(), "> [ ] Second") { + if !strings.Contains(model.View(), "> ? Unknown Second") { t.Fatalf("down key %q did not select second Target:\n%s", down.String(), model.View()) } for _, up := range []tea.KeyMsg{{Type: tea.KeyUp}, {Type: tea.KeyRunes, Runes: []rune{'k'}}} { updated, _ = model.Update(up) - if !strings.Contains(updated.(Model).View(), "> [ ] First") { + if !strings.Contains(updated.(Model).View(), "> ? Unknown First") { t.Fatalf("up key %q did not select first Target:\n%s", up.String(), updated.(Model).View()) } } @@ -484,12 +484,12 @@ func TestQAndEscapeReturnFromNestedPages(t *testing.T) { for _, key := range []tea.KeyMsg{{Type: tea.KeyRunes, Runes: []rune{'q'}}, {Type: tea.KeyEsc}} { model := updateKey(NewModel(module), "?") updated, command := model.Update(key) - if command != nil || !strings.Contains(updated.(Model).View(), "Target panel") { - t.Fatalf("help key %q did not return to Target panel", key.String()) + if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") { + t.Fatalf("help key %q did not return to Operations Console", key.String()) } model = updateKey(NewModel(module), "p") updated, command = model.Update(key) - if command != nil || !strings.Contains(updated.(Model).View(), "Target panel") { + if command != nil || !strings.Contains(updated.(Model).View(), "Operations Console") { t.Fatalf("Profile key %q did not return to Target panel", key.String()) } } diff --git a/internal/tui/theme.go b/internal/tui/theme.go index 9793300..80c54d4 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -11,16 +11,17 @@ import ( ) type semanticTheme struct { - title lipgloss.Style - section lipgloss.Style - selected lipgloss.Style - status lipgloss.Style - warning lipgloss.Style - enabled lipgloss.Style - disabled lipgloss.Style - external lipgloss.Style - drifted lipgloss.Style - applying lipgloss.Style + title lipgloss.Style + section lipgloss.Style + selected lipgloss.Style + status lipgloss.Style + warning lipgloss.Style + enabled lipgloss.Style + disabled lipgloss.Style + external lipgloss.Style + drifted lipgloss.Style + unavailable lipgloss.Style + applying lipgloss.Style } func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme { @@ -56,38 +57,50 @@ func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme { } style := renderer.NewStyle return semanticTheme{ - title: style().Bold(true).Foreground(lipgloss.Color(accent)), - section: style().Bold(true).Foreground(lipgloss.Color(foreground)), - selected: style().Bold(true).Underline(true).Foreground(lipgloss.Color(accent)), - status: style().Foreground(lipgloss.Color(foreground)), - warning: style().Bold(true).Foreground(lipgloss.Color("172")), - enabled: style().Foreground(lipgloss.Color("34")), - disabled: style().Foreground(lipgloss.Color(subtle)), - external: style().Foreground(lipgloss.Color("172")), - drifted: style().Bold(true).Foreground(lipgloss.Color("160")), - applying: style().Foreground(lipgloss.Color(accent)), + title: style().Bold(true).Foreground(lipgloss.Color(accent)), + section: style().Bold(true).Foreground(lipgloss.Color(foreground)), + selected: style().Bold(true).Underline(true).Foreground(lipgloss.Color(accent)), + status: style().Foreground(lipgloss.Color(foreground)), + warning: style().Bold(true).Foreground(lipgloss.Color("172")), + enabled: style().Foreground(lipgloss.Color("34")), + disabled: style().Foreground(lipgloss.Color(subtle)), + external: style().Foreground(lipgloss.Color("172")), + drifted: style().Bold(true).Foreground(lipgloss.Color("160")), + unavailable: style().Foreground(lipgloss.Color(subtle)), + applying: style().Foreground(lipgloss.Color(accent)), } } func (t semanticTheme) renderTargetState(state target.State, applying bool) string { if applying { - return t.applying.Render("Applying…") + return t.applying.Render("… Applying") } - label := string(state) + label := targetStateLabel(state) switch state { case target.Enabled: - return t.enabled.Render(paddedState(label)) + return t.enabled.Render(label) case target.Disabled: - return t.disabled.Render(paddedState(label)) + return t.disabled.Render(label) case target.External: - return t.external.Render(paddedState(label)) + return t.external.Render(label) case target.Drifted: - return t.drifted.Render(paddedState(label)) + return t.drifted.Render(label) default: - return paddedState(label) + return label } } -func paddedState(label string) string { - return lipgloss.NewStyle().Width(9).Render(label) +func targetStateLabel(state target.State) string { + switch state { + case target.Enabled: + return "● Enabled" + case target.Disabled: + return "○ Disabled" + case target.External: + return "◇ External" + case target.Drifted: + return "! Drifted" + default: + return "? Unknown" + } }