feat: fix proxy compatibility and establish TUI foundation
This commit is contained in:
@@ -11,9 +11,10 @@
|
|||||||
|
|
||||||
3. **Bare host:port default**. A typed endpoint without a scheme is treated as `http://` for parsing and dialing, matching the curl convention used elsewhere in the app.
|
3. **Bare host:port default**. A typed endpoint without a scheme is treated as `http://` for parsing and dialing, matching the curl convention used elsewhere in the app.
|
||||||
|
|
||||||
4. **Mapping table**. After detection, the Single proxy URL field is rewritten as follows:
|
4. **Mapping table**. After detection, the Profile is updated as follows:
|
||||||
- HTTP detected (alone or with SOCKS5): `http://<host:port>`
|
- HTTP only: the Single proxy URL becomes `http://<host:port>`.
|
||||||
- SOCKS5 only: `socks5h://<host:port>`
|
- HTTP and SOCKS5: the form switches to advanced mode; HTTP and HTTPS become `http://<host:port>`, while ALL/SOCKS becomes `socks5://<host:port>`. This preserves both detected capabilities and creates a Profile representable by GNOME as well as HTTP-only Targets.
|
||||||
|
- SOCKS5 only: the Single proxy URL becomes `socks5h://<host:port>`.
|
||||||
- Unrecognized protocol: status message only; the field is left untouched.
|
- Unrecognized protocol: status message only; the field is left untouched.
|
||||||
- Dial failure: status message only; the field is left untouched.
|
- Dial failure: status message only; the field is left untouched.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 01 — Build the responsive TUI foundation
|
||||||
|
|
||||||
|
**What to build:** Replace the current unbounded terminal document with a responsive, styled application shell that preserves the existing reachable workflows while establishing the layout, theme, redaction, and page seams required by the redesign.
|
||||||
|
|
||||||
|
**Blocked by:** None — can start immediately.
|
||||||
|
|
||||||
|
**Status:** done
|
||||||
|
|
||||||
|
- [x] Bubble Tea remains the runtime, with Bubbles and Lip Gloss available as direct production dependencies.
|
||||||
|
- [x] Production runs in the terminal alternate screen and restores the previous screen on normal exit.
|
||||||
|
- [x] The complete TUI Model handles terminal resize messages and renders a usable wide layout at 120×36.
|
||||||
|
- [x] The same Model renders a usable single-column layout at 80×24.
|
||||||
|
- [x] Terminals below 60×16 render a concise minimum-size message instead of a corrupted interface.
|
||||||
|
- [x] Selection and focus survive resize whenever their referenced item still exists.
|
||||||
|
- [x] Semantic styling supports light, dark, and `NO_COLOR` rendering without making color the only signal.
|
||||||
|
- [x] All text entering the presentation layer crosses one defensive credential-redaction path.
|
||||||
|
- [x] Application coordination, responsive layout, semantic theme, pages, and reusable rendering no longer accumulate in one monolithic implementation.
|
||||||
|
- [x] Existing Target and Proxy Profile workflows remain reachable; no long-lived legacy/new TUI switch is introduced.
|
||||||
|
- [x] Complete-Model tests cover the three agreed terminal-size classes.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 02 — Deliver the Operations Console dashboard
|
||||||
|
|
||||||
|
**What to build:** Give users the accepted Operations Console experience: a stable, scannable Target list and concise selected-Target details, side by side on wide terminals and stacked on narrow terminals.
|
||||||
|
|
||||||
|
**Blocked by:** 01 — Build the responsive TUI foundation.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] The header shows a compact proxyctl identity, the Active Profile, and counts for the current Target states.
|
||||||
|
- [ ] Available Targets appear in stable product order by default.
|
||||||
|
- [ ] A contextual action reveals unavailable Targets and their detection evidence without changing product order.
|
||||||
|
- [ ] Each Target row combines a standard Unicode/ASCII-safe symbol, explicit state text, and semantic styling.
|
||||||
|
- [ ] The selected Target details prioritize state, evidence, affected scope, permissions, ownership/restoration implications, and restart risk.
|
||||||
|
- [ ] Wide terminals show Target list and details panes side by side; narrow terminals stack the same regions.
|
||||||
|
- [ ] Tab changes pane focus, the focused pane is visible without relying only on color, and arrows navigate within the focused pane.
|
||||||
|
- [ ] Long Target lists and details remain reachable through list/viewport scrolling.
|
||||||
|
- [ ] The footer shows only actions valid for the current focus and state.
|
||||||
|
- [ ] Resizing preserves selection by Target identity rather than fragile row position.
|
||||||
|
- [ ] Complete-Model tests cover every Target state, unavailable visibility, long content, focus, scrolling, and wide/narrow rendering.
|
||||||
|
- [ ] Production rendering is rewritten from the spec; prototype branch `prototype/tui-operations-console` commit `364f28d` is reference material only and is not copied into production.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 03 — Stream progressive startup and refresh
|
||||||
|
|
||||||
|
**What to build:** Show the dashboard immediately and update each Target independently as detection completes, both at startup and when the user refreshes.
|
||||||
|
|
||||||
|
**Blocked by:** 02 — Deliver the Operations Console dashboard.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Interactive application construction exposes Target identity and descriptions before availability and inspection complete.
|
||||||
|
- [ ] Startup renders every known Target in a structured Checking state without waiting for aggregate detection.
|
||||||
|
- [ ] Availability and inspection results stream to the TUI one Target at a time.
|
||||||
|
- [ ] Refresh uses the same progressive behavior rather than a separate aggregate-only path.
|
||||||
|
- [ ] Checking rows display a non-color-only progress cue and update independently on success or failure.
|
||||||
|
- [ ] Independent completion order does not reorder Targets or move selection to a different identity.
|
||||||
|
- [ ] A Target detection failure is represented truthfully without preventing unrelated Target results from appearing.
|
||||||
|
- [ ] The progressive Application Interface contains presentation-independent phases and results rather than Bubble Tea types or strings that callers must parse.
|
||||||
|
- [ ] Existing non-interactive maintenance behavior remains compatible.
|
||||||
|
- [ ] Application tests cover independent completion order, mixed success/failure, and final snapshots.
|
||||||
|
- [ ] Complete-Model tests prove the first useful frame precedes detection completion and each subsequent result updates only the relevant row.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 04 — Unify operation coordination and status feedback
|
||||||
|
|
||||||
|
**What to build:** Give every interactive operation one coherent model for progress, serialization, confirmations, current status, partial failure, and safe exit while keeping the dashboard browsable.
|
||||||
|
|
||||||
|
**Blocked by:** 03 — Stream progressive startup and refresh.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Detection, Target mutation, and Active Profile transition use one presentation-independent progressive operation contract where their semantics overlap.
|
||||||
|
- [ ] System mutations remain serialized and a conflicting action exposes an actionable disabled reason instead of silently ignoring input.
|
||||||
|
- [ ] During a mutation, users can still select Targets, scroll details, and open existing read-only Help.
|
||||||
|
- [ ] The affected Target row displays progress while the status area describes the current step.
|
||||||
|
- [ ] Successful status is replaceable by later activity.
|
||||||
|
- [ ] Current errors remain visible until acknowledged, superseded by explicit resolution, or replaced by a directly related result.
|
||||||
|
- [ ] Current error details can be expanded without introducing an activity-history page.
|
||||||
|
- [ ] Partial Active Profile transitions retain successful Target updates, preserve the no-cross-Target-rollback rule, and focus the first failed Target.
|
||||||
|
- [ ] Confirmation state, progress, and final result are structured; the TUI does not parse user-facing strings to infer operation state.
|
||||||
|
- [ ] `q` on the dashboard and Ctrl+C on any page request exit; during a mutation the UI visibly reports deferred exit and exits only after safe completion.
|
||||||
|
- [ ] No new work can begin after deferred exit is requested.
|
||||||
|
- [ ] The concrete Application remains usable by existing synchronous callers while obsolete interactive fallback paths are removed after migration.
|
||||||
|
- [ ] Application and complete-Model tests cover browsing while busy, blocked mutations, persistent errors, partial failure, confirmation, and deferred exit.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 05 — Add contextual Target actions and Drift Resolution
|
||||||
|
|
||||||
|
**What to build:** Replace the ambiguous Target toggle with actions that reflect the selected Target's real state, including explicit Drift Resolution choices.
|
||||||
|
|
||||||
|
**Blocked by:** 04 — Unify operation coordination and status feedback.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] A Disabled Target offers Enable.
|
||||||
|
- [ ] An Enabled Target offers Disable.
|
||||||
|
- [ ] An External Target offers Apply Profile and explains that proxyctl will begin managing the configuration while preserving restorable state.
|
||||||
|
- [ ] A Drifted Target offers Resolve rather than silently choosing a direction.
|
||||||
|
- [ ] Drift Resolution offers Reapply Active Profile and Restore Original as separate intents.
|
||||||
|
- [ ] Reapply uses the Active Profile and returns truthful Enabled, Drifted, or failure state.
|
||||||
|
- [ ] Restore Original reuses existing ownership safeguards and refuses safely when restoration evidence is insufficient.
|
||||||
|
- [ ] No Adapter ownership, restoration, or cross-Target rollback semantics are weakened.
|
||||||
|
- [ ] Docker restart confirmation remains required for relevant actions and cancellation is the default focused choice.
|
||||||
|
- [ ] Routine reversible actions do not gain new confirmation dialogs.
|
||||||
|
- [ ] Action availability and disabled reasons are structured in the Application snapshot rather than inferred from rendered labels.
|
||||||
|
- [ ] Application and complete-Model tests cover all Target states, both Drift Resolution branches, missing Active Profile, ownership loss, confirmation, and cancellation without mutation.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 06 — Add the searchable Active Profile picker
|
||||||
|
|
||||||
|
**What to build:** Let users search for and activate a Proxy Profile directly from the dashboard without mixing routine switching with Profile management.
|
||||||
|
|
||||||
|
**Blocked by:** 02 — Deliver the Operations Console dashboard; 04 — Unify operation coordination and status feedback.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] A dashboard action opens a keyboard-only modal Profile picker.
|
||||||
|
- [ ] The Active Profile is pinned first and all remaining Profiles are sorted by name.
|
||||||
|
- [ ] The picker supports filtering and scrolling across more Profiles than fit in the terminal.
|
||||||
|
- [ ] Enter activates the selected Profile and Escape closes the picker without mutation.
|
||||||
|
- [ ] Activation uses the unified progressive operation contract and displays per-Target progress on the dashboard.
|
||||||
|
- [ ] Docker restart confirmation appears when required, with cancellation as the safe default.
|
||||||
|
- [ ] Partial failure retains successful Target updates, closes or preserves the picker predictably, and focuses the first failed Target.
|
||||||
|
- [ ] Search text and selection survive resize when the selected Profile still exists.
|
||||||
|
- [ ] Empty and no-match states provide clear next actions without conflating switching with creation.
|
||||||
|
- [ ] Profile names and all operation text are defensively redacted before rendering.
|
||||||
|
- [ ] Complete-Model tests cover ordering, search, keyboard selection, resize, activation, confirmation, cancellation, partial failure, and empty states.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 07 — Build the dedicated Profile management page
|
||||||
|
|
||||||
|
**What to build:** Provide a scrollable keyboard-first page for creating, editing, reviewing, and deleting Proxy Profiles while keeping activation in the dashboard picker.
|
||||||
|
|
||||||
|
**Blocked by:** 01 — Build the responsive TUI foundation; 04 — Unify operation coordination and status feedback.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Profile management is a distinct page from the dashboard activation picker.
|
||||||
|
- [ ] The Profile list scrolls and keeps selection visible with more entries than fit on screen.
|
||||||
|
- [ ] The selected Profile's mode, obscured URLs, and Bypass values are reviewable without exposing credentials.
|
||||||
|
- [ ] Create, edit, and delete entry points are available through contextual keyboard actions.
|
||||||
|
- [ ] Deletion uses a reusable confirmation dialog with cancellation focused by default.
|
||||||
|
- [ ] Deleting the final Proxy Profile clearly explains that existing managed configuration remains and becomes Drifted.
|
||||||
|
- [ ] Escape and `q` return to the previous page; Ctrl+C follows the global exit contract.
|
||||||
|
- [ ] Operations disabled by in-flight work expose a reason rather than silently ignoring input.
|
||||||
|
- [ ] Selection and viewport survive resize and CRUD results whenever the referenced Profile still exists.
|
||||||
|
- [ ] Empty-state copy provides a direct creation path and does not incorrectly claim no Active Profile when non-active Profiles exist.
|
||||||
|
- [ ] Complete-Model tests cover long lists, details, create/edit entry, deletion confirmation, final deletion, cancellation, resize, and navigation consistency.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 08 — Deliver the mature Profile editor
|
||||||
|
|
||||||
|
**What to build:** Replace append-only Profile editing with efficient terminal text inputs, precise validation, and a live Bypass interpretation that users can trust before saving.
|
||||||
|
|
||||||
|
**Blocked by:** 07 — Build the dedicated Profile management page.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Name and URL fields support insertion, cursor movement, Home, End, Delete, Backspace, paste, and horizontal scrolling.
|
||||||
|
- [ ] Focus is explicit and remains understandable without color.
|
||||||
|
- [ ] Single and Advanced mode transitions preserve values according to existing Proxy Profile semantics.
|
||||||
|
- [ ] Field-specific validation appears beside the relevant field and updates when input changes.
|
||||||
|
- [ ] Save uses the same canonical Profile validation semantics as the live feedback, preventing contradictory results.
|
||||||
|
- [ ] Bypass accepts efficient delimited input and displays a live normalized, deduplicated preview.
|
||||||
|
- [ ] Invalid Bypass entries are identified individually without hiding valid entries.
|
||||||
|
- [ ] Proxy credentials remain obscured during entry, scrolling, validation, review, errors, and resize.
|
||||||
|
- [ ] Save returns to management with the saved Profile selected; cancel leaves persistence unchanged.
|
||||||
|
- [ ] Long URLs and Bypass content remain usable at 80×24.
|
||||||
|
- [ ] Complete-Model tests cover editing operations, paste, horizontal scrolling, mode transitions, validation lifecycle, Bypass preview, save, cancel, resize, and adversarial secret-bearing input.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 09 — Attach Protocol Probe to the Profile field
|
||||||
|
|
||||||
|
**What to build:** Make Protocol Probe an explicit, user-initiated URL-field action whose progress and result remain attached to the input that produced them.
|
||||||
|
|
||||||
|
**Blocked by:** 08 — Deliver the mature Profile editor.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] The applicable URL field visibly offers Protocol Probe and documents its keyboard shortcut.
|
||||||
|
- [ ] Probe never runs when the form opens, when focus changes, or when a Profile is saved.
|
||||||
|
- [ ] Progress, endpoint-safe status, and detected capabilities render with the initiating field rather than as unrelated global status.
|
||||||
|
- [ ] HTTP-only detection populates the established HTTP representation.
|
||||||
|
- [ ] SOCKS5-only detection preserves the established `socks5h` semantics.
|
||||||
|
- [ ] Dual HTTP/SOCKS5 detection creates the established compatible Advanced Profile values.
|
||||||
|
- [ ] Editing the relevant URL, changing mode, saving, or cancelling invalidates an in-flight result.
|
||||||
|
- [ ] Repeated invocation while the same probe is active does not start duplicate work.
|
||||||
|
- [ ] Userinfo and credentials never appear in dial evidence, status, errors, or rendered frames.
|
||||||
|
- [ ] Failure and unrecognized-protocol results leave user input editable and do not prevent saving a manually chosen value.
|
||||||
|
- [ ] Complete-Model tests cover all capabilities, failure, explicit invocation, no hidden network work, duplicate suppression, resize, and every stale-result path.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 10 — Unify Help, dialogs, and navigation
|
||||||
|
|
||||||
|
**What to build:** Give every page a consistent keyboard navigation and modal model, with scrollable contextual Help that can be consulted without losing current work.
|
||||||
|
|
||||||
|
**Blocked by:** 04 — Unify operation coordination and status feedback; 07 — Build the dedicated Profile management page.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Help opens as a scrollable contextual dialog over the current page rather than an unbounded static document.
|
||||||
|
- [ ] Help describes actions valid for the current page, focus, Target state, and operation state.
|
||||||
|
- [ ] Escape closes the top dialog or returns one page level.
|
||||||
|
- [ ] `q` returns from child pages and exits only from the dashboard.
|
||||||
|
- [ ] Ctrl+C requests exit from every page and follows deferred-exit safety during mutations.
|
||||||
|
- [ ] Closing a dialog restores the prior page, focus, selection, and viewport when still valid.
|
||||||
|
- [ ] Dialog stacking and confirmation precedence are deterministic; an input cannot accidentally act on content behind a dialog.
|
||||||
|
- [ ] Dialogs resize within the agreed terminal bounds and preserve meaningful focus.
|
||||||
|
- [ ] Help can be opened and scrolled during a mutation while conflicting actions remain disabled.
|
||||||
|
- [ ] Footer hints agree with actual accepted keys, including cancel and back behavior.
|
||||||
|
- [ ] Complete-Model tests cover Help from every page, long Help, nested dialog precedence, resize, focus restoration, `q`, Escape, Ctrl+C, and in-flight operation behavior.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 11 — Harden accessibility, privacy, and rendering
|
||||||
|
|
||||||
|
**What to build:** Verify and finish the redesigned TUI across supported terminal sizes, color modes, hostile text, and process lifecycle so that visual polish does not compromise meaning, privacy, or reliability.
|
||||||
|
|
||||||
|
**Blocked by:** 03 — Stream progressive startup and refresh; 04 — Unify operation coordination and status feedback; 05 — Add contextual Target actions and Drift Resolution; 06 — Add the searchable Active Profile picker; 07 — Build the dedicated Profile management page; 08 — Deliver the mature Profile editor; 09 — Attach Protocol Probe to the Profile field; 10 — Unify Help, dialogs, and navigation.
|
||||||
|
|
||||||
|
**Status:** ready-for-agent
|
||||||
|
|
||||||
|
- [ ] Fixed-size render coverage includes 120×36, 80×24, and 59×15 across the completed workflows.
|
||||||
|
- [ ] Light, dark, and `NO_COLOR` renders retain readable state, focus, progress, warnings, disabled actions, and errors.
|
||||||
|
- [ ] Standard Unicode symbols have a safe rendering strategy and no Nerd Font dependency.
|
||||||
|
- [ ] Long Target names, Profile names, evidence, details, URLs, Bypass values, errors, and confirmation copy remain bounded and reachable.
|
||||||
|
- [ ] Secrets injected through valid and malformed Profile fields, evidence, notices, errors, progress, command output, and confirmations never appear in any rendered frame.
|
||||||
|
- [ ] Resize stress preserves valid page, dialog, focus, selection, viewport, in-flight operation, and deferred-exit state.
|
||||||
|
- [ ] Alternate-screen process tests verify normal exit and safe deferred exit restore the prior terminal screen.
|
||||||
|
- [ ] Startup, refresh, Target action, Active Profile switching, management, editor, Probe, Help, and Drift Resolution each have complete-Model behavioral coverage.
|
||||||
|
- [ ] Tests assert semantic behavior rather than private component structure or raw styling calls.
|
||||||
|
- [ ] Existing Application and Adapter suites remain green without duplicating Target configuration tests in the TUI.
|
||||||
|
- [ ] Documentation describes the redesigned keyboard model, responsive size behavior, Profile switching, contextual Target actions, and privacy boundaries.
|
||||||
|
- [ ] Full repository and hermetic verification pass without modifying real Shell, APT, Docker, GNOME, or systemd state.
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
Status: ready-for-agent
|
||||||
|
|
||||||
|
# Efficient professional TUI redesign
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
`proxyctl` is functionally capable, but its current terminal interface is an unbounded plain-text document rather than an efficient operational dashboard. Routine use requires navigating away from the Target panel to switch the Active Profile, long descriptions and errors compete with the Target list, and operations temporarily block useful browsing. The interface does not respond to terminal size, scroll long content, provide mature field editing, or present consistent focus, status, confirmation, and error behavior.
|
||||||
|
|
||||||
|
The visual hierarchy is too weak for users to assess configuration health at a glance. Target states are plain bracketed words, Profile markers are unexplained, errors are styled inconsistently, and busy or deferred-exit states are not always visible. The static Help page and long lists can extend beyond the terminal with no viewport. Profile fields support only appending and backspacing, making correction of long URLs and Bypass rules unnecessarily slow.
|
||||||
|
|
||||||
|
Some actions are also described too generically. In particular, treating every Target action as a toggle hides the meaningful difference between enabling a Disabled Target, applying an Active Profile to an External Target, and performing Drift Resolution on a Drifted Target.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Redesign the TUI as a keyboard-only, responsive, professional dashboard optimized for frequent operation. The default wide layout presents a stable Target list beside concise details for the selected Target, with the Active Profile and global Target health summarized in the header. Narrow terminals automatically use a single-column layout without changing the underlying workflows.
|
||||||
|
|
||||||
|
Use restrained semantic color, standard Unicode symbols, explicit state text, stable focus treatment, contextual actions, scrollable content, structured status feedback, and safe modal dialogs. Color reinforces meaning but is never the sole signal. The interface adapts to light and dark terminals and respects `NO_COLOR`.
|
||||||
|
|
||||||
|
Make Active Profile switching a first-class main-screen action through a searchable picker, while keeping Profile creation, editing, and deletion in a dedicated management experience. Replace the append-only form with mature text inputs, field-level validation, a parsed Bypass preview, and an explicit Protocol Probe action.
|
||||||
|
|
||||||
|
Keep system mutations serial and safe while allowing the user to browse the dashboard during an operation. Preserve the existing no-cross-Target-rollback rule, surface partial outcomes directly on Target rows, and focus the first failed Target. Introduce explicit Drift Resolution choices for reapplying the Active Profile or restoring original configuration when ownership evidence permits.
|
||||||
|
|
||||||
|
Rebuild the presentation layer around Bubble Tea, Bubbles, and Lip Gloss, with separate layout, theme, page, modal, and reusable component responsibilities. Permit focused changes to the Application Interface where required for progressive detection, structured progress, structured errors, and Drift Resolution, while preserving stable Target configuration semantics.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As a frequent proxyctl user, I want the default screen optimized for rapid scanning, so that I can understand system proxy state immediately.
|
||||||
|
2. As a frequent proxyctl user, I want the Active Profile visible in the header, so that I know which Proxy Profile is being applied.
|
||||||
|
3. As a frequent proxyctl user, I want a global count of Enabled, Disabled, External, and Drifted Targets, so that I can identify abnormal state without inspecting every row.
|
||||||
|
4. As a frequent proxyctl user, I want Target ordering to remain stable, so that state changes do not move actions beneath my cursor.
|
||||||
|
5. As a frequent proxyctl user, I want failed Targets focused after a partial operation, so that I can investigate without searching.
|
||||||
|
6. As a keyboard user, I want every workflow to remain fully operable without a mouse, so that proxyctl works consistently over SSH and in minimal terminals.
|
||||||
|
7. As a keyboard user, I want familiar arrow and terminal navigation conventions, so that the interface is quick to learn.
|
||||||
|
8. As a keyboard user, I want Tab to move focus between interactive regions, so that focus changes are explicit.
|
||||||
|
9. As a keyboard user, I want the focused region visibly highlighted, so that I always know where input will apply.
|
||||||
|
10. As a keyboard user, I want contextual shortcuts in a fixed footer, so that the most relevant actions remain discoverable.
|
||||||
|
11. As a keyboard user, I want complete shortcuts available through Help, so that the footer can remain concise.
|
||||||
|
12. As a user who prefers redesigned shortcuts, I want the new interaction model optimized without preserving every legacy key, so that obsolete constraints do not weaken the redesign.
|
||||||
|
13. As a user in a wide terminal, I want a Target list and Target details visible side by side, so that selection and explanation do not compete for vertical space.
|
||||||
|
14. As a user in a narrow terminal, I want the interface to become a usable single-column layout, so that split panes and SSH sessions remain supported.
|
||||||
|
15. As a user in a terminal smaller than the supported minimum, I want a concise size warning rather than corrupted layout, so that recovery is obvious.
|
||||||
|
16. As a user who resizes a terminal, I want the layout to update immediately while preserving selection and focus, so that resizing does not reset my work.
|
||||||
|
17. As a user with long Target details, I want a scrollable details viewport, so that every explanation remains reachable.
|
||||||
|
18. As a user with many Proxy Profiles, I want scrollable lists, so that selection never moves beyond visible content.
|
||||||
|
19. As a user opening Help in a short terminal, I want scrollable Help content, so that no instructions are inaccessible.
|
||||||
|
20. As a user, I want restrained professional styling, so that the interface is polished without becoming visually noisy.
|
||||||
|
21. As a user, I want standard Unicode or ASCII symbols, so that the interface does not depend on Nerd Font installation.
|
||||||
|
22. As a user, I want Target states represented by symbols, color, and words together, so that meaning survives monochrome terminals and screenshots.
|
||||||
|
23. As a user in a light terminal, I want readable contrast, so that styling designed for dark backgrounds does not disappear.
|
||||||
|
24. As a user in a dark terminal, I want readable contrast, so that the dashboard remains comfortable for long sessions.
|
||||||
|
25. As a user who sets `NO_COLOR`, I want all meaning retained without color, so that accessibility and terminal preferences are respected.
|
||||||
|
26. As a user, I want a small recognizable proxyctl title, so that the interface has identity without wasting screen space on a large logo.
|
||||||
|
27. As a user, I want only Available Targets shown during routine operation, so that absent integrations do not create clutter.
|
||||||
|
28. As a troubleshooting user, I want to toggle visibility of unavailable Targets, so that detection evidence is available without leaving the dashboard.
|
||||||
|
29. As a user selecting a Target, I want its state and evidence shown before long documentation, so that the most actionable information appears first.
|
||||||
|
30. As a user selecting a Target, I want to see what it affects, so that I understand the configuration surface.
|
||||||
|
31. As a user selecting a Target, I want permission and restart risks highlighted, so that operational consequences are not buried in prose.
|
||||||
|
32. As a user, I want Disabled Targets to offer an Enable action, so that the operation is unambiguous.
|
||||||
|
33. As a user, I want Enabled Targets to offer a Disable action, so that the operation is unambiguous.
|
||||||
|
34. As a user, I want External Targets to offer Apply Profile, so that I understand proxyctl will begin managing that configuration while preserving restorable state.
|
||||||
|
35. As a user, I want Drifted Targets to offer Drift Resolution, so that the interface does not guess my intended recovery.
|
||||||
|
36. As a user resolving drift, I want to reapply the Active Profile, so that managed configuration can return to Enabled.
|
||||||
|
37. As a user resolving drift, I want to restore original configuration when ownership evidence permits, so that I can stop proxyctl management safely.
|
||||||
|
38. As a user resolving drift without sufficient ownership evidence, I want restoration to fail truthfully and safely, so that external changes are not overwritten.
|
||||||
|
39. As a user performing a routine reversible Target action, I do not want a confirmation dialog, so that frequent operation remains fast.
|
||||||
|
40. As a user deleting a Proxy Profile, I want a confirmation dialog, so that destructive actions are deliberate.
|
||||||
|
41. As a user whose action will restart Docker, I want a high-impact confirmation dialog, so that running work is not disrupted unexpectedly.
|
||||||
|
42. As a user in a confirmation dialog, I want cancellation to be the safe default, so that accidental confirmation is unlikely.
|
||||||
|
43. As a user, I want to switch the Active Profile directly from the dashboard, so that routine switching does not require entering management screens.
|
||||||
|
44. As a user switching Profiles, I want a searchable picker, so that selection remains fast with many Profiles.
|
||||||
|
45. As a user opening the Profile picker, I want the Active Profile pinned first and other Profiles sorted by name, so that ordering is stable and predictable.
|
||||||
|
46. As a user, I want Profile switching and Profile management to remain distinct, so that routine activation is not mixed with destructive editing actions.
|
||||||
|
47. As a user, I want to create, edit, and delete Proxy Profiles in a dedicated management page, so that those workflows have enough space and context.
|
||||||
|
48. As a user editing a Proxy Profile, I want a real text cursor and insertion editing, so that I can correct text without deleting from the end.
|
||||||
|
49. As a user editing a Proxy Profile, I want Home, End, Delete, Backspace, and paste behavior, so that terminal text editing is efficient.
|
||||||
|
50. As a user editing a long URL, I want horizontal scrolling with visible focus, so that the field remains usable in narrow layouts.
|
||||||
|
51. As a user entering invalid data, I want validation beside the relevant field, so that I know exactly what to fix.
|
||||||
|
52. As a user correcting a field, I want stale field errors to clear or update, so that the form reflects current input.
|
||||||
|
53. As a user switching between Single and Advanced Profile modes, I want existing values preserved according to current Profile semantics, so that mode changes do not discard work.
|
||||||
|
54. As a user entering Bypass rules, I want to paste a comma-separated list, so that common configuration remains fast.
|
||||||
|
55. As a user entering Bypass rules, I want a live normalized preview, so that deduplication and canonical forms are visible before saving.
|
||||||
|
56. As a user entering an invalid Bypass rule, I want the specific rule identified, so that one bad entry does not produce an opaque form error.
|
||||||
|
57. As a user entering proxy credentials, I want passwords obscured while editing and reviewing Profiles, so that shoulder surfing and screenshots do not expose secrets.
|
||||||
|
58. As a security-conscious user, I want all TUI status, evidence, and error text defensively redacted before rendering, so that a lower layer cannot accidentally expose credentials.
|
||||||
|
59. As a user with an uncertain endpoint, I want an explicit Protocol Probe action beside the applicable URL field, so that discovery is visible rather than hidden behind an unexplained shortcut.
|
||||||
|
60. As a user who knows the Protocol Probe shortcut, I want the shortcut retained as an efficient path, so that frequent use remains quick.
|
||||||
|
61. As a privacy-conscious user, I want Protocol Probe to run only when explicitly requested, so that saving and opening forms never create hidden network traffic.
|
||||||
|
62. As a user probing an endpoint, I want progress attached to the URL field, so that the operation is connected to its source.
|
||||||
|
63. As a user probing an endpoint, I want detected HTTP and SOCKS5 capabilities displayed, so that the resulting Profile values are understandable.
|
||||||
|
64. As a user probing a dual-protocol endpoint, I want a compatible Advanced Profile populated, so that HTTP-only and GNOME Targets can share the Active Profile.
|
||||||
|
65. As a user whose probe input changes while a probe is running, I want stale results ignored, so that old endpoint results never overwrite new input.
|
||||||
|
66. As a user launching proxyctl, I want the dashboard to appear immediately with per-Target Checking states, so that slow detection does not look like a hung process.
|
||||||
|
67. As a user refreshing Targets, I want each Target row to update as detection completes, so that progress is visible incrementally.
|
||||||
|
68. As a user applying a Target change, I want an in-row spinner and a bottom status message, so that both local and global progress are visible.
|
||||||
|
69. As a user waiting for a system mutation, I want to navigate Targets and read Help, so that the entire interface is not unnecessarily frozen.
|
||||||
|
70. As a user waiting for a system mutation, I want conflicting modifications disabled, so that configuration operations remain serialized.
|
||||||
|
71. As a user attempting a disabled action, I want an explanation of why it is unavailable, so that the interface does not silently ignore input.
|
||||||
|
72. As a user switching the Active Profile, I want each affected Target to update independently, so that partial progress is truthful.
|
||||||
|
73. As a user whose Active Profile switch partially fails, I want successful Target updates retained and failed Targets clearly marked, so that the existing no-global-rollback rule is visible.
|
||||||
|
74. As a user whose Active Profile switch partially fails, I want the error summary preserved until acknowledged, so that the failure is not replaced by a transient success message.
|
||||||
|
75. As a user completing a successful operation, I want the success notice replaceable by the next operation, so that routine feedback does not require dismissal.
|
||||||
|
76. As a user receiving an error, I want concise text in the status area and an option to expand current details, so that diagnostics are available without permanently consuming space.
|
||||||
|
77. As a user, I do not want an in-TUI activity history, so that the interface remains focused on current state.
|
||||||
|
78. As a troubleshooting user, I want historical operation details to remain in the existing redacted log, so that removing an activity page does not remove diagnostics.
|
||||||
|
79. As a user pressing Escape, I want the current modal closed or the current page left, so that back navigation is consistent.
|
||||||
|
80. As a user pressing `q` on a child page, I want to return to the previous page, so that accidental application exit is less likely.
|
||||||
|
81. As a user pressing `q` on the dashboard, I want to exit, so that the primary quit path remains efficient.
|
||||||
|
82. As a user pressing Ctrl+C on any page, I want to exit, so that terminal interrupt behavior is consistent.
|
||||||
|
83. As a user requesting exit during a mutation, I want a visible message that proxyctl will exit safely after completion, so that my input is not silently ignored.
|
||||||
|
84. As a safety-conscious user, I do not want a system mutation terminated at an arbitrary intermediate step, so that exit does not strand configuration unnecessarily.
|
||||||
|
85. As a user leaving proxyctl, I want the prior terminal screen restored, so that the dashboard does not leave redraw artifacts in shell history.
|
||||||
|
86. As a user, I do not want the dashboard to claim that a proxy is online merely because Targets are Enabled, so that configuration state is not confused with connectivity.
|
||||||
|
87. As a user, I want Protocol Probe results confined to Profile editing, so that handshake capability is not presented as global health or latency.
|
||||||
|
88. As a maintainer, I want layout, theme, pages, dialogs, and components separated, so that future TUI work does not enlarge one monolithic state machine.
|
||||||
|
89. As a maintainer, I want mature terminal components for text input, lists, viewports, and spinners, so that common interaction behavior is not reimplemented incompletely.
|
||||||
|
90. As a maintainer, I want the Application Interface to expose structured progress and Drift Resolution intents, so that the TUI does not infer domain outcomes from display strings.
|
||||||
|
91. As a maintainer, I want Target Adapter configuration semantics preserved, so that a visual redesign does not destabilize working proxy configuration.
|
||||||
|
92. As a maintainer, I want each migration phase to leave a runnable TUI, so that the redesign can be verified incrementally.
|
||||||
|
93. As a maintainer, I want fixed-size rendering tests, so that responsive layouts do not regress silently.
|
||||||
|
94. As a maintainer, I want behavioral tests to drive the same Model used in production, so that test confidence covers real navigation and rendering composition.
|
||||||
|
95. As a maintainer, I want long content, resize, focus, modal, and no-color cases tested, so that edge terminals receive deliberate behavior.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- The redesign optimizes first for rapid daily operation and second for polished visual quality. New-user explanation is available contextually but does not dominate the dashboard.
|
||||||
|
- The primary wide-screen layout is a two-pane dashboard with a stable Target list on the left and concise selected-Target details on the right.
|
||||||
|
- Layout is responsive. Terminals at least 100 columns by 28 rows receive the complete two-pane layout. Terminals from 60 through 99 columns use a single-column layout. Terminals smaller than 60 columns or 16 rows receive a compact size warning rather than a broken full interface.
|
||||||
|
- Bubble Tea remains the application runtime. Bubbles supplies mature list, text-input, viewport, and spinner behavior. Lip Gloss supplies semantic styling and responsive composition.
|
||||||
|
- The TUI is decomposed into explicit responsibilities for application state coordination, responsive layout, semantic theme, pages, dialogs, status presentation, and reusable components. The redesign must not continue concentrating all behavior and rendering in one file.
|
||||||
|
- The program uses the terminal alternate screen and restores the previous terminal display on exit.
|
||||||
|
- The interface remains strictly keyboard-only. Mouse handling is out of scope.
|
||||||
|
- Existing shortcuts are not a compatibility contract. The redesign may replace them, while still preferring familiar terminal conventions and displaying contextual help.
|
||||||
|
- Standard Unicode symbols are used for visual status. The interface does not depend on Nerd Font glyphs. ASCII-safe degradation is permitted where terminal capabilities require it.
|
||||||
|
- Target state uses a symbol, semantic color, and explicit word. Color is reinforcement rather than the sole carrier of meaning.
|
||||||
|
- The theme adapts to light and dark terminal backgrounds and respects `NO_COLOR`. Styling is restrained, professional, and low-saturation outside meaningful states and warnings.
|
||||||
|
- The header contains a compact proxyctl identity, the Active Profile, and global Target state counts. It does not contain a large logo, version clutter, latency, or a global connected indicator.
|
||||||
|
- The dashboard shows Available Targets by default. A contextual action toggles unavailable Targets into the same stable list with detection evidence and disabled mutation actions.
|
||||||
|
- Target ordering is fixed by product composition rather than dynamically grouped by state.
|
||||||
|
- Target details prioritize current state, evidence, affected scope, permissions, restart risk, and current contextual action. Longer explanation remains scrollable below those facts.
|
||||||
|
- Tab moves focus between dashboard regions. The focused pane or component has a clear non-color-only focus treatment. Arrow keys act within the focused component.
|
||||||
|
- The footer presents only the most important actions for the current focus and state. Complete contextual keyboard help appears in a scrollable Help dialog.
|
||||||
|
- Active Profile switching is available from the dashboard through a searchable modal picker. The Active Profile is pinned first and remaining Profiles sort by name.
|
||||||
|
- Proxy Profile creation, editing, and deletion remain in a dedicated management page, separate from routine activation.
|
||||||
|
- Profile forms use mature text inputs with insertion, deletion, cursor movement, paste, focus styling, and horizontal scrolling.
|
||||||
|
- Validation is structured by field. A changed field updates or clears stale validation feedback instead of leaving unrelated global errors in place.
|
||||||
|
- Bypass editing accepts efficient delimited text and simultaneously renders normalized, deduplicated entries with item-specific validation.
|
||||||
|
- Protocol Probe is an explicit field action with a keyboard shortcut. It remains user-initiated, never runs on save or form entry, and never becomes a general connectivity or latency test.
|
||||||
|
- Protocol Probe progress and results belong to the field that initiated the probe. Results are invalidated when relevant input or mode changes.
|
||||||
|
- Dual HTTP and SOCKS5 detection creates an Advanced Profile with HTTP transport values for HTTP and HTTPS and a GNOME-compatible SOCKS5 value for ALL/SOCKS, preserving the behavior already established in the current implementation.
|
||||||
|
- Disabled, Enabled, External, and Drifted Targets do not share an ambiguous Toggle label. Their primary actions are Enable, Disable, Apply Profile, and Resolve respectively.
|
||||||
|
- Drift Resolution is explicit. It offers reapplication of the Active Profile and restoration of original configuration when ownership evidence permits. Failed ownership checks remain truthful failures and never overwrite externally changed state.
|
||||||
|
- Confirmation dialogs are limited to destructive Profile deletion and disruptive Docker restart paths, including Active Profile switches that require that restart. Cancellation is the default focus.
|
||||||
|
- Target detection at startup and refresh is progressive. The dashboard appears immediately, each Target begins in a Checking state, and rows update independently.
|
||||||
|
- Mutations remain serialized. During a mutation, browsing, selection, details, and Help remain usable, while conflicting mutations and management actions are disabled with an explanation.
|
||||||
|
- Per-Target and Active Profile operations expose structured progress rather than requiring the TUI to parse notice strings.
|
||||||
|
- Active Profile transitions retain successful Target updates when another Target fails. There is no cross-Target rollback. The first failed Target is selected, row states remain truthful, and the current error summary remains visible.
|
||||||
|
- A successful status may be replaced by subsequent activity. An error remains until acknowledged or superseded by an explicit successful resolution of that error.
|
||||||
|
- Current error details may be expanded, but the TUI does not add an activity-history page. Existing bounded, redacted operation logging remains the historical diagnostic surface.
|
||||||
|
- All text crossing into the TUI is defensively redacted immediately before rendering, in addition to lower-layer redaction.
|
||||||
|
- Escape closes a dialog or returns one level. `q` returns from child pages and exits from the dashboard. Ctrl+C requests exit from every page.
|
||||||
|
- Exit during a system operation is deferred until the operation reaches a safe completion. The UI explicitly displays the pending-exit state and rejects new work.
|
||||||
|
- Selection, focus, viewport position, and active dialogs survive terminal resize whenever their referenced item still exists.
|
||||||
|
- The Application Interface may change to represent progressive detection, structured progress, structured current errors, action availability, and Drift Resolution. These changes must remain independent of Bubble Tea presentation types.
|
||||||
|
- Stable Target Adapter configuration and restoration semantics are not redesigned as part of this effort.
|
||||||
|
- Migration proceeds in runnable stages: responsive/theme foundation; dashboard; Profile picker and management; forms/dialogs/Help; status, accessibility, and consistency completion. A long-lived legacy/new TUI switch is not introduced.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- Tests assert externally observable behavior through the highest practical seam rather than component implementation, Lip Gloss calls, or private state layout.
|
||||||
|
- The primary seam is the complete TUI Model with an injected fake Application Module. Tests send terminal resize messages, keyboard messages, asynchronous progress messages, and completion results, then assert rendered semantics, focus, visible actions, emitted intents, and navigation outcomes.
|
||||||
|
- Fixed-size render snapshots are produced through the complete Model at representative sizes: a wide 120×36 dashboard, an 80×24 single-column layout, and a 59×15 below-minimum layout.
|
||||||
|
- Render coverage includes light, dark, and `NO_COLOR` modes. Assertions must not rely only on raw ANSI sequences; semantic text and layout remain testable without color.
|
||||||
|
- The secondary seam is the Application Interface. It tests progressive detection, structured progress, serialized mutation, Drift Resolution choices, safe restore refusal, and partial Active Profile transition behavior.
|
||||||
|
- Existing Application Module tests for progressive Profile activation, partial failure without rollback, confirmation, and Target state coordination are prior art and should be extended rather than duplicated at lower seams.
|
||||||
|
- Existing TUI tests that submit keys and inspect rendered output are prior art for the primary seam. They should migrate to the new layout contract rather than preserving obsolete literal views.
|
||||||
|
- Target Adapter tests remain responsible for actual configuration translation, ownership, restoration, permissions, and command sequencing. The redesign does not duplicate those tests in the TUI.
|
||||||
|
- Bubbles and Lip Gloss internals are not unit tested. Small component tests are added only when a pure input boundary cannot be exercised deterministically through the complete Model.
|
||||||
|
- Responsive tests cover resizing in both directions while retaining valid selection, focus, viewport, in-flight state, and dialogs.
|
||||||
|
- List and viewport tests cover enough Targets, Profiles, Help text, and detail text to exceed terminal height.
|
||||||
|
- Content tests cover long Target names, long evidence, long redacted errors, long URLs, long Profile names, and many Bypass entries.
|
||||||
|
- Form tests cover insertion, cursor movement, deletion, paste, horizontal scrolling, field navigation, Single/Advanced transitions, inline validation, Bypass normalization preview, save, and cancel.
|
||||||
|
- Protocol Probe tests cover explicit invocation, in-field progress, HTTP-only, SOCKS5-only, dual-protocol population, failure, unrecognized protocol, credential-safe status, and stale-result invalidation after edits or mode changes.
|
||||||
|
- Status tests cover replaceable success, persistent errors, expanded current details, disabled-action explanations, deferred exit, and removal of stale status after a successful resolution.
|
||||||
|
- Action tests cover contextual Enable, Disable, Apply Profile, and both Drift Resolution branches rather than a generic toggle.
|
||||||
|
- Confirmation tests cover safe default focus, cancellation without mutation, Profile deletion, Docker restart, and confirmation required during Active Profile switching.
|
||||||
|
- Accessibility tests verify that state, focus, progress, warnings, and errors remain understandable with color disabled.
|
||||||
|
- Credential tests inject secrets through Profile fields, malformed URLs, evidence, notices, errors, runner output, and confirmation text, then assert that no rendered frame contains the secret.
|
||||||
|
- Startup tests assert immediate dashboard rendering followed by independent Target detection updates rather than waiting for one aggregate completion.
|
||||||
|
- Busy-state tests assert that browsing remains available, conflicting mutations are disabled, and exit is visibly deferred until safe completion.
|
||||||
|
- Full repository tests remain hermetic and must not mutate real Shell, APT, Docker, GNOME, or systemd state.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Mouse input, clickable controls, hover states, or wheel-specific interaction.
|
||||||
|
- Nerd Font or terminal-font installation requirements.
|
||||||
|
- User-selectable theme packs or a theme configuration screen.
|
||||||
|
- Large ASCII logos or decorative animation unrelated to progress.
|
||||||
|
- An in-TUI activity or historical log viewer.
|
||||||
|
- Automatic proxy connectivity tests, latency checks, health monitoring, or background network requests.
|
||||||
|
- Treating Enabled Target state as proof that the proxy endpoint or Internet is reachable.
|
||||||
|
- Changes to Profile persistence format unless independently required by a later specification.
|
||||||
|
- Reworking stable Target-specific proxy translation, file ownership, restoration, permission, or service-control behavior.
|
||||||
|
- Cross-Target rollback after partial Active Profile transition failure.
|
||||||
|
- Parallel system mutations.
|
||||||
|
- Long-term coexistence of legacy and redesigned TUI implementations.
|
||||||
|
- Changes to doctor, reset, Shell integration, release packaging, or non-interactive CLI behavior except where shared Application Interface evolution requires compatibility adaptation.
|
||||||
|
- A graphical desktop interface or web interface.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- The project glossary defines Drift Resolution as the explicit choice between reapplying the Active Profile and restoring original configuration when ownership evidence permits. UI copy and Application intents should use this language consistently.
|
||||||
|
- The throwaway interactive prototype selected **Variant A — Operations Console**: a compact responsive Target list and details layout. Wide terminals use side-by-side panes; narrow terminals stack the same regions vertically. This won over the metric-heavy status board and the single-Target workbench because it preserves complete Target scanability while keeping evidence and actions visible.
|
||||||
|
- The full three-variant prototype is preserved as the primary design source on branch `prototype/tui-operations-console` at commit `364f28d`. Its prototype code must not be promoted directly into production.
|
||||||
|
- The redesign should retain the product's privacy boundary: Protocol Probe is the only user-initiated network handshake, and display of its result must not imply general connectivity.
|
||||||
@@ -32,6 +32,10 @@ _Avoid_: Broken Target, partially enabled Target
|
|||||||
A Target for which a recognized proxy configuration exists outside this tool's ownership; absence of detected external configuration is treated as Disabled.
|
A Target for which a recognized proxy configuration exists outside this tool's ownership; absence of detected external configuration is treated as Disabled.
|
||||||
_Avoid_: Unknown Target, unmanaged error
|
_Avoid_: Unknown Target, unmanaged error
|
||||||
|
|
||||||
|
**Drift Resolution**:
|
||||||
|
The explicit choice to resolve a Drifted Target either by reapplying the Active Profile or, when ownership evidence permits, restoring the configuration that existed before proxyctl managed it.
|
||||||
|
_Avoid_: Toggle drift, automatic repair
|
||||||
|
|
||||||
**Protocol Probe**:
|
**Protocol Probe**:
|
||||||
A user-initiated handshake check against a Proxy Profile's endpoint that reports which proxy protocols (HTTP, SOCKS5) the endpoint speaks; it never forwards traffic through the proxy or contacts anything beyond the endpoint itself.
|
A user-initiated handshake check against a Proxy Profile's endpoint that reports which proxy protocols (HTTP, SOCKS5) the endpoint speaks; it never forwards traffic through the proxy or contacts anything beyond the endpoint itself.
|
||||||
_Avoid_: Connectivity test, latency check
|
_Avoid_: Connectivity test, latency check
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Start the Target panel with no arguments:
|
|||||||
proxyctl
|
proxyctl
|
||||||
```
|
```
|
||||||
|
|
||||||
Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. Select the Profile and press `a` or Enter to make it the Active Profile.
|
Press `p` for Proxy Profile management, `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. Select the Profile and press `a` or Enter to make it the Active Profile.
|
||||||
|
|
||||||
The main panel shows the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` or Escape to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled.
|
The main panel shows the Active Profile. Use arrows or `j`/`k` to select an Available Target, Space to toggle it, `r` to refresh detection, `?` for details, and `q` or Escape to leave. A Target cannot be enabled without an Active Profile, but previously managed configuration can still be disabled.
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ func runWithEnvironmentAndTargets(args []string, input io.Reader, output io.Writ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
program := tea.NewProgram(tui.NewModel(application), tea.WithInput(input), tea.WithOutput(output))
|
program := tea.NewProgram(tui.NewModel(application, tui.WithOutput(output)), tea.WithInput(input), tea.WithOutput(output), tea.WithAltScreen())
|
||||||
_, err = program.Run()
|
_, err = program.Run()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ func TestDefaultInvocationStartsTUIWithNoProfileState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultInvocationUsesAlternateScreenAndRestoresItOnExit(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := runWithSystemTargets(nil, strings.NewReader("q"), &output, []target.Target{}); err != nil {
|
||||||
|
t.Fatalf("run proxyctl: %v", err)
|
||||||
|
}
|
||||||
|
frame := output.String()
|
||||||
|
if !strings.Contains(frame, "\x1b[?1049h") {
|
||||||
|
t.Fatalf("default TUI did not enter the alternate screen: %q", frame)
|
||||||
|
}
|
||||||
|
if !strings.Contains(frame, "\x1b[?1049l") {
|
||||||
|
t.Fatalf("default TUI did not restore the previous screen: %q", frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
|
func TestDefaultInvocationLoadsPersistedActiveProfile(t *testing.T) {
|
||||||
xdg := t.TempDir()
|
xdg := t.TempDir()
|
||||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||||
|
|||||||
@@ -3,14 +3,16 @@ module proxyctl
|
|||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/muesli/termenv v0.16.0
|
||||||
golang.org/x/sys v0.36.0
|
golang.org/x/sys v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
@@ -21,7 +23,6 @@ require (
|
|||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/text v0.3.8 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
|||||||
+37
-3
@@ -21,8 +21,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
overlayName = "99proxyctl"
|
// overlayName sorts after digit-prefixed and common letter-named fragments
|
||||||
overlayHeader = "// Generated by proxyctl. Do not edit.\n"
|
// (such as a hand-written proxy.conf) because APT applies apt.conf.d entries
|
||||||
|
// in lexical order with later values winning; the overlay must be effective
|
||||||
|
// for Enabled to be truthful. legacyOverlayName is the pre-rename overlay
|
||||||
|
// removed on the next mutation so upgrades never leave stale fragments.
|
||||||
|
overlayName = "zz-proxyctl.conf"
|
||||||
|
legacyOverlayName = "99proxyctl"
|
||||||
|
overlayHeader = "// Generated by proxyctl. Do not edit.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Configuration names the Debian marker and the isolated APT configuration
|
// Configuration names the Debian marker and the isolated APT configuration
|
||||||
@@ -234,6 +240,8 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
|
|||||||
mutationErr = a.files.AtomicWrite(a.configuration.OverlayFile, translated.rendered, 0o600)
|
mutationErr = a.files.AtomicWrite(a.configuration.OverlayFile, translated.rendered, 0o600)
|
||||||
if mutationErr != nil {
|
if mutationErr != nil {
|
||||||
mutationErr = fmt.Errorf("write managed APT overlay: %w", mutationErr)
|
mutationErr = fmt.Errorf("write managed APT overlay: %w", mutationErr)
|
||||||
|
} else {
|
||||||
|
mutationErr = a.removeLegacyOverlay()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
existing, err := a.files.ReadFile(a.configuration.OverlayFile)
|
existing, err := a.files.ReadFile(a.configuration.OverlayFile)
|
||||||
@@ -248,6 +256,9 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
|
|||||||
mutationErr = fmt.Errorf("remove managed APT overlay: %w", err)
|
mutationErr = fmt.Errorf("remove managed APT overlay: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if mutationErr == nil {
|
||||||
|
mutationErr = a.removeLegacyOverlay()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
inspection, inspectErr := a.inspect(ctx, active)
|
inspection, inspectErr := a.inspect(ctx, active)
|
||||||
if mutationErr == nil && inspectErr != nil {
|
if mutationErr == nil && inspectErr != nil {
|
||||||
@@ -262,6 +273,29 @@ func (a *Adapter) SetDesired(ctx context.Context, enabled bool, active *profile.
|
|||||||
return target.OperationResult{Inspection: inspection, Err: redactError(mutationErr, active)}
|
return target.OperationResult{Inspection: inspection, Err: redactError(mutationErr, active)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeLegacyOverlay deletes the pre-rename overlay when proxyctl owns it, so
|
||||||
|
// upgrades never leave a stale credential-bearing fragment behind. A legacy
|
||||||
|
// file without the managed header is user-owned and stays untouched.
|
||||||
|
func (a *Adapter) removeLegacyOverlay() error {
|
||||||
|
legacy := filepath.Join(a.configuration.ConfigDirectory, legacyOverlayName)
|
||||||
|
if legacy == a.configuration.OverlayFile {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := a.files.ReadFile(legacy)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, fs.ErrNotExist):
|
||||||
|
return nil
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("inspect legacy APT overlay: %w", err)
|
||||||
|
case !isOwnedOverlay(data):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.files.Remove(legacy); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("remove legacy APT overlay: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Adapter) dump(ctx context.Context) ([]byte, error) {
|
func (a *Adapter) dump(ctx context.Context) ([]byte, error) {
|
||||||
command, err := a.runner.LookPath("apt-config")
|
command, err := a.runner.LookPath("apt-config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -293,7 +327,7 @@ func translate(input profile.Profile) (translation, error) {
|
|||||||
}
|
}
|
||||||
for _, candidate := range []struct {
|
for _, candidate := range []struct {
|
||||||
field, value string
|
field, value string
|
||||||
}{{"HTTP proxy", p.HTTPURL}, {"HTTPS proxy", p.HTTPSURL}, {"ALL/SOCKS proxy", p.AllURL}} {
|
}{{"effective HTTP proxy", httpValue}, {"effective HTTPS proxy", httpsValue}} {
|
||||||
if candidate.value == "" {
|
if candidate.value == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func TestAvailabilityRequiresDebianMarkerAPTToolAndConfigurationDirectory(t *tes
|
|||||||
mustWrite(t, filepath.Join(root, "etc", "debian_version"), "12\n")
|
mustWrite(t, filepath.Join(root, "etc", "debian_version"), "12\n")
|
||||||
}
|
}
|
||||||
runner := &fakeRunner{available: tc.tool, outputError: !tc.dumpWorks}
|
runner := &fakeRunner{available: tc.tool, outputError: !tc.dumpWorks}
|
||||||
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: configDir, OverlayFile: filepath.Join(configDir, "99proxyctl"), TrustedRoot: root}, nil, runner)
|
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: configDir, TrustedRoot: root}, nil, runner)
|
||||||
got := a.Availability(context.Background())
|
got := a.Availability(context.Background())
|
||||||
if got.Available != tc.want || got.Evidence == "" {
|
if got.Available != tc.want || got.Evidence == "" {
|
||||||
t.Fatalf("availability = %#v", got)
|
t.Fatalf("availability = %#v", got)
|
||||||
@@ -225,6 +225,59 @@ func TestOverlayNameCollisionNeverRewritesOrRemovesUnownedConfiguration(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultOverlayNameSortsAfterCommonAPTFragments(t *testing.T) {
|
||||||
|
name := filepath.Base(apt.New(apt.Configuration{}, nil, nil).OverlayPath())
|
||||||
|
if filepath.Ext(name) != ".conf" {
|
||||||
|
t.Fatalf("default overlay name %q has no .conf extension; stricter APT versions ignore extensionless fragments", name)
|
||||||
|
}
|
||||||
|
for _, fragment := range []string{"proxy.conf", "99update-notifier", "01-vendor-ubuntu"} {
|
||||||
|
if strings.Compare(name, fragment) <= 0 {
|
||||||
|
t.Fatalf("default overlay name %q does not sort after common fragment %q; a later fragment shadows the overlay and enable can never become effective", name, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnedLegacyOverlayIsRemovedOnEnableAndDisable(t *testing.T) {
|
||||||
|
legacyContents := "// Generated by proxyctl. Do not edit.\nAcquire::http::Proxy \"http://stale.example:3128\";\n"
|
||||||
|
|
||||||
|
t.Run("enable", func(t *testing.T) {
|
||||||
|
a, _ := newAdapter(t, nil)
|
||||||
|
legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl")
|
||||||
|
mustWrite(t, legacy, legacyContents)
|
||||||
|
if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("enable = %#v", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("owned legacy overlay remains after enable: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("disable", func(t *testing.T) {
|
||||||
|
a, _ := newAdapter(t, nil)
|
||||||
|
legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl")
|
||||||
|
mustWrite(t, legacy, legacyContents)
|
||||||
|
if got := a.SetDesired(context.Background(), false, nil); got.Err != nil {
|
||||||
|
t.Fatalf("disable = %#v", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(legacy); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("owned legacy overlay remains after disable: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unowned legacy is untouched", func(t *testing.T) {
|
||||||
|
a, _ := newAdapter(t, nil)
|
||||||
|
legacy := filepath.Join(filepath.Dir(a.OverlayPath()), "99proxyctl")
|
||||||
|
original := "Acquire::http::Proxy \"http://user-owned.example:3128\";\n"
|
||||||
|
mustWrite(t, legacy, original)
|
||||||
|
if got := a.SetDesired(context.Background(), true, compatibleProfile()); got.Err != nil || got.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("enable = %#v", got)
|
||||||
|
}
|
||||||
|
if got := readFile(t, legacy); got != original {
|
||||||
|
t.Fatalf("unowned legacy overlay changed: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestDisablePermissionFailureReportsActualManagedState(t *testing.T) {
|
func TestDisablePermissionFailureReportsActualManagedState(t *testing.T) {
|
||||||
files := &failingFS{}
|
files := &failingFS{}
|
||||||
a, _ := newFailingAdapter(t, files)
|
a, _ := newFailingAdapter(t, files)
|
||||||
@@ -268,7 +321,7 @@ func TestIncompatibleProtocolOrBypassPreventsMutationAndNamesValue(t *testing.T)
|
|||||||
value string
|
value string
|
||||||
}{
|
}{
|
||||||
{"socks5 local DNS", func(p *profile.Profile) { p.HTTPURL = "socks5://unsupported.example:1080" }, "socks5://unsupported.example:1080"},
|
{"socks5 local DNS", func(p *profile.Profile) { p.HTTPURL = "socks5://unsupported.example:1080" }, "socks5://unsupported.example:1080"},
|
||||||
{"incompatible overridden ALL", func(p *profile.Profile) { p.AllURL = "socks5://all.example:1080" }, "socks5://all.example:1080"},
|
{"incompatible ALL fallback", func(p *profile.Profile) { p.HTTPURL = ""; p.AllURL = "socks5://all.example:1080" }, "socks5://all.example:1080"},
|
||||||
{"domain suffix", func(p *profile.Profile) { p.Bypass = []string{".example.com"} }, ".example.com"},
|
{"domain suffix", func(p *profile.Profile) { p.Bypass = []string{".example.com"} }, ".example.com"},
|
||||||
{"CIDR", func(p *profile.Profile) { p.Bypass = []string{"10.0.0.0/8"} }, "10.0.0.0/8"},
|
{"CIDR", func(p *profile.Profile) { p.Bypass = []string{"10.0.0.0/8"} }, "10.0.0.0/8"},
|
||||||
{"IPv6", func(p *profile.Profile) { p.Bypass = []string{"2001:db8::1"} }, "2001:db8::1"},
|
{"IPv6", func(p *profile.Profile) { p.Bypass = []string{"2001:db8::1"} }, "2001:db8::1"},
|
||||||
@@ -323,6 +376,22 @@ func TestManagedOverlayIsAtomicOwnerOnlyAndRejectsSymlinkVictims(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompleteHTTPProfileIgnoresAdditionalSOCKSProxy(t *testing.T) {
|
||||||
|
a, _ := newAdapter(t, nil)
|
||||||
|
p := &profile.Profile{
|
||||||
|
Name: "dual", Mode: profile.AdvancedMode,
|
||||||
|
HTTPURL: "http://localhost:7890", HTTPSURL: "http://localhost:7890", AllURL: "socks5://localhost:7890",
|
||||||
|
}
|
||||||
|
got := a.SetDesired(context.Background(), true, p)
|
||||||
|
if got.Err != nil || got.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("enable = %#v", got)
|
||||||
|
}
|
||||||
|
contents := readFile(t, a.OverlayPath())
|
||||||
|
if strings.Count(contents, `"http://localhost:7890"`) != 2 || strings.Contains(contents, "socks5") {
|
||||||
|
t.Fatalf("dual-protocol translation:\n%s", contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdvancedALLValueSuppliesMissingAPTProtocols(t *testing.T) {
|
func TestAdvancedALLValueSuppliesMissingAPTProtocols(t *testing.T) {
|
||||||
a, _ := newAdapter(t, nil)
|
a, _ := newAdapter(t, nil)
|
||||||
p := &profile.Profile{Name: "all", Mode: profile.AdvancedMode, AllURL: "socks5h://proxy.example:1080"}
|
p := &profile.Profile{Name: "all", Mode: profile.AdvancedMode, AllURL: "socks5h://proxy.example:1080"}
|
||||||
@@ -361,7 +430,7 @@ func newAdapter(t *testing.T, files apt.FileSystem) (*apt.Adapter, *fakeRunner)
|
|||||||
if failure, ok := files.(*failingFS); ok {
|
if failure, ok := files.(*failingFS); ok {
|
||||||
failure.delegate = apt.NewOSFileSystem(root)
|
failure.delegate = apt.NewOSFileSystem(root)
|
||||||
}
|
}
|
||||||
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: dir, OverlayFile: filepath.Join(dir, "99proxyctl"), TrustedRoot: root}, files, runner)
|
a := apt.New(apt.Configuration{DebianVersionFile: filepath.Join(root, "etc", "debian_version"), ConfigDirectory: dir, TrustedRoot: root}, files, runner)
|
||||||
runner.overlay = a.OverlayPath()
|
runner.overlay = a.OverlayPath()
|
||||||
return a, runner
|
return a, runner
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ func compatible(input profile.Profile) (profile.Profile, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return profile.Profile{}, err
|
return profile.Profile{}, err
|
||||||
}
|
}
|
||||||
if p.Mode == profile.AdvancedMode && p.AllURL != "" {
|
if p.Mode == profile.AdvancedMode && p.AllURL != "" && (p.HTTPURL == "" || p.HTTPSURL == "") {
|
||||||
return profile.Profile{}, errors.New("Docker daemon Target does not support an ALL/SOCKS proxy; remove it before applying")
|
return profile.Profile{}, errors.New("Docker daemon Target cannot use an ALL/SOCKS proxy as a fallback; provide both HTTP and HTTPS proxy URLs before applying")
|
||||||
}
|
}
|
||||||
for name, raw := range map[string]string{"HTTP": p.HTTPURL, "HTTPS": p.HTTPSURL} {
|
for name, raw := range map[string]string{"HTTP": p.HTTPURL, "HTTPS": p.HTTPSURL} {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -562,6 +562,25 @@ func TestDockerDaemonRestartFailureIsRedactedTruthfulAndRetryable(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDockerDaemonAcceptsCompleteHTTPProfileWithAdditionalSOCKSProxy(t *testing.T) {
|
||||||
|
a, record, daemonPath, _, _ := fixture(t)
|
||||||
|
p := &profile.Profile{
|
||||||
|
Name: "dual", Mode: profile.AdvancedMode,
|
||||||
|
HTTPURL: "http://localhost:7890", HTTPSURL: "http://localhost:7890", AllURL: "socks5://localhost:7890",
|
||||||
|
}
|
||||||
|
result := a.SetDesired(context.Background(), true, p)
|
||||||
|
if result.Err != nil || result.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("dual-protocol result = %#v events=%v", result, record.events)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(daemonPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"http-proxy": "http://localhost:7890"`) || !strings.Contains(string(data), `"https-proxy": "http://localhost:7890"`) {
|
||||||
|
t.Fatalf("daemon config = %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDockerDaemonRejectsIncompatibleProfileBeforeMutation(t *testing.T) {
|
func TestDockerDaemonRejectsIncompatibleProfileBeforeMutation(t *testing.T) {
|
||||||
for _, p := range []*profile.Profile{
|
for _, p := range []*profile.Profile{
|
||||||
{Name: "socks", Mode: profile.SingleMode, SingleURL: "socks5://proxy.example:1080"},
|
{Name: "socks", Mode: profile.SingleMode, SingleURL: "socks5://proxy.example:1080"},
|
||||||
|
|||||||
@@ -28,18 +28,18 @@ func TestConfigurationTargetsSudoInvokingUsersSessionAndState(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.UID != 1000 || got.GID != 1001 || got.HomeDir != "/home/alice" || got.RuntimeDir != "/run/user/1000" || got.StateFile != "/home/alice/.local/state/proxyctl/gnome.json" {
|
if got.UID != 1000 || got.GID != 1001 || got.HomeDir != "/home/alice" || got.ConfigDir != "/home/alice/.config" || got.RuntimeDir != "/run/user/1000" || got.StateFile != "/home/alice/.local/state/proxyctl/gnome.json" {
|
||||||
t.Fatalf("%#v", got)
|
t.Fatalf("%#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigurationKeepsDirectUsersSafeAbsoluteXDGPaths(t *testing.T) {
|
func TestConfigurationKeepsDirectUsersSafeAbsoluteXDGPaths(t *testing.T) {
|
||||||
user := profile.UserIdentity{Username: "alice", HomeDir: "/home/alice", UID: 1000, GID: 1001}
|
user := profile.UserIdentity{Username: "alice", HomeDir: "/home/alice", UID: 1000, GID: 1001}
|
||||||
got, err := gnome.ResolveConfiguration(identityEnvironment{current: user, env: map[string]string{"XDG_STATE_HOME": "/home/alice/state", "XDG_RUNTIME_DIR": "/run/user/1000", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/custom/bus"}})
|
got, err := gnome.ResolveConfiguration(identityEnvironment{current: user, env: map[string]string{"XDG_CONFIG_HOME": "/home/alice/config", "XDG_STATE_HOME": "/home/alice/state", "XDG_RUNTIME_DIR": "/run/user/1000", "DBUS_SESSION_BUS_ADDRESS": "unix:path=/custom/bus"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.StateFile != "/home/alice/state/proxyctl/gnome.json" || got.RuntimeDir != "/run/user/1000" || got.SessionBus != "unix:path=/custom/bus" {
|
if got.ConfigDir != "/home/alice/config" || got.StateFile != "/home/alice/state/proxyctl/gnome.json" || got.RuntimeDir != "/run/user/1000" || got.SessionBus != "unix:path=/custom/bus" {
|
||||||
t.Fatalf("%#v", got)
|
t.Fatalf("%#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,9 @@ func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuratio
|
|||||||
if stateDir == "" {
|
if stateDir == "" {
|
||||||
stateDir = filepath.Join(owner.HomeDir, ".local", "state")
|
stateDir = filepath.Join(owner.HomeDir, ".local", "state")
|
||||||
}
|
}
|
||||||
configDir := filepath.Dir(profileConfiguration.Path)
|
// Profile storage lives below <XDG_CONFIG_HOME>/proxyctl, while gsettings
|
||||||
|
// must receive the XDG base itself or dconf opens a proxyctl-private database.
|
||||||
|
configDir := filepath.Dir(filepath.Dir(profileConfiguration.Path))
|
||||||
dataDir := filepath.Join(owner.HomeDir, ".local", "share")
|
dataDir := filepath.Join(owner.HomeDir, ".local", "share")
|
||||||
if value, ok := environment.LookupEnv("XDG_DATA_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
|
if value, ok := environment.LookupEnv("XDG_DATA_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
|
||||||
dataDir = value
|
dataDir = value
|
||||||
@@ -691,7 +693,7 @@ func translate(input profile.Profile) (settings, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return settings{}, err
|
return settings{}, err
|
||||||
}
|
}
|
||||||
result := settings{Mode: "manual"}
|
result := settings{Mode: "manual", IgnoreHosts: []string{}}
|
||||||
if p.Mode == profile.SingleMode {
|
if p.Mode == profile.SingleMode {
|
||||||
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.SingleURL, "Single proxy URL", "socks5", false); err != nil {
|
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.SingleURL, "Single proxy URL", "socks5", false); err != nil {
|
||||||
return settings{}, err
|
return settings{}, err
|
||||||
@@ -874,6 +876,9 @@ func parseQuoted(raw string) (string, int, error) {
|
|||||||
|
|
||||||
func parseStrings(raw string) ([]string, error) {
|
func parseStrings(raw string) ([]string, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
|
// GVariant prints an empty string array with its otherwise-inferable type
|
||||||
|
// annotation ("@as []") on some gsettings versions.
|
||||||
|
raw = strings.TrimSpace(strings.TrimPrefix(raw, "@as"))
|
||||||
if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' {
|
if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' {
|
||||||
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
|
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func newRunner() *fakeRunner {
|
|||||||
return &fakeRunner{schemas: []string{"org.gnome.system.proxy", "org.gnome.system.proxy.http", "org.gnome.system.proxy.https", "org.gnome.system.proxy.socks"}, values: map[string]string{
|
return &fakeRunner{schemas: []string{"org.gnome.system.proxy", "org.gnome.system.proxy.http", "org.gnome.system.proxy.https", "org.gnome.system.proxy.socks"}, values: map[string]string{
|
||||||
"org.gnome.system.proxy mode": "'none'",
|
"org.gnome.system.proxy mode": "'none'",
|
||||||
"org.gnome.system.proxy autoconfig-url": "''",
|
"org.gnome.system.proxy autoconfig-url": "''",
|
||||||
"org.gnome.system.proxy ignore-hosts": "[]",
|
"org.gnome.system.proxy ignore-hosts": "@as []",
|
||||||
"org.gnome.system.proxy.http host": "''", "org.gnome.system.proxy.http port": "0",
|
"org.gnome.system.proxy.http host": "''", "org.gnome.system.proxy.http port": "0",
|
||||||
"org.gnome.system.proxy.http use-authentication": "false", "org.gnome.system.proxy.http authentication-user": "''", "org.gnome.system.proxy.http authentication-password": "''",
|
"org.gnome.system.proxy.http use-authentication": "false", "org.gnome.system.proxy.http authentication-user": "''", "org.gnome.system.proxy.http authentication-password": "''",
|
||||||
"org.gnome.system.proxy.https host": "''", "org.gnome.system.proxy.https port": "0",
|
"org.gnome.system.proxy.https host": "''", "org.gnome.system.proxy.https port": "0",
|
||||||
@@ -439,6 +439,28 @@ func TestSingleSOCKSProxyUsesGNOMESOCKSForAllProtocols(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDualProtocolEndpointConfiguresHTTPAndSOCKSAtSameAddress(t *testing.T) {
|
||||||
|
r, s := newRunner(), &memoryState{}
|
||||||
|
p := &profile.Profile{
|
||||||
|
Name: "dual", Mode: profile.AdvancedMode,
|
||||||
|
HTTPURL: "http://localhost:7890", HTTPSURL: "http://localhost:7890", AllURL: "socks5://localhost:7890",
|
||||||
|
}
|
||||||
|
got := adapter(r, s).SetDesired(context.Background(), true, p)
|
||||||
|
if got.Err != nil || got.Inspection.State != target.Enabled {
|
||||||
|
t.Fatalf("dual-protocol result = %#v", got)
|
||||||
|
}
|
||||||
|
for _, key := range []string{"org.gnome.system.proxy.http host", "org.gnome.system.proxy.https host", "org.gnome.system.proxy.socks host"} {
|
||||||
|
if r.values[key] != "'localhost'" {
|
||||||
|
t.Errorf("%s = %q", key, r.values[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"org.gnome.system.proxy.http port", "org.gnome.system.proxy.https port", "org.gnome.system.proxy.socks port"} {
|
||||||
|
if r.values[key] != "7890" {
|
||||||
|
t.Errorf("%s = %q", key, r.values[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdvancedHTTPProxyCredentialsArePreserved(t *testing.T) {
|
func TestAdvancedHTTPProxyCredentialsArePreserved(t *testing.T) {
|
||||||
r, s := newRunner(), &memoryState{}
|
r, s := newRunner(), &memoryState{}
|
||||||
p := *active()
|
p := *active()
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/profile"
|
||||||
|
"proxyctl/internal/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompleteModelRendersResponsiveTerminalSizeClasses(t *testing.T) {
|
||||||
|
module := responsiveLayoutModule()
|
||||||
|
|
||||||
|
t.Run("wide 120x36", func(t *testing.T) {
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
|
view := model.View()
|
||||||
|
if !sameLine(view, "Targets", "Target details") {
|
||||||
|
t.Fatalf("wide layout did not place list and details side by side:\n%s", view)
|
||||||
|
}
|
||||||
|
assertUsableFrame(t, view, 120, 36, "Shell", "Shell summary", "Active Profile: office")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("narrow 80x24", func(t *testing.T) {
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||||
|
view := model.View()
|
||||||
|
if sameLine(view, "Targets", "Target details") || strings.Index(view, "Target details") < strings.Index(view, "Shell") {
|
||||||
|
t.Fatalf("narrow layout did not stack list above details:\n%s", view)
|
||||||
|
}
|
||||||
|
assertUsableFrame(t, view, 80, 24, "Shell", "Shell summary", "Active Profile: office")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("below minimum 59x15", func(t *testing.T) {
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 59, 15)
|
||||||
|
view := model.View()
|
||||||
|
assertUsableFrame(t, view, 59, 15, "Terminal too small", "Minimum size: 60×16", "Current size: 59×15")
|
||||||
|
if strings.Contains(view, "Shell summary") || strings.Contains(view, "Targets\n") {
|
||||||
|
t.Fatalf("minimum-size message was mixed with the application interface:\n%s", view)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
|
||||||
|
module := responsiveLayoutModule()
|
||||||
|
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") {
|
||||||
|
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") {
|
||||||
|
t.Fatalf("Target selection did not survive narrow-to-wide resize:\n%s", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
model = updateKey(model, "p")
|
||||||
|
model = updateKey(model, "e")
|
||||||
|
model = updateKey(model, "ctrl+t")
|
||||||
|
for range 4 {
|
||||||
|
model = updateKey(model, "tab")
|
||||||
|
}
|
||||||
|
for _, size := range []struct{ width, height int }{{60, 16}, {120, 36}, {80, 24}} {
|
||||||
|
model = resize(model, size.width, size.height)
|
||||||
|
view := model.View()
|
||||||
|
assertUsableFrame(t, view, size.width, size.height, "> Bypass:", "Ctrl+S: save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelSemanticThemeSupportsDarkLightAndNoColor(t *testing.T) {
|
||||||
|
module := responsiveLayoutModule()
|
||||||
|
dark := resizedModel(t, module, themeModeDark, 80, 24).View()
|
||||||
|
light := resizedModel(t, module, themeModeLight, 80, 24).View()
|
||||||
|
plain := resizedModel(t, module, themeModeNoColor, 80, 24).View()
|
||||||
|
|
||||||
|
for name, view := range map[string]string{"dark": dark, "light": light, "NO_COLOR": plain} {
|
||||||
|
for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} {
|
||||||
|
if !strings.Contains(view, semantic) {
|
||||||
|
t.Fatalf("%s theme lost non-color semantic %q:\n%s", name, semantic, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(dark, "\x1b[") || !strings.Contains(light, "\x1b[") {
|
||||||
|
t.Fatalf("light or dark semantic theme emitted no styling\ndark: %q\nlight: %q", dark, light)
|
||||||
|
}
|
||||||
|
if dark == light {
|
||||||
|
t.Fatal("light and dark themes rendered identically")
|
||||||
|
}
|
||||||
|
if ansiPattern.MatchString(plain) {
|
||||||
|
t.Fatalf("NO_COLOR rendering contained ANSI styling: %q", plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewModelUsesInjectedBubbleTeaOutputForTerminalCapabilities(t *testing.T) {
|
||||||
|
var bubbleTeaOutput bytes.Buffer
|
||||||
|
model := resize(NewModel(responsiveLayoutModule(), WithOutput(&bubbleTeaOutput)), 80, 24)
|
||||||
|
view := model.View()
|
||||||
|
if ansiPattern.MatchString(view) {
|
||||||
|
t.Fatalf("non-terminal Bubble Tea output received ANSI styling: %q", view)
|
||||||
|
}
|
||||||
|
for _, semantic := range []string{"Active Profile: office", "Disabled", "External", ">"} {
|
||||||
|
if !strings.Contains(view, semantic) {
|
||||||
|
t.Fatalf("injected-output rendering lost semantic %q:\n%s", semantic, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelRespectsNoColorEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("NO_COLOR", "1")
|
||||||
|
model := resize(NewModel(responsiveLayoutModule()), 80, 24)
|
||||||
|
view := model.View()
|
||||||
|
if ansiPattern.MatchString(view) {
|
||||||
|
t.Fatalf("NO_COLOR environment produced ANSI styling: %q", view)
|
||||||
|
}
|
||||||
|
for _, semantic := range []string{"Disabled", "External", ">"} {
|
||||||
|
if !strings.Contains(view, semantic) {
|
||||||
|
t.Fatalf("NO_COLOR environment lost semantic %q:\n%s", semantic, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelDefensivelyRedactsEveryRenderedPage(t *testing.T) {
|
||||||
|
const (
|
||||||
|
secret = "foundation-secret"
|
||||||
|
credential = "https://alice:" + secret + "@proxy.example:8443"
|
||||||
|
redacted = "https://alice:REDACTED@proxy.example:8443"
|
||||||
|
)
|
||||||
|
module := &recordingModule{snapshot: app.Snapshot{
|
||||||
|
ActiveProfile: credential,
|
||||||
|
Profiles: []profile.Profile{{
|
||||||
|
Name: credential, Mode: profile.SingleMode, SingleURL: credential, Bypass: []string{credential},
|
||||||
|
}},
|
||||||
|
Targets: []app.TargetSnapshot{{
|
||||||
|
ID: credential,
|
||||||
|
Description: target.Description{Name: credential, Summary: credential, Details: credential},
|
||||||
|
Available: true, State: target.External, Evidence: credential, AvailabilityEvidence: credential,
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 120, 36)
|
||||||
|
|
||||||
|
frames := []string{model.View()}
|
||||||
|
model = updateKey(model, "?")
|
||||||
|
frames = append(frames, model.View())
|
||||||
|
model = updateKey(model, "q")
|
||||||
|
model = updateKey(model, "p")
|
||||||
|
frames = append(frames, model.View())
|
||||||
|
model = updateKey(model, "e")
|
||||||
|
frames = append(frames, model.View())
|
||||||
|
model.status = "operation failed for " + credential
|
||||||
|
frames = append(frames, model.View())
|
||||||
|
|
||||||
|
for i, frame := range frames {
|
||||||
|
if strings.Contains(frame, secret) {
|
||||||
|
t.Fatalf("frame %d exposed a credential:\n%s", i, frame)
|
||||||
|
}
|
||||||
|
if !strings.Contains(frame, redacted) {
|
||||||
|
t.Fatalf("frame %d did not apply the presentation redaction path:\n%s", i, frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelRedactsApplicationErrorsBeforeRendering(t *testing.T) {
|
||||||
|
const secret = "result-secret"
|
||||||
|
initial := app.Snapshot{Targets: []app.TargetSnapshot{{ID: "shell", Description: target.Description{Name: "Shell"}, Available: true, State: target.Disabled}}}
|
||||||
|
module := &targetModule{snapshot: initial, result: app.Result{Snapshot: initial, Err: errors.New("failed https://bob:" + secret + "@proxy.example:8080")}}
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||||
|
updated, cmd := model.Update(keyMessage("r"))
|
||||||
|
model = updated.(Model)
|
||||||
|
updated, _ = model.Update(cmd())
|
||||||
|
view := updated.(Model).View()
|
||||||
|
if strings.Contains(view, secret) || !strings.Contains(view, "https://bob:REDACTED@proxy.example:8080") {
|
||||||
|
t.Fatalf("Application error bypassed presentation redaction:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelNarrowShellKeepsLastProductionTargetStatusAndActionsVisible(t *testing.T) {
|
||||||
|
snapshot := productionScaleSnapshot()
|
||||||
|
longNotice := "Refresh completed; selected Target remains actionable. " + strings.Repeat("Additional redacted diagnostic context. ", 12)
|
||||||
|
module := &targetModule{snapshot: snapshot, result: app.Result{Snapshot: snapshot, Notice: longNotice}}
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||||
|
for range len(snapshot.Targets) - 1 {
|
||||||
|
model = updateKey(model, "down")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, refresh := model.Update(keyMessage("r"))
|
||||||
|
model = updated.(Model)
|
||||||
|
updated, _ = model.Update(refresh())
|
||||||
|
model = updated.(Model)
|
||||||
|
|
||||||
|
assertUsableFrame(t, model.View(), 80, 24,
|
||||||
|
"> [Drifted ] Docker daemon",
|
||||||
|
"Docker daemon selected summary",
|
||||||
|
"Docker daemon selected detail.",
|
||||||
|
"Status: Refresh completed; selected Target remains actionable.",
|
||||||
|
"Space: toggle",
|
||||||
|
"p: manage Proxy Profiles",
|
||||||
|
)
|
||||||
|
|
||||||
|
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.",
|
||||||
|
"Space: toggle",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteModelLongProfileListKeepsLastSelectionDetailsAndActionsVisibleAcrossResize(t *testing.T) {
|
||||||
|
profiles := make([]profile.Profile, 32)
|
||||||
|
for i := range profiles {
|
||||||
|
profiles[i] = profile.Profile{
|
||||||
|
Name: fmt.Sprintf("regional-office-%02d", i),
|
||||||
|
Mode: profile.SingleMode,
|
||||||
|
SingleURL: fmt.Sprintf("http://proxy-%02d.example:8080", i),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module := &recordingModule{snapshot: app.Snapshot{ActiveProfile: profiles[0].Name, Profiles: profiles}}
|
||||||
|
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||||
|
model = updateKey(model, "p")
|
||||||
|
for range len(profiles) - 1 {
|
||||||
|
model = updateKey(model, "down")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {80, 24}} {
|
||||||
|
model = resize(model, size.width, size.height)
|
||||||
|
assertUsableFrame(t, model.View(), size.width, size.height,
|
||||||
|
"> regional-office-31",
|
||||||
|
"Proxy: http://proxy-31.example:8080",
|
||||||
|
"a/Enter: activate",
|
||||||
|
"q/Esc: back",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func productionScaleSnapshot() app.Snapshot {
|
||||||
|
return app.Snapshot{
|
||||||
|
ActiveProfile: "office",
|
||||||
|
Profiles: []profile.Profile{{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080"}},
|
||||||
|
Targets: []app.TargetSnapshot{
|
||||||
|
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: strings.Repeat("Shell detail. ", 30)}, Available: true, State: target.Enabled, Evidence: strings.Repeat("Shell evidence. ", 20)},
|
||||||
|
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: strings.Repeat("APT detail. ", 30)}, Available: true, State: target.Disabled, Evidence: strings.Repeat("APT evidence. ", 20)},
|
||||||
|
{ID: "docker-containers", Description: target.Description{Name: "Docker containers/builds", Summary: "Docker client summary", Details: strings.Repeat("Docker client detail. ", 30)}, Available: true, State: target.External, Evidence: strings.Repeat("Docker client evidence. ", 20)},
|
||||||
|
{ID: "gnome", Description: target.Description{Name: "GNOME", Summary: "GNOME summary", Details: strings.Repeat("GNOME detail. ", 30)}, Available: true, State: target.Enabled, Evidence: strings.Repeat("GNOME evidence. ", 20)},
|
||||||
|
{ID: "docker-daemon", Description: target.Description{Name: "Docker daemon", Summary: "Docker daemon selected summary", Details: strings.Repeat("Docker daemon selected detail. ", 30)}, Available: true, State: target.Drifted, Evidence: strings.Repeat("Docker daemon selected evidence. ", 20)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
|
||||||
|
|
||||||
|
func responsiveLayoutModule() *recordingModule {
|
||||||
|
return &recordingModule{snapshot: app.Snapshot{
|
||||||
|
ActiveProfile: "office",
|
||||||
|
Profiles: []profile.Profile{{Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080"}},
|
||||||
|
Targets: []app.TargetSnapshot{
|
||||||
|
{ID: "shell", Description: target.Description{Name: "Shell", Summary: "Shell summary", Details: "Shell details"}, Available: true, State: target.Disabled, Evidence: "Shell evidence"},
|
||||||
|
{ID: "apt", Description: target.Description{Name: "APT", Summary: "APT summary", Details: "APT details"}, Available: true, State: target.External, Evidence: "APT evidence"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resizedModel(t *testing.T, module app.Module, mode themeMode, width, height int) Model {
|
||||||
|
t.Helper()
|
||||||
|
return resize(NewModel(module, withTheme(mode)), width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resize(model Model, width, height int) Model {
|
||||||
|
updated, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height})
|
||||||
|
return updated.(Model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameLine(view, first, second string) bool {
|
||||||
|
for _, line := range strings.Split(view, "\n") {
|
||||||
|
if strings.Contains(line, first) && strings.Contains(line, second) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertUsableFrame(t *testing.T, view string, width, height int, semantics ...string) {
|
||||||
|
t.Helper()
|
||||||
|
lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n")
|
||||||
|
if len(lines) > height {
|
||||||
|
t.Fatalf("frame has %d lines, terminal height is %d:\n%s", len(lines), height, view)
|
||||||
|
}
|
||||||
|
for _, line := range lines {
|
||||||
|
if len([]rune(line)) > width {
|
||||||
|
t.Fatalf("frame line is wider than %d columns: %q", width, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, semantic := range semantics {
|
||||||
|
if !strings.Contains(view, semantic) {
|
||||||
|
t.Fatalf("frame missing %q:\n%s", semantic, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-154
@@ -3,7 +3,6 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -33,8 +32,11 @@ type formState struct {
|
|||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
application app.Module
|
application app.Module
|
||||||
|
presentation terminalPresentation
|
||||||
snapshot app.Snapshot
|
snapshot app.Snapshot
|
||||||
page page
|
page page
|
||||||
|
width int
|
||||||
|
height int
|
||||||
selected int
|
selected int
|
||||||
form formState
|
form formState
|
||||||
confirm bool
|
confirm bool
|
||||||
@@ -84,8 +86,19 @@ func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(application app.Module) Model {
|
func NewModel(application app.Module, configure ...ModelOption) Model {
|
||||||
return Model{application: application, snapshot: application.Snapshot(), detect: probe.Detect}
|
options := defaultModelOptions()
|
||||||
|
for _, option := range configure {
|
||||||
|
option(&options)
|
||||||
|
}
|
||||||
|
return Model{
|
||||||
|
application: application,
|
||||||
|
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
||||||
|
snapshot: application.Snapshot(),
|
||||||
|
width: 80,
|
||||||
|
height: 24,
|
||||||
|
detect: probe.Detect,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return nil }
|
func (m Model) Init() tea.Cmd { return nil }
|
||||||
@@ -102,6 +115,12 @@ func (m Model) operationInFlight() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if size, ok := message.(tea.WindowSizeMsg); ok {
|
||||||
|
m.width = size.Width
|
||||||
|
m.height = size.Height
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// A deferred tea.Quit is queued only after the in-flight completion has
|
// A deferred tea.Quit is queued only after the in-flight completion has
|
||||||
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
|
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
|
||||||
// terminal so intervening input cannot cancel a confirmation or start a
|
// terminal so intervening input cannot cancel a confirmation or start a
|
||||||
@@ -120,7 +139,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshing = false
|
m.refreshing = false
|
||||||
m.snapshot = completed.result.Snapshot
|
m.snapshot = completed.result.Snapshot
|
||||||
if completed.result.Err != nil {
|
if completed.result.Err != nil {
|
||||||
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
m.status = "Error: " + completed.result.Err.Error()
|
||||||
} else {
|
} else {
|
||||||
m.status = completed.result.Notice
|
m.status = completed.result.Notice
|
||||||
}
|
}
|
||||||
@@ -156,7 +175,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.deferredQuit()
|
return m, m.deferredQuit()
|
||||||
}
|
}
|
||||||
if progress.result.Err != nil {
|
if progress.result.Err != nil {
|
||||||
m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m"
|
m.status = "Error: " + progress.result.Err.Error()
|
||||||
} else if progress.result.Notice != "" {
|
} else if progress.result.Notice != "" {
|
||||||
m.status = progress.result.Notice
|
m.status = progress.result.Notice
|
||||||
}
|
}
|
||||||
@@ -184,7 +203,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = completed.result.Confirmation
|
m.status = completed.result.Confirmation
|
||||||
} else if completed.result.Err != nil {
|
} else if completed.result.Err != nil {
|
||||||
m.targetConfirm = nil
|
m.targetConfirm = nil
|
||||||
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
|
m.status = "Error: " + completed.result.Err.Error()
|
||||||
} else {
|
} else {
|
||||||
m.targetConfirm = nil
|
m.targetConfirm = nil
|
||||||
m.status = completed.result.Notice
|
m.status = completed.result.Notice
|
||||||
@@ -202,7 +221,26 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case !done.caps.HTTP && !done.caps.SOCKS5:
|
case !done.caps.HTTP && !done.caps.SOCKS5:
|
||||||
m.status = "Protocol Probe: unrecognized protocol at " + done.address
|
m.status = "Protocol Probe: unrecognized protocol at " + done.address
|
||||||
default:
|
default:
|
||||||
rewritten, err := probe.URL(m.form.profile.SingleURL, done.caps)
|
raw := m.form.profile.SingleURL
|
||||||
|
if done.caps.HTTP && done.caps.SOCKS5 {
|
||||||
|
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
|
||||||
|
if err != nil {
|
||||||
|
m.status = "Protocol Probe: " + err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
|
||||||
|
if err != nil {
|
||||||
|
m.status = "Protocol Probe: " + err.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.form.profile.Mode = profile.AdvancedMode
|
||||||
|
m.form.profile.HTTPURL = httpURL
|
||||||
|
m.form.profile.HTTPSURL = httpURL
|
||||||
|
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
|
||||||
|
m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
rewritten, err := probe.URL(raw, done.caps)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.status = "Protocol Probe: " + err.Error()
|
m.status = "Protocol Probe: " + err.Error()
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -514,10 +552,7 @@ func probeAddress(raw string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) selectedProfile() (profile.Profile, bool) {
|
func (m Model) selectedProfile() (profile.Profile, bool) {
|
||||||
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
|
return profileAt(m.snapshot, m.selected)
|
||||||
return profile.Profile{}, false
|
|
||||||
}
|
|
||||||
return m.snapshot.Profiles[m.selected], true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) selectName(name string) {
|
func (m *Model) selectName(name string) {
|
||||||
@@ -530,152 +565,25 @@ func (m *Model) selectName(name string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
switch m.page {
|
return m.presentation.Render(presentationState{
|
||||||
case profilesPage:
|
snapshot: m.snapshot,
|
||||||
return m.profilesView()
|
page: m.page,
|
||||||
case formPage:
|
width: m.width,
|
||||||
return m.formView()
|
height: m.height,
|
||||||
case helpPage:
|
selected: m.selected,
|
||||||
return m.helpView()
|
form: m.form,
|
||||||
default:
|
confirm: m.confirm,
|
||||||
return m.mainView()
|
targetConfirm: m.targetConfirm != nil,
|
||||||
}
|
profileConfirm: m.profileConfirm != nil,
|
||||||
|
status: m.status,
|
||||||
|
applying: m.applying,
|
||||||
|
profileTransition: m.profileTransition,
|
||||||
|
progressTarget: m.progressTarget,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) availableTargets() []app.TargetSnapshot {
|
func (m Model) availableTargets() []app.TargetSnapshot {
|
||||||
var result []app.TargetSnapshot
|
return availableTargets(m.snapshot)
|
||||||
for _, item := range m.snapshot.Targets {
|
|
||||||
if item.Available {
|
|
||||||
result = append(result, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) mainView() string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("proxyctl — Target panel\n\n")
|
|
||||||
if m.snapshot.ActiveProfile == "" {
|
|
||||||
b.WriteString("No Active Profile\n")
|
|
||||||
b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n")
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile)
|
|
||||||
}
|
|
||||||
targets := m.availableTargets()
|
|
||||||
if len(targets) == 0 {
|
|
||||||
b.WriteString("\nNo Targets are currently Available. Press ? to review detection reasons.\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("\nTargets\n")
|
|
||||||
for i, item := range targets {
|
|
||||||
cursor := " "
|
|
||||||
if i == m.selected {
|
|
||||||
cursor = "> "
|
|
||||||
}
|
|
||||||
state := string(item.State)
|
|
||||||
if (m.progressTarget != "" && item.ID == m.progressTarget) || (!m.profileTransition && m.progressTarget == "" && m.applying && i == m.selected) {
|
|
||||||
state = "Applying…"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name)
|
|
||||||
}
|
|
||||||
if m.selected >= 0 && m.selected < len(targets) {
|
|
||||||
selected := targets[m.selected]
|
|
||||||
fmt.Fprintf(&b, "\n%s\n%s\n%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.targetConfirm != nil && !m.applying {
|
|
||||||
b.WriteString("\ny: confirm n/Esc: cancel\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles r: refresh ?: help q/Esc: quit\n")
|
|
||||||
}
|
|
||||||
if m.status != "" {
|
|
||||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) helpView() string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("proxyctl Help — built-in Targets\n\n")
|
|
||||||
for _, item := range m.snapshot.Targets {
|
|
||||||
fmt.Fprintf(&b, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details)
|
|
||||||
if item.Available {
|
|
||||||
fmt.Fprintf(&b, "Detected: %s\n\n", item.AvailabilityEvidence)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&b, "Not detected: %s\n\n", item.AvailabilityEvidence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString("Keys: ↑/k and ↓/j navigate; Space toggles; p manages Proxy Profiles; r refreshes; ? opens help; q/Escape returns or exits.\n")
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) profilesView() string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("Proxy Profiles\n\n")
|
|
||||||
if len(m.snapshot.Profiles) == 0 {
|
|
||||||
b.WriteString("No Proxy Profiles. Press n to create one.\n")
|
|
||||||
}
|
|
||||||
for i, item := range m.snapshot.Profiles {
|
|
||||||
cursor, active := " ", " "
|
|
||||||
if i == m.selected {
|
|
||||||
cursor = "> "
|
|
||||||
}
|
|
||||||
if item.Name == m.snapshot.ActiveProfile {
|
|
||||||
active = "*"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name)
|
|
||||||
}
|
|
||||||
if selected, ok := m.selectedProfile(); ok {
|
|
||||||
b.WriteString("\nSelected Profile\n")
|
|
||||||
fmt.Fprintf(&b, "Mode: %s\n", selected.Mode)
|
|
||||||
if selected.Mode == profile.SingleMode {
|
|
||||||
fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL))
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n",
|
|
||||||
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", "))
|
|
||||||
}
|
|
||||||
if m.profileConfirm != nil {
|
|
||||||
b.WriteString("\ny: confirm n/Esc: cancel\n")
|
|
||||||
} else if m.confirm {
|
|
||||||
if len(m.snapshot.Profiles) == 1 {
|
|
||||||
b.WriteString("\nDelete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted. y: confirm n/Esc: cancel\n")
|
|
||||||
} else {
|
|
||||||
b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n")
|
|
||||||
}
|
|
||||||
if m.status != "" {
|
|
||||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) formView() string {
|
|
||||||
var b strings.Builder
|
|
||||||
action := "Create"
|
|
||||||
if m.form.originalName != "" {
|
|
||||||
action = "Edit"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode)
|
|
||||||
labels := m.form.labels()
|
|
||||||
for i, label := range labels {
|
|
||||||
cursor := " "
|
|
||||||
if i == m.form.field {
|
|
||||||
cursor = "> "
|
|
||||||
}
|
|
||||||
value := m.form.valueAt(i)
|
|
||||||
if label != "Name" && label != "Bypass" {
|
|
||||||
value = profile.ObscureURL(value)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value)
|
|
||||||
}
|
|
||||||
b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Ctrl+D: detect protocol Esc: cancel\n")
|
|
||||||
if m.status != "" {
|
|
||||||
fmt.Fprintf(&b, "\n%s\n", m.status)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f formState) labels() []string {
|
func (f formState) labels() []string {
|
||||||
|
|||||||
@@ -395,6 +395,34 @@ func TestProtocolProbeOnlyTriggersOnSingleURLField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProtocolProbeDetectsDualProtocolEndpointBuildsCompatibleAdvancedProfile(t *testing.T) {
|
||||||
|
module := &recordingModule{}
|
||||||
|
model := NewModel(module)
|
||||||
|
model.detect = func(_ context.Context, address string) (probe.Capabilities, error) {
|
||||||
|
return probe.Capabilities{HTTP: true, SOCKS5: true}, nil
|
||||||
|
}
|
||||||
|
model = updateKey(model, "p")
|
||||||
|
model = updateKey(model, "n")
|
||||||
|
model = updateRunes(model, "home")
|
||||||
|
model = updateKey(model, "tab")
|
||||||
|
model = updateRunes(model, "localhost:7890")
|
||||||
|
|
||||||
|
model, cmd := pressCtrlD(model)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("ctrl+d did not start a Protocol Probe")
|
||||||
|
}
|
||||||
|
updated, _ := model.Update(cmd())
|
||||||
|
model = updated.(Model)
|
||||||
|
|
||||||
|
got := model.form.profile
|
||||||
|
if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://localhost:7890" || got.HTTPSURL != "http://localhost:7890" || got.AllURL != "socks5://localhost:7890" {
|
||||||
|
t.Fatalf("dual-protocol Profile = %#v", got)
|
||||||
|
}
|
||||||
|
if view := model.View(); !strings.Contains(view, "Protocol Probe: detected HTTP and SOCKS5 proxy at localhost:7890") {
|
||||||
|
t.Fatalf("form view missing dual-protocol detection status:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProtocolProbeDetectsSOCKS5AndRewritesSingleURL(t *testing.T) {
|
func TestProtocolProbeDetectsSOCKS5AndRewritesSingleURL(t *testing.T) {
|
||||||
module := &recordingModule{}
|
module := &recordingModule{}
|
||||||
model := NewModel(module)
|
model := NewModel(module)
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
if model.targetConfirm && !model.applying {
|
||||||
|
page.footer = "y: confirm n/Esc: cancel"
|
||||||
|
}
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
return list.String(), details.String(), model.selected + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p terminalPresentation) helpView(model presentationState) terminalPage {
|
||||||
|
var body strings.Builder
|
||||||
|
for _, item := range model.snapshot.Targets {
|
||||||
|
fmt.Fprintf(&body, "%s\n%s\n%s\n", p.theme.section.Render(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terminalPage{
|
||||||
|
header: p.theme.title.Render("proxyctl Help — built-in Targets"),
|
||||||
|
primary: strings.TrimRight(body.String(), "\n"),
|
||||||
|
footer: "↑/k ↓/j: navigate Space: toggle p: manage Proxy Profiles\nr: refresh ?: help q/Escape: back",
|
||||||
|
focusLine: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p terminalPresentation) profilesView(model presentationState) terminalPage {
|
||||||
|
var list strings.Builder
|
||||||
|
if len(model.snapshot.Profiles) == 0 {
|
||||||
|
list.WriteString("No Proxy Profiles. Press n to create one.")
|
||||||
|
}
|
||||||
|
for i, item := range model.snapshot.Profiles {
|
||||||
|
cursor, active := " ", " "
|
||||||
|
if i == model.selected {
|
||||||
|
cursor = "> "
|
||||||
|
}
|
||||||
|
if item.Name == model.snapshot.ActiveProfile {
|
||||||
|
active = "*"
|
||||||
|
}
|
||||||
|
if list.Len() > 0 {
|
||||||
|
list.WriteByte('\n')
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&list, "%s%s %s", cursor, active, item.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
context := selectedProfileDetails(p, model)
|
||||||
|
footer := "n: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back"
|
||||||
|
switch {
|
||||||
|
case model.profileConfirm:
|
||||||
|
footer = "y: confirm n/Esc: cancel"
|
||||||
|
case model.confirm:
|
||||||
|
footer = "y: confirm n/Esc: cancel"
|
||||||
|
if len(model.snapshot.Profiles) == 1 {
|
||||||
|
context = "Delete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted."
|
||||||
|
} else {
|
||||||
|
context = "Delete Profile?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return terminalPage{
|
||||||
|
header: p.theme.title.Render("Proxy Profiles"),
|
||||||
|
primary: list.String(),
|
||||||
|
context: context,
|
||||||
|
footer: footer,
|
||||||
|
focusLine: model.selected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedProfileDetails(p terminalPresentation, model presentationState) string {
|
||||||
|
selected, ok := profileAt(model.snapshot, model.selected)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var details strings.Builder
|
||||||
|
details.WriteString(p.theme.section.Render("Selected Profile"))
|
||||||
|
fmt.Fprintf(&details, "\nMode: %s", selected.Mode)
|
||||||
|
if selected.Mode == profile.SingleMode {
|
||||||
|
fmt.Fprintf(&details, "\nProxy: %s", profile.ObscureURL(selected.SingleURL))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&details, "\nHTTP: %s\nHTTPS: %s\nALL/SOCKS: %s",
|
||||||
|
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&details, "\nBypass: %s", strings.Join(selected.Bypass, ", "))
|
||||||
|
return details.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p terminalPresentation) formView(model presentationState) terminalPage {
|
||||||
|
action := "Create"
|
||||||
|
if model.form.originalName != "" {
|
||||||
|
action = "Edit"
|
||||||
|
}
|
||||||
|
var fields strings.Builder
|
||||||
|
for i, label := range model.form.labels() {
|
||||||
|
cursor := " "
|
||||||
|
if i == model.form.field {
|
||||||
|
cursor = "> "
|
||||||
|
}
|
||||||
|
value := model.form.valueAt(i)
|
||||||
|
if label != "Name" && label != "Bypass" {
|
||||||
|
value = profile.ObscureURL(value)
|
||||||
|
}
|
||||||
|
if fields.Len() > 0 {
|
||||||
|
fields.WriteByte('\n')
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&fields, "%s%s: %s", cursor, label, value)
|
||||||
|
}
|
||||||
|
return terminalPage{
|
||||||
|
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)),
|
||||||
|
primary: fields.String(),
|
||||||
|
footer: "Tab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Ctrl+D: detect protocol Esc: cancel",
|
||||||
|
focusLine: model.form.field,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"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
|
||||||
|
form formState
|
||||||
|
confirm bool
|
||||||
|
targetConfirm bool
|
||||||
|
profileConfirm bool
|
||||||
|
status string
|
||||||
|
applying bool
|
||||||
|
profileTransition bool
|
||||||
|
progressTarget 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
|
||||||
|
focusLine int
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
case helpPage:
|
||||||
|
page = p.helpView(state)
|
||||||
|
default:
|
||||||
|
page = p.mainView(state, width, height)
|
||||||
|
}
|
||||||
|
page.status = state.status
|
||||||
|
page = redactTerminalPage(page)
|
||||||
|
return p.renderShell(page, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactTerminalPage(page terminalPage) terminalPage {
|
||||||
|
page.header = profile.RedactText(page.header)
|
||||||
|
page.primary = profile.RedactText(page.primary)
|
||||||
|
page.context = profile.RedactText(page.context)
|
||||||
|
page.footer = profile.RedactText(page.footer)
|
||||||
|
page.status = profile.RedactText(page.status)
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
status = viewportContent(wrapped, width, min(2, lineCount(wrapped)), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if page.context == "" || height < 5 {
|
||||||
|
return viewportContent(page.primary, width, height, page.focusLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
contextHeight := min(lineCount(page.context), max(3, height/2))
|
||||||
|
primaryHeight := height - contextHeight - 1
|
||||||
|
if primaryHeight < 1 {
|
||||||
|
primaryHeight = 1
|
||||||
|
contextHeight = max(1, height-primaryHeight-1)
|
||||||
|
}
|
||||||
|
primary := viewportContent(page.primary, width, primaryHeight, page.focusLine)
|
||||||
|
context := viewportContent(page.context, width, contextHeight, 0)
|
||||||
|
return primary + "\n\n" + context
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewportContent(content string, width, height, focusLine int) string {
|
||||||
|
if content == "" || width <= 0 || height <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
view := viewport.New(width, height)
|
||||||
|
view.SetContent(strings.TrimRight(content, "\n"))
|
||||||
|
if focusLine >= height {
|
||||||
|
view.SetYOffset(focusLine - height + 1)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"proxyctl/internal/app"
|
||||||
|
"proxyctl/internal/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
func availableTargets(snapshot app.Snapshot) []app.TargetSnapshot {
|
||||||
|
result := make([]app.TargetSnapshot, 0, len(snapshot.Targets))
|
||||||
|
for _, item := range snapshot.Targets {
|
||||||
|
if item.Available {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func profileAt(snapshot app.Snapshot, index int) (profile.Profile, bool) {
|
||||||
|
if index < 0 || index >= len(snapshot.Profiles) {
|
||||||
|
return profile.Profile{}, false
|
||||||
|
}
|
||||||
|
return snapshot.Profiles[index], true
|
||||||
|
}
|
||||||
@@ -573,7 +573,7 @@ func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T
|
|||||||
updated, _ = model.Update(command())
|
updated, _ = model.Update(command())
|
||||||
model = updated.(Model)
|
model = updated.(Model)
|
||||||
view := model.View()
|
view := model.View()
|
||||||
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "\x1b[31mError:") || !strings.Contains(view, "REDACTED") {
|
if !strings.Contains(view, "Enabled") || !strings.Contains(view, "Error:") || !strings.Contains(view, "REDACTED") {
|
||||||
t.Fatalf("failed operation rendering:\n%s", view)
|
t.Fatalf("failed operation rendering:\n%s", view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
|
||||||
|
"proxyctl/internal/target"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSemanticTheme(mode themeMode, output io.Writer) semanticTheme {
|
||||||
|
if output == nil {
|
||||||
|
output = os.Stdout
|
||||||
|
}
|
||||||
|
renderer := lipgloss.NewRenderer(output)
|
||||||
|
explicit := mode != themeModeAuto
|
||||||
|
if _, noColor := os.LookupEnv("NO_COLOR"); noColor && mode == themeModeAuto {
|
||||||
|
mode = themeModeNoColor
|
||||||
|
explicit = true
|
||||||
|
}
|
||||||
|
if mode == themeModeAuto {
|
||||||
|
if renderer.ColorProfile() == termenv.Ascii {
|
||||||
|
mode = themeModeNoColor
|
||||||
|
} else if renderer.HasDarkBackground() {
|
||||||
|
mode = themeModeDark
|
||||||
|
} else {
|
||||||
|
mode = themeModeLight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == themeModeNoColor {
|
||||||
|
renderer.SetColorProfile(termenv.Ascii)
|
||||||
|
} else if explicit {
|
||||||
|
// Explicit modes make fixed-size render tests deterministic. Production
|
||||||
|
// auto mode retains capabilities detected from the injected output.
|
||||||
|
renderer.SetColorProfile(termenv.ANSI256)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreground, subtle, accent := "252", "245", "75"
|
||||||
|
if mode == themeModeLight {
|
||||||
|
foreground, subtle, accent = "235", "242", "25"
|
||||||
|
}
|
||||||
|
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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t semanticTheme) renderTargetState(state target.State, applying bool) string {
|
||||||
|
if applying {
|
||||||
|
return t.applying.Render("Applying…")
|
||||||
|
}
|
||||||
|
label := string(state)
|
||||||
|
switch state {
|
||||||
|
case target.Enabled:
|
||||||
|
return t.enabled.Render(paddedState(label))
|
||||||
|
case target.Disabled:
|
||||||
|
return t.disabled.Render(paddedState(label))
|
||||||
|
case target.External:
|
||||||
|
return t.external.Render(paddedState(label))
|
||||||
|
case target.Drifted:
|
||||||
|
return t.drifted.Render(paddedState(label))
|
||||||
|
default:
|
||||||
|
return paddedState(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paddedState(label string) string {
|
||||||
|
return lipgloss.NewStyle().Width(9).Render(label)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user