feat: fix proxy compatibility and establish TUI foundation
This commit is contained in:
+37
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
"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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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