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
@@ -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.
+1 -1
View File
@@ -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, and `?` on any page or dialog for contextual Help. Help scrolls with arrows, `j`/`k`, Page Up/Page Down, Home, and End; `q` or Escape closes it without changing the page, focus, selection, or viewport beneath it. Applying or reapplying requires an Active Profile, while Restore Original remains available without one.
+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
}