feat: fix proxy compatibility and establish TUI foundation

This commit is contained in:
proxyctl coordinator
2026-07-17 20:30:52 +08:00
parent 0f4c122eff
commit daeb788eac
34 changed files with 1521 additions and 176 deletions
+37 -3
View File
@@ -21,8 +21,14 @@ import (
)
const (
overlayName = "99proxyctl"
overlayHeader = "// Generated by proxyctl. Do not edit.\n"
// overlayName sorts after digit-prefixed and common letter-named fragments
// (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
@@ -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)
if mutationErr != nil {
mutationErr = fmt.Errorf("write managed APT overlay: %w", mutationErr)
} else {
mutationErr = a.removeLegacyOverlay()
}
} else {
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)
}
}
if mutationErr == nil {
mutationErr = a.removeLegacyOverlay()
}
}
inspection, inspectErr := a.inspect(ctx, active)
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)}
}
// 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) {
command, err := a.runner.LookPath("apt-config")
if err != nil {
@@ -293,7 +327,7 @@ func translate(input profile.Profile) (translation, error) {
}
for _, candidate := range []struct {
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 == "" {
continue
}
+72 -3
View File
@@ -37,7 +37,7 @@ func TestAvailabilityRequiresDebianMarkerAPTToolAndConfigurationDirectory(t *tes
mustWrite(t, filepath.Join(root, "etc", "debian_version"), "12\n")
}
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())
if got.Available != tc.want || got.Evidence == "" {
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) {
files := &failingFS{}
a, _ := newFailingAdapter(t, files)
@@ -268,7 +321,7 @@ func TestIncompatibleProtocolOrBypassPreventsMutationAndNamesValue(t *testing.T)
value string
}{
{"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"},
{"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"},
@@ -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) {
a, _ := newAdapter(t, nil)
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 {
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()
return a, runner
}
+2 -2
View File
@@ -256,8 +256,8 @@ func compatible(input profile.Profile) (profile.Profile, error) {
if err != nil {
return profile.Profile{}, err
}
if p.Mode == profile.AdvancedMode && p.AllURL != "" {
return profile.Profile{}, errors.New("Docker daemon Target does not support an ALL/SOCKS proxy; remove it before applying")
if p.Mode == profile.AdvancedMode && p.AllURL != "" && (p.HTTPURL == "" || p.HTTPSURL == "") {
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} {
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) {
for _, p := range []*profile.Profile{
{Name: "socks", Mode: profile.SingleMode, SingleURL: "socks5://proxy.example:1080"},
+3 -3
View File
@@ -28,18 +28,18 @@ func TestConfigurationTargetsSudoInvokingUsersSessionAndState(t *testing.T) {
if err != nil {
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)
}
}
func TestConfigurationKeepsDirectUsersSafeAbsoluteXDGPaths(t *testing.T) {
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 {
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)
}
}
+7 -2
View File
@@ -168,7 +168,9 @@ func ResolveConfiguration(environment profile.IdentityEnvironment) (Configuratio
if stateDir == "" {
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")
if value, ok := environment.LookupEnv("XDG_DATA_HOME"); ok && filepath.IsAbs(value) && (!invoking || pathWithin(value, owner.HomeDir)) {
dataDir = value
@@ -691,7 +693,7 @@ func translate(input profile.Profile) (settings, error) {
if err != nil {
return settings{}, err
}
result := settings{Mode: "manual"}
result := settings{Mode: "manual", IgnoreHosts: []string{}}
if p.Mode == profile.SingleMode {
if result.SocksHost, result.SocksPort, _, _, err = endpoint(p.SingleURL, "Single proxy URL", "socks5", false); err != nil {
return settings{}, err
@@ -874,6 +876,9 @@ func parseQuoted(raw string) (string, int, error) {
func parseStrings(raw string) ([]string, error) {
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] != ']' {
return nil, fmt.Errorf("invalid gsettings string array %q", raw)
}
+23 -1
View File
@@ -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{
"org.gnome.system.proxy mode": "'none'",
"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 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",
@@ -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) {
r, s := newRunner(), &memoryState{}
p := *active()
+303
View File
@@ -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
View File
@@ -3,7 +3,6 @@ package tui
import (
"context"
"errors"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
@@ -33,8 +32,11 @@ type formState struct {
type Model struct {
application app.Module
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
form formState
confirm bool
@@ -84,8 +86,19 @@ func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
}
}
func NewModel(application app.Module) Model {
return Model{application: application, snapshot: application.Snapshot(), detect: probe.Detect}
func NewModel(application app.Module, configure ...ModelOption) Model {
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 }
@@ -102,6 +115,12 @@ func (m Model) operationInFlight() bool {
}
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
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
// 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.snapshot = completed.result.Snapshot
if completed.result.Err != nil {
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
m.status = "Error: " + completed.result.Err.Error()
} else {
m.status = completed.result.Notice
}
@@ -156,7 +175,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, m.deferredQuit()
}
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 != "" {
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
} else if completed.result.Err != nil {
m.targetConfirm = nil
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
m.status = "Error: " + completed.result.Err.Error()
} else {
m.targetConfirm = nil
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:
m.status = "Protocol Probe: unrecognized protocol at " + done.address
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 {
m.status = "Protocol Probe: " + err.Error()
return m, nil
@@ -514,10 +552,7 @@ func probeAddress(raw string) (string, error) {
}
func (m Model) selectedProfile() (profile.Profile, bool) {
if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) {
return profile.Profile{}, false
}
return m.snapshot.Profiles[m.selected], true
return profileAt(m.snapshot, m.selected)
}
func (m *Model) selectName(name string) {
@@ -530,152 +565,25 @@ func (m *Model) selectName(name string) {
}
func (m Model) View() string {
switch m.page {
case profilesPage:
return m.profilesView()
case formPage:
return m.formView()
case helpPage:
return m.helpView()
default:
return m.mainView()
}
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
form: m.form,
confirm: m.confirm,
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 {
var result []app.TargetSnapshot
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()
return availableTargets(m.snapshot)
}
func (f formState) labels() []string {
+28
View File
@@ -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) {
module := &recordingModule{}
model := NewModel(module)
+176
View File
@@ -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,
}
}
+214
View File
@@ -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)
}
+23
View File
@@ -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
}
+1 -1
View File
@@ -573,7 +573,7 @@ func TestTargetFailurePreservesTruthfulStateAndRendersRedactedError(t *testing.T
updated, _ = model.Update(command())
model = updated.(Model)
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)
}
}
+93
View File
@@ -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)
}