diff --git a/.scratch/tui-redesign/issues/08-profile-editor.md b/.scratch/tui-redesign/issues/08-profile-editor.md index c345686..8ca2572 100644 --- a/.scratch/tui-redesign/issues/08-profile-editor.md +++ b/.scratch/tui-redesign/issues/08-profile-editor.md @@ -4,16 +4,16 @@ **Blocked by:** 07 — Build the dedicated Profile management page. -**Status:** ready-for-agent +**Status:** done -- [ ] Name and URL fields support insertion, cursor movement, Home, End, Delete, Backspace, paste, and horizontal scrolling. -- [ ] Focus is explicit and remains understandable without color. -- [ ] Single and Advanced mode transitions preserve values according to existing Proxy Profile semantics. -- [ ] Field-specific validation appears beside the relevant field and updates when input changes. -- [ ] Save uses the same canonical Profile validation semantics as the live feedback, preventing contradictory results. -- [ ] Bypass accepts efficient delimited input and displays a live normalized, deduplicated preview. -- [ ] Invalid Bypass entries are identified individually without hiding valid entries. -- [ ] Proxy credentials remain obscured during entry, scrolling, validation, review, errors, and resize. -- [ ] Save returns to management with the saved Profile selected; cancel leaves persistence unchanged. -- [ ] Long URLs and Bypass content remain usable at 80×24. -- [ ] Complete-Model tests cover editing operations, paste, horizontal scrolling, mode transitions, validation lifecycle, Bypass preview, save, cancel, resize, and adversarial secret-bearing input. +- [x] Name and URL fields support insertion, cursor movement, Home, End, Delete, Backspace, paste, and horizontal scrolling. +- [x] Focus is explicit and remains understandable without color. +- [x] Single and Advanced mode transitions preserve values according to existing Proxy Profile semantics. +- [x] Field-specific validation appears beside the relevant field and updates when input changes. +- [x] Save uses the same canonical Profile validation semantics as the live feedback, preventing contradictory results. +- [x] Bypass accepts efficient delimited input and displays a live normalized, deduplicated preview. +- [x] Invalid Bypass entries are identified individually without hiding valid entries. +- [x] Proxy credentials remain obscured during entry, scrolling, validation, review, errors, and resize. +- [x] Save returns to management with the saved Profile selected; cancel leaves persistence unchanged. +- [x] Long URLs and Bypass content remain usable at 80×24. +- [x] Complete-Model tests cover editing operations, paste, horizontal scrolling, mode transitions, validation lifecycle, Bypass preview, save, cancel, resize, and adversarial secret-bearing input. diff --git a/README.md b/README.md index e429b45..b2c6324 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Start the Target panel with no arguments: proxyctl ``` -Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts hostnames, domain suffixes, IPv4, IPv6, and CIDR entries. +Press `m` for Proxy Profile management, then `n` to create a Profile, enter a name and proxy URL, and press Ctrl+S to save. The editor supports cursor movement, Home, End, Delete, Backspace, insertion, paste, and horizontal scrolling; Tab or the arrow keys move between fields, and Escape cancels without saving. Validation stays beside each field. The management page supports reviewing obscured connection details, editing, and safe deletion; Active Profile switching remains a separate dashboard picker workflow rather than a management action. Single mode applies one URL to HTTP, HTTPS, and ALL/SOCKS. Ctrl+T selects advanced mode for separate values without discarding values entered in either mode. A bare `host:port` is accepted and normalized to `http://`. With the cursor in the Single proxy URL field, Ctrl+D runs the Protocol Probe, which detects whether the endpoint speaks HTTP or SOCKS5 and fills in the scheme. When one endpoint supports both protocols, the probe switches the form to advanced mode and fills HTTP/HTTPS with `http://` and ALL/SOCKS with `socks5://`. Bypass accepts comma- or newline-delimited hostnames, domain suffixes, IPv4, IPv6, and CIDR entries; the editor previews normalized, deduplicated values and identifies invalid items before save. The dashboard shows the Active Profile. Press `p` to open the searchable Active Profile picker, type to filter, use arrows to select, and press Enter to activate; Escape cancels without changing the Active Profile. Use arrows or `j`/`k` to select an Available Target and Space to run its contextual action: Enable, Disable, Apply Profile, or Resolve. Drift Resolution explicitly offers Reapply Active Profile and Restore Original; restoration refuses safely if proxyctl no longer has sufficient ownership evidence. Press `r` to refresh detection, `u` to show unavailable Targets, `?` for Help, and `q` to leave. Applying or reapplying requires an Active Profile, while Restore Original remains available without one. diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 8e3aa82..8b9a0ad 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -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 == "" { @@ -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 { diff --git a/internal/tui/foundation_test.go b/internal/tui/foundation_test.go index 2d94763..fc0fde0 100644 --- a/internal/tui/foundation_test.go +++ b/internal/tui/foundation_test.go @@ -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") } } diff --git a/internal/tui/model.go b/internal/tui/model.go index b3fce60..f3857be 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -22,14 +22,6 @@ const ( helpPage ) -type formState struct { - originalName string - profile profile.Profile - field int - probing bool - probeGen uint64 -} - type Model struct { application app.InteractiveModule presentation terminalPresentation @@ -199,6 +191,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 } @@ -208,6 +202,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" @@ -491,7 +486,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": @@ -499,7 +494,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 = "" } @@ -545,19 +540,28 @@ func newProfileDeleteConfirmation(selected profile.Profile, final, active bool) } func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { - switch key.String() { - case "esc": + switch key.Type { + case tea.KeyEsc: m.page = profilesPage m.status = "" m.form = formState{} return m, nil - case "ctrl+s": + 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 @@ -566,12 +570,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) @@ -588,34 +592,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) { @@ -744,61 +729,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} - } -} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index c1dd106..a17515c 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -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) diff --git a/internal/tui/pages.go b/internal/tui/pages.go index d1e349a..9f3de86 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -209,24 +209,46 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { 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) - } + 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() + fields.WriteString("\n Normalized Bypass preview:") + currentLine++ + if len(analysis) == 0 { + fields.WriteString(" (none)") + } else { + for _, line := range analysis { + fields.WriteString("\n " + line) + currentLine++ + } + } } - 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, + focusLine: focusLine, } } diff --git a/internal/tui/profile_editor_test.go b/internal/tui/profile_editor_test.go new file mode 100644 index 0000000..8dc60ad --- /dev/null +++ b/internal/tui/profile_editor_test.go @@ -0,0 +1,230 @@ +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, "! item 25", "late invalid entry", "invalid", "PgUp/PgDn: scroll form") +} + +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) + } + } +} diff --git a/internal/tui/profile_form.go b/internal/tui/profile_form.go new file mode 100644 index 0000000..481fcf0 --- /dev/null +++ b/internal/tui/profile_form.go @@ -0,0 +1,240 @@ +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 + viewportOffset 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.viewportOffset = 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.viewportOffset = 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.viewportOffset = 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) 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 +}