feat: fix proxy compatibility and establish TUI foundation
This commit is contained in:
@@ -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