fix: finish profile editor preview scrolling

This commit is contained in:
proxyctl coordinator
2026-07-18 00:07:57 +08:00
parent c56c206d8b
commit 27beb51a0e
6 changed files with 110 additions and 19 deletions
+3 -3
View File
@@ -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 {
+10
View File
@@ -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
+11 -10
View File
@@ -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,
}
}
+7 -1
View File
@@ -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
}
+64 -1
View File
@@ -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) {
+15 -4
View File
@@ -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 ""