From 27beb51a0e2b5c59b3b6d78db1d9b43afd1de177 Mon Sep 17 00:00:00 2001 From: proxyctl coordinator Date: Sat, 18 Jul 2026 00:07:57 +0800 Subject: [PATCH] fix: finish profile editor preview scrolling --- internal/profile/profile.go | 6 +-- internal/tui/model.go | 10 +++++ internal/tui/pages.go | 21 +++++----- internal/tui/presentation.go | 8 +++- internal/tui/profile_editor_test.go | 65 ++++++++++++++++++++++++++++- internal/tui/profile_form.go | 19 +++++++-- 6 files changed, 110 insertions(+), 19 deletions(-) diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 8b9a0ad..973e18f 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -263,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 { diff --git a/internal/tui/model.go b/internal/tui/model.go index f3857be..3d8c124 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -540,6 +540,16 @@ func newProfileDeleteConfirmation(selected profile.Profile, final, active bool) } func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { + 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 + } + } switch key.Type { case tea.KeyEsc: m.page = profilesPage diff --git a/internal/tui/pages.go b/internal/tui/pages.go index 9f3de86..edfee77 100644 --- a/internal/tui/pages.go +++ b/internal/tui/pages.go @@ -208,7 +208,7 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { if model.form.originalName != "" { action = "Edit" } - var fields strings.Builder + var fields, bypassPreview strings.Builder focusLine, currentLine := 0, 0 for i, item := range model.form.inputs { if fields.Len() > 0 { @@ -233,22 +233,23 @@ func (p terminalPresentation) formView(model presentationState) terminalPage { } if item.field == profile.BypassField { analysis := model.form.bypassAnalysis() - fields.WriteString("\n Normalized Bypass preview:") - currentLine++ + bypassPreview.WriteString("Normalized Bypass preview:") if len(analysis) == 0 { - fields.WriteString(" (none)") + bypassPreview.WriteString(" (none)") } else { for _, line := range analysis { - fields.WriteString("\n " + line) - currentLine++ + bypassPreview.WriteString("\n " + line) } } } } 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: focusLine, + header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)), + primary: fields.String(), + context: bypassPreview.String(), + footer: "Tab/↑/↓: move Ctrl+T: toggle Single/Advanced PgUp/PgDn: scroll form\nCtrl+S: save Ctrl+D: detect protocol Esc: cancel", + focusLine: focusLine, + contextAnchor: model.form.bypassPreviewLine, + anchorContext: true, } } diff --git a/internal/tui/presentation.go b/internal/tui/presentation.go index 340c41e..7c8ca31 100644 --- a/internal/tui/presentation.go +++ b/internal/tui/presentation.go @@ -99,6 +99,8 @@ type terminalPage struct { focusLine int primaryOffset int contextOffset int + contextAnchor int + anchorContext bool dashboard *dashboardRenderState } @@ -225,7 +227,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 } diff --git a/internal/tui/profile_editor_test.go b/internal/tui/profile_editor_test.go index 8dc60ad..64d82e7 100644 --- a/internal/tui/profile_editor_test.go +++ b/internal/tui/profile_editor_test.go @@ -167,7 +167,70 @@ func TestCompleteModelProfileEditorScrollsLongBypassPreviewAt80Columns(t *testin 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") + 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) { diff --git a/internal/tui/profile_form.go b/internal/tui/profile_form.go index 481fcf0..862b9b1 100644 --- a/internal/tui/profile_form.go +++ b/internal/tui/profile_form.go @@ -27,7 +27,7 @@ type formState struct { profile profile.Profile inputs []formInput field int - viewportOffset int + bypassPreviewLine int validation profile.Validation advancedInitialized bool probing bool @@ -48,7 +48,7 @@ func newFormState(originalName string, value profile.Profile) formState { } func (f *formState) rebuildInputs(focus int) tea.Cmd { - f.viewportOffset = 0 + f.bypassPreviewLine = 0 definitions := []formDefinition{{"Name", profile.NameField, f.profile.Name}} if f.profile.Mode == profile.AdvancedMode { definitions = append(definitions, @@ -95,7 +95,7 @@ func (f *formState) move(delta int) tea.Cmd { } f.inputs[f.field].input.Blur() f.field = (f.field + delta + len(f.inputs)) % len(f.inputs) - f.viewportOffset = 0 + f.bypassPreviewLine = 0 return f.inputs[f.field].input.Focus() } @@ -124,7 +124,7 @@ func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd { commands = append(commands, command) } if f.inputs[f.field].input.Value() != previous { - f.viewportOffset = 0 + f.bypassPreviewLine = 0 f.syncFocusedValue() f.refreshValidation() } @@ -181,6 +181,17 @@ func (f formState) currentField() profile.Field { 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 ""