Merge mature Profile editor with contextual Help
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user