Merge mature Profile editor with contextual Help

This commit is contained in:
proxyctl coordinator
2026-07-18 00:12:54 +08:00
12 changed files with 838 additions and 193 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ func TestCompleteModelResizePreservesValidSelectionAndFormFocus(t *testing.T) {
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")
assertUsableFrame(t, view, size.width, size.height, "> Bypass [focused]:", "Ctrl+S: save")
}
}
+8 -5
View File
@@ -7,6 +7,7 @@ import (
"github.com/charmbracelet/lipgloss"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
// helpDialogState is an overlay, not a page. Keeping only its viewport state
@@ -132,12 +133,14 @@ func (p terminalPresentation) contextualHelp(model presentationState) string {
}
case model.page == formPage:
body.WriteString(p.theme.section.Render("Profile form"))
labels := model.form.labels()
if model.form.field >= 0 && model.form.field < len(labels) {
fmt.Fprintf(&body, "\nFocus: %s field.\n", labels[model.form.field])
if model.form.field >= 0 && model.form.field < len(model.form.inputs) {
fmt.Fprintf(&body, "\nFocus: %s field.\n", model.form.inputs[model.form.field].label)
}
body.WriteString("Type to append text and Backspace to delete. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode. Ctrl+S saves. q or Escape cancels and returns to Profile management.\n")
if model.form.profile.Mode == "single" && model.form.field == 1 {
body.WriteString("Type or paste to insert at the cursor. Left/Right moves the cursor; Home/End moves to the field boundary; Backspace/Delete removes text. Long inputs scroll horizontally. Tab or Down moves to the next field; Shift+Tab or Up moves to the previous field. Ctrl+T changes mode while preserving Profile values. Ctrl+S validates and saves. q or Escape cancels and returns to Profile management.\n")
if model.form.currentField() == profile.BypassField {
body.WriteString("PgUp/PgDn scrolls the normalized Bypass preview while the Bypass field is focused.\n")
}
if model.form.profile.Mode == profile.SingleMode && model.form.currentField() == profile.SingleURLField {
if model.form.probing {
body.WriteString("Protocol Probe is in progress; Ctrl+D does not start a duplicate probe.\n")
} else {
+49
View File
@@ -1,6 +1,7 @@
package tui
import (
"fmt"
"strings"
"testing"
@@ -333,3 +334,51 @@ func TestCompleteModelFooterHintsMatchContextAndAcceptedKeys(t *testing.T) {
close(updates)
})
}
func TestCompleteModelHelpOverEditorPreservesFieldCursorPreviewAndResizeState(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "http://proxy.example:8080")
model = updateKey(model, "tab")
items := make([]string, 24)
for i := range items {
items[i] = fmt.Sprintf("host-%02d.example", i)
}
model = updateRunes(model, strings.Join(items, ","))
for range 7 {
model = editorKey(model, tea.KeyLeft)
}
for range 3 {
model = updateKey(model, "pgdown")
}
field := model.form.field
cursor := model.form.inputs[field].input.Position()
previewLine := model.form.bypassPreviewLine
values := make([]string, len(model.form.inputs))
for i := range model.form.inputs {
values[i] = model.form.inputs[i].input.Value()
}
if previewLine == 0 {
t.Fatal("fixture did not scroll the Bypass preview")
}
model = updateKey(model, "?")
model = updateKey(model, "pgdown")
model = resize(model, 60, 16)
model = resize(model, 120, 36)
model = resize(model, 80, 24)
model = updateKey(model, "esc")
if model.page != formPage || model.form.field != field || model.form.inputs[field].input.Position() != cursor || model.form.bypassPreviewLine != previewLine {
t.Fatalf("Help or resize changed editor state: page=%d field=%d cursor=%d preview=%d", model.page, model.form.field, model.form.inputs[field].input.Position(), model.form.bypassPreviewLine)
}
for i := range values {
if got := model.form.inputs[i].input.Value(); got != values[i] {
t.Fatalf("Help or resize changed field %d: got %q want %q", i, got, values[i])
}
}
assertUsableFrame(t, model.View(), 80, 24, "> Bypass [focused]", "PgUp/PgDn: scroll form preview")
}
+40 -102
View File
@@ -21,14 +21,6 @@ const (
formPage
)
type formState struct {
originalName string
profile profile.Profile
field int
probing bool
probeGen uint64
}
type Model struct {
application app.InteractiveModule
presentation terminalPresentation
@@ -211,6 +203,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
m.form.profile.HTTPURL = httpURL
m.form.profile.HTTPSURL = httpURL
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
m.form.advancedInitialized = true
m.form.rebuildInputs(1)
m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address
return m, nil
}
@@ -220,6 +214,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.form.profile.SingleURL = rewritten
m.form.rebuildInputs(1)
kind := "SOCKS5"
if done.caps.HTTP {
kind = "HTTP"
@@ -500,7 +495,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if !m.profileMutationAvailable("creating a Proxy Profile") {
break
}
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
m.form = newFormState("", profile.Profile{Mode: profile.SingleMode})
m.page = formPage
m.status = ""
case "e", "enter":
@@ -508,7 +503,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
break
}
if selected, ok := m.selectedProfile(); ok {
m.form = formState{originalName: selected.Name, profile: selected}
m.form = newFormState(selected.Name, selected)
m.page = formPage
m.status = ""
}
@@ -554,19 +549,39 @@ func newProfileDeleteConfirmation(selected profile.Profile, final, active bool)
}
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "q", "esc":
if m.form.currentField() == profile.BypassField {
switch key.String() {
case "pgup":
m.form.scrollBypassPreview(-5)
return m, nil
case "pgdown":
m.form.scrollBypassPreview(5)
return m, nil
}
}
if key.String() == "q" || key.Type == tea.KeyEsc {
m.page = profilesPage
m.status = ""
m.form = formState{}
return m, nil
case "ctrl+s":
}
switch key.Type {
case tea.KeyCtrlS:
m.form.syncFocusedValue()
m.form.refreshValidation()
if err := m.form.validation.Err(); err != nil || m.form.nameConflict(m.snapshot.Profiles) != "" {
m.status = "Fix the highlighted Profile fields before saving"
m.form.probeGen++
m.form.probing = false
return m, nil
}
kind := app.CreateProfile
if m.form.originalName != "" {
kind = app.UpdateProfile
}
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
result := m.applyProfileMutation(intent, intent.Profile.Name)
normalized := m.form.validation.Normalized
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized}
result := m.applyProfileMutation(intent, normalized.Name)
if result.Err != nil {
m.form.probeGen++ // discard any in-flight probe result while staying on the form
m.form.probing = false
@@ -575,12 +590,12 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
m.page = profilesPage
m.form = formState{}
return m, nil
case "ctrl+d":
case tea.KeyCtrlD:
if m.form.profile.Mode != profile.SingleMode {
m.status = "Protocol Probe: available in Single mode only"
return m, nil
}
if m.form.field != 1 || m.form.probing {
if m.form.currentField() != profile.SingleURLField || m.form.probing {
return m, nil
}
address, err := probeAddress(m.form.profile.SingleURL)
@@ -597,34 +612,15 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
caps, err := detect(context.Background(), address)
return probeDone{address: address, caps: caps, err: err, gen: gen}
}
case "ctrl+t":
if m.form.profile.Mode == profile.SingleMode {
m.form.profile.Mode = profile.AdvancedMode
m.form.profile.HTTPURL = m.form.profile.SingleURL
m.form.profile.HTTPSURL = m.form.profile.SingleURL
m.form.profile.AllURL = m.form.profile.SingleURL
} else {
m.form.profile.Mode = profile.SingleMode
}
m.form.field = 0
return m, nil
case "tab", "down":
m.form.field = (m.form.field + 1) % m.form.fieldCount()
return m, nil
case "shift+tab", "up":
m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount()
return m, nil
case "backspace":
value := []rune(m.form.value())
if len(value) > 0 {
m.form.setValue(string(value[:len(value)-1]))
}
return m, nil
case tea.KeyCtrlT:
return m, m.form.toggleMode()
case tea.KeyTab, tea.KeyDown:
return m, m.form.move(1)
case tea.KeyShiftTab, tea.KeyUp:
return m, m.form.move(-1)
default:
return m, m.form.updateInput(key)
}
if key.Type == tea.KeyRunes {
m.form.setValue(m.form.value() + string(key.Runes))
}
return m, nil
}
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
@@ -754,61 +750,3 @@ func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
}
func (f formState) labels() []string {
if f.profile.Mode == profile.AdvancedMode {
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
}
return []string{"Name", "Single proxy URL", "Bypass"}
}
func (f formState) fieldCount() int { return len(f.labels()) }
func (f formState) value() string { return f.valueAt(f.field) }
func (f formState) valueAt(index int) string {
if index == 0 {
return f.profile.Name
}
if f.profile.Mode == profile.AdvancedMode {
switch index {
case 1:
return f.profile.HTTPURL
case 2:
return f.profile.HTTPSURL
case 3:
return f.profile.AllURL
default:
return strings.Join(f.profile.Bypass, ", ")
}
}
if index == 1 {
return f.profile.SingleURL
}
return strings.Join(f.profile.Bypass, ", ")
}
func (f *formState) setValue(value string) {
if f.field == 0 {
f.profile.Name = value
return
}
if f.profile.Mode == profile.AdvancedMode {
switch f.field {
case 1:
f.profile.HTTPURL = value
case 2:
f.profile.HTTPSURL = value
case 3:
f.profile.AllURL = value
default:
f.profile.Bypass = []string{value}
}
return
}
if f.field == 1 {
f.profile.SingleURL = value
} else {
f.profile.Bypass = []string{value}
}
}
+12 -11
View File
@@ -87,8 +87,8 @@ func TestProfileFormMapsKeyboardInputToCreateIntent(t *testing.T) {
if intent.Kind != app.CreateProfile || intent.Profile.Name != "home" || intent.Profile.SingleURL != "socks5://localhost:1080" {
t.Fatalf("create form dispatched %#v", intent)
}
if got := intent.Profile.Bypass; len(got) != 1 || got[0] != "localhost, .example.com" {
t.Fatalf("create form Bypass = %#v", got)
if got := intent.Profile.Bypass; len(got) != 2 || got[0] != "localhost" || got[1] != ".example.com" {
t.Fatalf("create form normalized Bypass = %#v", got)
}
}
@@ -487,15 +487,16 @@ func TestProtocolProbeInAdvancedModeShowsHintWithoutProbing(t *testing.T) {
func updateKey(model Model, key string) Model {
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
switch key {
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
case "ctrl+s":
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
case "ctrl+t":
msg = tea.KeyMsg{Type: tea.KeyCtrlT}
case "backspace":
msg = tea.KeyMsg{Type: tea.KeyBackspace}
special := map[string]tea.KeyType{
"tab": tea.KeyTab, "shift+tab": tea.KeyShiftTab,
"enter": tea.KeyEnter, "esc": tea.KeyEsc,
"up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,
"home": tea.KeyHome, "end": tea.KeyEnd, "delete": tea.KeyDelete, "backspace": tea.KeyBackspace,
"pgup": tea.KeyPgUp, "pgdown": tea.KeyPgDown,
"ctrl+d": tea.KeyCtrlD, "ctrl+s": tea.KeyCtrlS, "ctrl+t": tea.KeyCtrlT, "ctrl+u": tea.KeyCtrlU,
}
if keyType, ok := special[key]; ok {
msg = tea.KeyMsg{Type: keyType}
}
updated, _ := model.Update(msg)
return updated.(Model)
+42 -16
View File
@@ -221,23 +221,46 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
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)
}
var fields, bypassPreview strings.Builder
focusLine, currentLine := 0, 0
for i, item := range model.form.inputs {
if fields.Len() > 0 {
fields.WriteByte('\n')
currentLine++
}
cursor, focus := " ", ""
if i == model.form.field {
cursor, focus = "> ", " [focused]"
focusLine = currentLine
}
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, model.form.renderedInput(i, model.width))
validation := model.form.validationText(i)
if item.field == profile.NameField {
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
validation = conflict
}
}
if validation != "" {
fmt.Fprintf(&fields, "\n Error: %s", validation)
currentLine++
}
if item.field == profile.BypassField {
analysis := model.form.bypassAnalysis()
bypassPreview.WriteString("Normalized Bypass preview:")
if len(analysis) == 0 {
bypassPreview.WriteString(" (none)")
} else {
for _, line := range analysis {
bypassPreview.WriteString("\n " + line)
}
}
}
fmt.Fprintf(&fields, "%s%s: %s", cursor, label, value)
}
secondaryHints := "?: help q/Esc: cancel"
if model.form.profile.Mode == profile.SingleMode && model.form.field == 1 {
if model.form.currentField() == profile.BypassField {
secondaryHints = "PgUp/PgDn: scroll form preview " + secondaryHints
}
if model.form.profile.Mode == profile.SingleMode && model.form.currentField() == profile.SingleURLField {
probeHint := "Ctrl+D: probe"
if model.form.probing {
probeHint = "Protocol Probe in progress"
@@ -246,9 +269,12 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
}
footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints
return terminalPage{
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)),
primary: fields.String(),
footer: footer,
focusLine: model.form.field,
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)),
primary: fields.String(),
context: bypassPreview.String(),
footer: footer,
focusLine: focusLine,
contextAnchor: model.form.bypassPreviewLine,
anchorContext: true,
}
}
+7 -1
View File
@@ -100,6 +100,8 @@ type terminalPage struct {
focusLine int
primaryOffset int
contextOffset int
contextAnchor int
anchorContext bool
dashboard *dashboardRenderState
}
@@ -236,7 +238,11 @@ func renderPageBody(page terminalPage, width, height int) string {
contextHeight = max(1, height-primaryHeight-1)
}
primary := viewportContentAt(page.primary, width, primaryHeight, page.primaryOffset, page.focusLine)
context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, -1)
contextFocus := -1
if page.anchorContext {
contextFocus = page.contextAnchor
}
context := viewportContentAt(page.context, width, contextHeight, page.contextOffset, contextFocus)
return primary + "\n\n" + context
}
+293
View File
@@ -0,0 +1,293 @@
package tui
import (
"fmt"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/profile"
)
func editorKey(model Model, key tea.KeyType) Model {
updated, _ := model.Update(tea.KeyMsg{Type: key})
return updated.(Model)
}
func openNewProfileEditor(t *testing.T, module *profileManagementModule) Model {
t.Helper()
model := resizedModel(t, module, themeModeNoColor, 80, 24)
model = updateKey(model, "m")
return updateKey(model, "n")
}
func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "ofice")
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyLeft)
model = updateRunes(model, "f")
model = editorKey(model, tea.KeyHome)
model = updateRunes(model, "x")
model = editorKey(model, tea.KeyBackspace)
model = editorKey(model, tea.KeyEnd)
model = editorKey(model, tea.KeyBackspace)
model = updateRunes(model, "e")
model = updateKey(model, "tab")
model = updateRunes(model, "http://proxy.example:808x0") // multi-rune KeyMsg is Bubble Tea's paste boundary
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyLeft)
model = editorKey(model, tea.KeyDelete)
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save dispatched %d intents, want 1; frame:\n%s", len(module.intents), model.View())
}
got := module.intents[0].Profile
if got.Name != "office" || got.SingleURL != "http://proxy.example:8080" {
t.Fatalf("edited Profile = %#v", got)
}
}
func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const secret = "credential-never-rendered"
const raw = "https://operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443"
model = updateRunes(model, raw)
end := model.View()
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
if strings.Contains(end, secret) || strings.Contains(end, "https://operator") {
t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end)
}
model = editorKey(model, tea.KeyHome)
start := model.View()
assertUsableFrame(t, start, 80, 24, "https://operator:")
if strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") {
t.Fatalf("home did not scroll to the safe beginning of the input:\n%s", start)
}
}
func TestCompleteModelProfileEditorPreservesValuesAcrossSingleAdvancedTransitions(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example:8080")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "tab")
model = editorKey(model, tea.KeyHome)
model = updateRunes(model, "http://")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "ctrl+t")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("save dispatched %d intents; frame:\n%s", len(module.intents), model.View())
}
got := module.intents[0].Profile
if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://proxy.example:8080" || got.HTTPSURL != "http://proxy.example:8080" || got.AllURL != "http://proxy.example:8080" {
t.Fatalf("transition discarded values: %#v", got)
}
}
func TestCompleteModelProfileEditorShowsCanonicalFieldValidationAndLiveBypassAnalysis(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example")
view := model.View()
assertUsableFrame(t, view, 80, 24, "Single proxy URL must include a port")
model = editorKey(model, tea.KeyEnd)
model = updateRunes(model, ":8080")
if view := model.View(); strings.Contains(view, "must include a port") {
t.Fatalf("correcting the URL left a stale field error:\n%s", view)
}
model = updateKey(model, "tab")
model = updateRunes(model, "LOCALHOST, .Example.COM, localhost, bad entry, 10.0.0.7/24")
view = model.View()
assertUsableFrame(t, view, 80, 24,
"Normalized Bypass preview", "localhost", ".example.com", "10.0.0.0/24", "bad entry", "invalid")
if strings.Count(view, "• localhost") != 1 {
t.Fatalf("Bypass preview did not deduplicate localhost:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 0 {
t.Fatalf("invalid Bypass dispatched %#v", module.intents)
}
model = editorKey(model, tea.KeyCtrlU)
model = updateRunes(model, "LOCALHOST, .Example.COM, localhost, 10.0.0.7/24")
if view := model.View(); strings.Contains(view, "bad entry") || strings.Contains(view, "invalid") {
t.Fatalf("correcting Bypass left stale item validation:\n%s", view)
}
model = updateKey(model, "ctrl+s")
if len(module.intents) != 1 {
t.Fatalf("valid save dispatched %d intents; frame:\n%s", len(module.intents), model.View())
}
want := []string{"localhost", ".example.com", "10.0.0.0/24"}
got := module.intents[0].Profile.Bypass
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("saved Bypass = %#v, want %#v", got, want)
}
}
func TestCompleteModelProfileEditorScrollsLongBypassPreviewAt80Columns(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example:8080")
model = updateKey(model, "tab")
items := make([]string, 0, 25)
for i := range 24 {
items = append(items, fmt.Sprintf("host-%02d.example", i))
}
items = append(items, "late invalid entry")
model = updateRunes(model, strings.Join(items, ","))
if strings.Contains(model.View(), "! item 25") {
t.Fatal("fixture did not exceed the 80x24 preview viewport")
}
for range 6 {
model = updateKey(model, "pgdown")
}
model = resize(model, 80, 24)
assertUsableFrame(t, model.View(), 80, 24,
"> Bypass [focused]", "! item 25", "late invalid entry", "invalid", "PgUp/PgDn: scroll form")
model = updateKey(model, "pgup")
view := model.View()
assertUsableFrame(t, view, 80, 24, "> Bypass [focused]", "host-19.example", "PgUp/PgDn: scroll form")
if strings.Contains(view, "! item 25") {
t.Fatalf("PgUp did not move the persistent Bypass preview viewport:\n%s", view)
}
}
func TestCompleteModelProfileEditorObscuresCredentialsInScrolledBypassValidation(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "proxy.example:8080")
model = updateKey(model, "tab")
items := make([]string, 0, 25)
for i := range 24 {
items = append(items, fmt.Sprintf("host-%02d.example", i))
}
const secret = "late-bypass-secret"
items = append(items, "http://operator:"+secret+"@proxy.invalid")
model = updateRunes(model, strings.Join(items, ","))
for range 6 {
model = updateKey(model, "pgdown")
}
for _, size := range []struct{ width, height int }{{80, 24}, {60, 16}, {120, 36}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height, "> Bypass [focused]", "REDACTED@proxy.invalid")
if strings.Contains(view, secret) {
t.Fatalf("scrolled Bypass validation exposed credentials at %dx%d:\n%s", size.width, size.height, view)
}
}
}
func TestCompleteModelProfileEditorObscuresSchemeLessCredentialsDuringEntryValidationAndScroll(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const secret = "scheme-less-secret"
const raw = "operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-scroll.example:notaport"
model = updateRunes(model, raw)
for _, step := range []func(Model) Model{
func(value Model) Model { return value },
func(value Model) Model { return editorKey(value, tea.KeyHome) },
func(value Model) Model { return editorKey(value, tea.KeyEnd) },
func(value Model) Model { return updateKey(value, "ctrl+s") },
func(value Model) Model { return resize(value, 60, 16) },
func(value Model) Model { return resize(value, 80, 24) },
} {
model = step(model)
if view := model.View(); strings.Contains(view, secret) {
t.Fatalf("scheme-less credential rendered during entry, validation, or scroll:\n%s", view)
}
}
if len(module.intents) != 0 {
t.Fatalf("invalid credential-bearing URL dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileEditorShowsNameConflictsInlineAndBlocksContradictorySave(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
}}}}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
model = updateRunes(model, "other.example:8080")
assertUsableFrame(t, model.View(), 80, 24, "Name", "a Profile named \"office\" already exists")
model = updateKey(model, "ctrl+s")
if len(module.intents) != 0 {
t.Fatalf("conflicting name dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileEditorSaveSelectsSavedProfileAndCancelDoesNotPersist(t *testing.T) {
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
Name: "existing", Mode: profile.SingleMode, SingleURL: "http://existing.example:8080",
}}}}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "discarded")
model = updateKey(model, "tab")
model = updateRunes(model, "http://discarded.example:8080")
model = updateKey(model, "esc")
if len(module.intents) != 0 || len(module.snapshot.Profiles) != 1 {
t.Fatalf("cancel mutated persistence: intents=%#v snapshot=%#v", module.intents, module.snapshot)
}
model = updateKey(model, "n")
model = updateRunes(model, "saved")
model = updateKey(model, "tab")
model = updateRunes(model, "saved.example:8080")
model = updateKey(model, "ctrl+s")
assertUsableFrame(t, model.View(), 80, 24, "> saved", "Proxy: http://saved.example:8080")
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
t.Fatalf("save dispatched %#v", module.intents)
}
}
func TestCompleteModelProfileEditorResizeAndErrorsNeverRenderAdversarialCredentials(t *testing.T) {
module := &profileManagementModule{}
model := openNewProfileEditor(t, module)
model = updateRunes(model, "office")
model = updateKey(model, "tab")
const secret = "adversarial-secret"
model = updateRunes(model, "http://alice:"+secret+"@proxy.example:notaport")
model = updateKey(model, "tab")
model = updateRunes(model, "http://bob:"+secret+"@bypass.invalid")
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {60, 16}, {80, 24}} {
model = resize(model, size.width, size.height)
view := model.View()
assertUsableFrame(t, view, size.width, size.height, "Ctrl+S: save", "Esc: cancel")
if strings.Contains(view, secret) {
t.Fatalf("secret rendered after resize to %dx%d:\n%s", size.width, size.height, view)
}
}
}
+251
View File
@@ -0,0 +1,251 @@
package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/profile"
)
type formInput struct {
label string
field profile.Field
input textinput.Model
}
type formDefinition struct {
label string
field profile.Field
value string
}
type formState struct {
originalName string
profile profile.Profile
inputs []formInput
field int
bypassPreviewLine int
validation profile.Validation
advancedInitialized bool
probing bool
probeGen uint64
}
func newFormState(originalName string, value profile.Profile) formState {
if value.Mode == "" {
value.Mode = profile.SingleMode
}
form := formState{
originalName: originalName,
profile: value,
advancedInitialized: value.Mode == profile.AdvancedMode,
}
form.rebuildInputs(0)
return form
}
func (f *formState) rebuildInputs(focus int) tea.Cmd {
f.bypassPreviewLine = 0
definitions := []formDefinition{{"Name", profile.NameField, f.profile.Name}}
if f.profile.Mode == profile.AdvancedMode {
definitions = append(definitions,
formDefinition{"HTTP URL", profile.HTTPURLField, f.profile.HTTPURL},
formDefinition{"HTTPS URL", profile.HTTPSURLField, f.profile.HTTPSURL},
formDefinition{"ALL/SOCKS URL", profile.AllURLField, f.profile.AllURL},
)
} else {
definitions = append(definitions, formDefinition{"Single proxy URL", profile.SingleURLField, f.profile.SingleURL})
}
definitions = append(definitions, formDefinition{"Bypass", profile.BypassField, strings.Join(f.profile.Bypass, ", ")})
f.inputs = make([]formInput, len(definitions))
for i, definition := range definitions {
input := textinput.New()
input.Prompt = ""
input.Placeholder = fieldPlaceholder(definition.field)
input.CharLimit = 4096
input.Width = 48
input.SetValue(definition.value)
input.CursorEnd()
f.inputs[i] = formInput{label: definition.label, field: definition.field, input: input}
}
f.field = min(max(0, focus), len(f.inputs)-1)
command := f.inputs[f.field].input.Focus()
f.refreshValidation()
return command
}
func fieldPlaceholder(field profile.Field) string {
switch field {
case profile.NameField:
return "required"
case profile.BypassField:
return "comma-separated; optional"
default:
return "host:port or protocol://host:port"
}
}
func (f *formState) move(delta int) tea.Cmd {
if len(f.inputs) == 0 {
return nil
}
f.inputs[f.field].input.Blur()
f.field = (f.field + delta + len(f.inputs)) % len(f.inputs)
f.bypassPreviewLine = 0
return f.inputs[f.field].input.Focus()
}
func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
if len(f.inputs) == 0 {
return nil
}
// Bubbles textinput intentionally collapses pasted newlines. Bypass uses
// canonical comma/newline delimiters, so preserve that boundary as commas.
if f.inputs[f.field].field == profile.BypassField && key.Type == tea.KeyRunes {
key.Runes = []rune(strings.NewReplacer("\r\n", ",", "\n", ",", "\r", ",").Replace(string(key.Runes)))
}
previous := f.inputs[f.field].input.Value()
var commands []tea.Cmd
if key.Type == tea.KeyRunes && len(key.Runes) > 1 {
// A pasted word such as "home" must remain text rather than matching a
// named navigation binding in textinput's string-based key map.
for _, pasted := range key.Runes {
updated, command := f.inputs[f.field].input.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{pasted}})
f.inputs[f.field].input = updated
commands = append(commands, command)
}
} else {
updated, command := f.inputs[f.field].input.Update(key)
f.inputs[f.field].input = updated
commands = append(commands, command)
}
if f.inputs[f.field].input.Value() != previous {
f.bypassPreviewLine = 0
f.syncFocusedValue()
f.refreshValidation()
}
return tea.Batch(commands...)
}
func (f *formState) syncFocusedValue() {
if len(f.inputs) == 0 {
return
}
value := f.inputs[f.field].input.Value()
switch f.inputs[f.field].field {
case profile.NameField:
f.profile.Name = value
case profile.SingleURLField:
f.profile.SingleURL = value
case profile.HTTPURLField:
f.profile.HTTPURL = value
case profile.HTTPSURLField:
f.profile.HTTPSURL = value
case profile.AllURLField:
f.profile.AllURL = value
case profile.BypassField:
f.profile.Bypass = []string{value}
}
}
func (f *formState) refreshValidation() {
f.validation = profile.ValidateFields(f.profile)
}
func (f *formState) toggleMode() tea.Cmd {
f.syncFocusedValue()
if f.profile.Mode == profile.SingleMode {
f.profile.Mode = profile.AdvancedMode
if !f.advancedInitialized {
f.profile.HTTPURL = f.profile.SingleURL
f.profile.HTTPSURL = f.profile.SingleURL
f.profile.AllURL = f.profile.SingleURL
f.advancedInitialized = true
}
} else {
f.profile.Mode = profile.SingleMode
}
return f.rebuildInputs(0)
}
func (f formState) fieldCount() int { return len(f.inputs) }
func (f formState) currentField() profile.Field {
if f.field < 0 || f.field >= len(f.inputs) {
return ""
}
return f.inputs[f.field].field
}
func (f *formState) scrollBypassPreview(delta int) {
if f.currentField() != profile.BypassField {
return
}
// Line zero is the preview heading; each following line represents one
// normalized or invalid validation item. Anchoring the viewport to that
// item keeps Page navigation stable across terminal resizes.
maximum := len(f.bypassAnalysis())
f.bypassPreviewLine = min(maximum, max(0, f.bypassPreviewLine+delta))
}
func (f formState) renderedInput(index, width int) string {
if index < 0 || index >= len(f.inputs) {
return ""
}
input := f.inputs[index].input
input.Width = max(12, width-len(f.inputs[index].label)-18)
position := input.Position()
// Mask before horizontal clipping for every field. This prevents a
// malformed secret-bearing URL pasted into Name or Bypass from becoming
// unrecognizable to the final frame redactor when its prefix scrolls away.
input.SetValue(profile.ObscureURLForEditing(input.Value()))
input.SetCursor(position)
return input.View()
}
func (f formState) nameConflict(profiles []profile.Profile) string {
name := strings.TrimSpace(f.profile.Name)
if name == "" {
return ""
}
for _, candidate := range profiles {
if candidate.Name == name && candidate.Name != f.originalName {
return fmt.Sprintf("a Profile named %q already exists", name)
}
}
return ""
}
func (f formState) validationText(index int) string {
if index < 0 || index >= len(f.inputs) {
return ""
}
field := f.inputs[index].field
if field == profile.BypassField {
return ""
}
if err := f.validation.FieldError(field); err != nil {
return err.Error()
}
return ""
}
func (f formState) bypassAnalysis() []string {
var lines []string
seen := make(map[string]bool)
for _, item := range f.validation.BypassItems {
if item.Err != nil {
lines = append(lines, fmt.Sprintf("! item %d %q — invalid: %s", item.Index, item.Input, item.Err))
continue
}
if !seen[item.Normalized] {
seen[item.Normalized] = true
lines = append(lines, "• "+item.Normalized)
}
}
return lines
}