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
+122 -44
View File
@@ -37,11 +37,65 @@ type Store interface {
Mutate(func(State) (State, error)) (State, error)
}
func ValidateAndNormalize(input Profile) (Profile, error) {
// Field identifies the Profile input responsible for a validation error.
// Presentation layers use it to keep feedback attached to the field a user can
// correct instead of parsing canonical error strings.
type Field string
const (
NameField Field = "name"
ModeField Field = "mode"
SingleURLField Field = "single-url"
HTTPURLField Field = "http-url"
HTTPSURLField Field = "https-url"
AllURLField Field = "all-url"
BypassField Field = "bypass"
)
// BypassItemValidation describes one delimited Bypass item. Valid items retain
// their canonical value even when another item is invalid, enabling a truthful
// live preview without weakening save validation.
type BypassItemValidation struct {
Index int
Input string
Normalized string
Err error
}
// Validation is the shared canonical validation result used by forms and save.
// Normalized is complete only when Err returns nil; BypassItems always contains
// the independently interpreted Bypass entries.
type Validation struct {
Normalized Profile
FieldErrors map[Field]error
BypassItems []BypassItemValidation
firstError error
}
func (v Validation) Err() error { return v.firstError }
func (v Validation) FieldError(field Field) error { return v.FieldErrors[field] }
// ValidateFields performs the same validation and normalization as
// ValidateAndNormalize while retaining structured, field-specific feedback.
func ValidateFields(input Profile) Validation {
p := input
validation := Validation{FieldErrors: make(map[Field]error)}
setError := func(field Field, err error) {
if err == nil {
return
}
if validation.FieldErrors[field] == nil {
validation.FieldErrors[field] = err
}
if validation.firstError == nil {
validation.firstError = err
}
}
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
return Profile{}, errors.New("Profile name is required")
setError(NameField, errors.New("Profile name is required"))
}
if p.Mode == "" {
p.Mode = SingleMode
@@ -49,43 +103,65 @@ func ValidateAndNormalize(input Profile) (Profile, error) {
switch p.Mode {
case SingleMode:
value, err := validateURL("Single proxy URL", p.SingleURL)
if err != nil {
return Profile{}, err
}
p.SingleURL = value
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
case AdvancedMode:
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" {
value, err := validateURL("Single proxy URL", p.SingleURL)
if err != nil {
return Profile{}, err
}
setError(SingleURLField, err)
if err == nil {
p.SingleURL = value
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
}
case AdvancedMode:
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && strings.TrimSpace(p.SingleURL) != "" {
value, err := validateURL("Single proxy URL", p.SingleURL)
setError(HTTPURLField, err)
if err == nil {
p.SingleURL = value
p.HTTPURL, p.HTTPSURL, p.AllURL = value, value, value
}
}
var err error
if p.HTTPURL, err = validateOptionalURL("HTTP proxy URL", p.HTTPURL); err != nil {
return Profile{}, err
setError(HTTPURLField, err)
}
if p.HTTPSURL, err = validateOptionalURL("HTTPS proxy URL", p.HTTPSURL); err != nil {
return Profile{}, err
setError(HTTPSURLField, err)
}
if p.AllURL, err = validateOptionalURL("ALL/SOCKS proxy URL", p.AllURL); err != nil {
return Profile{}, err
setError(AllURLField, err)
}
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" {
return Profile{}, errors.New("at least one advanced proxy URL is required")
if p.HTTPURL == "" && p.HTTPSURL == "" && p.AllURL == "" && validation.FieldErrors[HTTPURLField] == nil && validation.FieldErrors[HTTPSURLField] == nil && validation.FieldErrors[AllURLField] == nil {
setError(HTTPURLField, errors.New("at least one advanced proxy URL is required"))
}
default:
return Profile{}, errors.New("proxy mode must be single or advanced")
setError(ModeField, errors.New("proxy mode must be single or advanced"))
}
bypass, err := normalizeBypass(p.Bypass)
if err != nil {
seen := make(map[string]bool)
canonicalBypass := make([]string, 0, len(p.Bypass))
for _, raw := range p.Bypass {
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) {
entry := BypassItemValidation{Index: len(validation.BypassItems) + 1, Input: strings.TrimSpace(item)}
entry.Normalized, entry.Err = normalizeBypassEntry(item)
validation.BypassItems = append(validation.BypassItems, entry)
if entry.Err != nil {
setError(BypassField, entry.Err)
continue
}
if !seen[entry.Normalized] {
seen[entry.Normalized] = true
canonicalBypass = append(canonicalBypass, entry.Normalized)
}
}
}
p.Bypass = canonicalBypass
validation.Normalized = p
return validation
}
func ValidateAndNormalize(input Profile) (Profile, error) {
validation := ValidateFields(input)
if err := validation.Err(); err != nil {
return Profile{}, err
}
p.Bypass = bypass
return p, nil
return validation.Normalized, nil
}
func validateOptionalURL(field, value string) (string, error) {
@@ -165,24 +241,6 @@ func validateURL(field, value string) (string, error) {
return u.String(), nil
}
func normalizeBypass(values []string) ([]string, error) {
result := make([]string, 0, len(values))
seen := make(map[string]bool)
for _, raw := range values {
for _, item := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '\n' }) {
value, err := normalizeBypassEntry(item)
if err != nil {
return nil, err
}
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
}
return result, nil
}
func normalizeBypassEntry(raw string) (string, error) {
value := strings.TrimSpace(raw)
if value == "" {
@@ -205,12 +263,12 @@ func normalizeBypassEntry(raw string) (string, error) {
return host, nil
}
var credentialPattern = regexp.MustCompile(`(?i)(https?|socks5h?)://([^/@\s:]+):([^@\s]*)@`)
var credentialPattern = regexp.MustCompile(`(?i)((?:https?|socks5h?)://)?([^/@\s:]+):([^@\s]*)@`)
// RedactText removes URL passwords from diagnostic text without requiring the
// surrounding text to be a valid URL.
// surrounding text to be a valid URL or to include a scheme.
func RedactText(value string) string {
return credentialPattern.ReplaceAllString(value, `${1}://${2}:REDACTED@`)
return credentialPattern.ReplaceAllString(value, `${1}${2}:REDACTED@`)
}
func ObscureURL(value string) string {
@@ -225,6 +283,26 @@ func ObscureURL(value string) string {
return u.String()
}
// ObscureURLForEditing masks only password runes while preserving the input's
// rune count. A text input clone can therefore retain its real cursor and
// horizontal viewport positions without ever rendering the credential.
func ObscureURLForEditing(value string) string {
matches := credentialPattern.FindAllStringSubmatchIndex(value, -1)
if len(matches) == 0 {
return value
}
var masked strings.Builder
last := 0
for _, match := range matches {
passwordStart, passwordEnd := match[6], match[7]
masked.WriteString(value[last:passwordStart])
masked.WriteString(strings.Repeat("•", len([]rune(value[passwordStart:passwordEnd]))))
last = passwordEnd
}
masked.WriteString(value[last:])
return masked.String()
}
func looksLikeIPv4(host string) bool {
parts := strings.Split(host, ".")
if len(parts) != 4 {
+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
}