pi-agent: Implement mature Profile editor
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
-102
@@ -22,14 +22,6 @@ const (
|
||||
helpPage
|
||||
)
|
||||
|
||||
type formState struct {
|
||||
originalName string
|
||||
profile profile.Profile
|
||||
field int
|
||||
probing bool
|
||||
probeGen uint64
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
application app.InteractiveModule
|
||||
presentation terminalPresentation
|
||||
@@ -199,6 +191,8 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.form.profile.HTTPURL = httpURL
|
||||
m.form.profile.HTTPSURL = httpURL
|
||||
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
|
||||
m.form.advancedInitialized = true
|
||||
m.form.rebuildInputs(1)
|
||||
m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address
|
||||
return m, nil
|
||||
}
|
||||
@@ -208,6 +202,7 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.form.profile.SingleURL = rewritten
|
||||
m.form.rebuildInputs(1)
|
||||
kind := "SOCKS5"
|
||||
if done.caps.HTTP {
|
||||
kind = "HTTP"
|
||||
@@ -491,7 +486,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if !m.profileMutationAvailable("creating a Proxy Profile") {
|
||||
break
|
||||
}
|
||||
m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}}
|
||||
m.form = newFormState("", profile.Profile{Mode: profile.SingleMode})
|
||||
m.page = formPage
|
||||
m.status = ""
|
||||
case "e", "enter":
|
||||
@@ -499,7 +494,7 @@ func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
break
|
||||
}
|
||||
if selected, ok := m.selectedProfile(); ok {
|
||||
m.form = formState{originalName: selected.Name, profile: selected}
|
||||
m.form = newFormState(selected.Name, selected)
|
||||
m.page = formPage
|
||||
m.status = ""
|
||||
}
|
||||
@@ -545,19 +540,28 @@ func newProfileDeleteConfirmation(selected profile.Profile, final, active bool)
|
||||
}
|
||||
|
||||
func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch key.String() {
|
||||
case "esc":
|
||||
switch key.Type {
|
||||
case tea.KeyEsc:
|
||||
m.page = profilesPage
|
||||
m.status = ""
|
||||
m.form = formState{}
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
case tea.KeyCtrlS:
|
||||
m.form.syncFocusedValue()
|
||||
m.form.refreshValidation()
|
||||
if err := m.form.validation.Err(); err != nil || m.form.nameConflict(m.snapshot.Profiles) != "" {
|
||||
m.status = "Fix the highlighted Profile fields before saving"
|
||||
m.form.probeGen++
|
||||
m.form.probing = false
|
||||
return m, nil
|
||||
}
|
||||
kind := app.CreateProfile
|
||||
if m.form.originalName != "" {
|
||||
kind = app.UpdateProfile
|
||||
}
|
||||
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: m.form.profile}
|
||||
result := m.applyProfileMutation(intent, intent.Profile.Name)
|
||||
normalized := m.form.validation.Normalized
|
||||
intent := app.Intent{Kind: kind, Name: m.form.originalName, Profile: normalized}
|
||||
result := m.applyProfileMutation(intent, normalized.Name)
|
||||
if result.Err != nil {
|
||||
m.form.probeGen++ // discard any in-flight probe result while staying on the form
|
||||
m.form.probing = false
|
||||
@@ -566,12 +570,12 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.page = profilesPage
|
||||
m.form = formState{}
|
||||
return m, nil
|
||||
case "ctrl+d":
|
||||
case tea.KeyCtrlD:
|
||||
if m.form.profile.Mode != profile.SingleMode {
|
||||
m.status = "Protocol Probe: available in Single mode only"
|
||||
return m, nil
|
||||
}
|
||||
if m.form.field != 1 || m.form.probing {
|
||||
if m.form.currentField() != profile.SingleURLField || m.form.probing {
|
||||
return m, nil
|
||||
}
|
||||
address, err := probeAddress(m.form.profile.SingleURL)
|
||||
@@ -588,34 +592,15 @@ func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
caps, err := detect(context.Background(), address)
|
||||
return probeDone{address: address, caps: caps, err: err, gen: gen}
|
||||
}
|
||||
case "ctrl+t":
|
||||
if m.form.profile.Mode == profile.SingleMode {
|
||||
m.form.profile.Mode = profile.AdvancedMode
|
||||
m.form.profile.HTTPURL = m.form.profile.SingleURL
|
||||
m.form.profile.HTTPSURL = m.form.profile.SingleURL
|
||||
m.form.profile.AllURL = m.form.profile.SingleURL
|
||||
} else {
|
||||
m.form.profile.Mode = profile.SingleMode
|
||||
}
|
||||
m.form.field = 0
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
m.form.field = (m.form.field + 1) % m.form.fieldCount()
|
||||
return m, nil
|
||||
case "shift+tab", "up":
|
||||
m.form.field = (m.form.field - 1 + m.form.fieldCount()) % m.form.fieldCount()
|
||||
return m, nil
|
||||
case "backspace":
|
||||
value := []rune(m.form.value())
|
||||
if len(value) > 0 {
|
||||
m.form.setValue(string(value[:len(value)-1]))
|
||||
}
|
||||
return m, nil
|
||||
case tea.KeyCtrlT:
|
||||
return m, m.form.toggleMode()
|
||||
case tea.KeyTab, tea.KeyDown:
|
||||
return m, m.form.move(1)
|
||||
case tea.KeyShiftTab, tea.KeyUp:
|
||||
return m, m.form.move(-1)
|
||||
default:
|
||||
return m, m.form.updateInput(key)
|
||||
}
|
||||
if key.Type == tea.KeyRunes {
|
||||
m.form.setValue(m.form.value() + string(key.Runes))
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
|
||||
@@ -744,61 +729,3 @@ func (m *Model) retainTargetSelection() {
|
||||
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
||||
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
|
||||
}
|
||||
|
||||
func (f formState) labels() []string {
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
return []string{"Name", "HTTP URL", "HTTPS URL", "ALL/SOCKS URL", "Bypass"}
|
||||
}
|
||||
return []string{"Name", "Single proxy URL", "Bypass"}
|
||||
}
|
||||
|
||||
func (f formState) fieldCount() int { return len(f.labels()) }
|
||||
|
||||
func (f formState) value() string { return f.valueAt(f.field) }
|
||||
|
||||
func (f formState) valueAt(index int) string {
|
||||
if index == 0 {
|
||||
return f.profile.Name
|
||||
}
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
switch index {
|
||||
case 1:
|
||||
return f.profile.HTTPURL
|
||||
case 2:
|
||||
return f.profile.HTTPSURL
|
||||
case 3:
|
||||
return f.profile.AllURL
|
||||
default:
|
||||
return strings.Join(f.profile.Bypass, ", ")
|
||||
}
|
||||
}
|
||||
if index == 1 {
|
||||
return f.profile.SingleURL
|
||||
}
|
||||
return strings.Join(f.profile.Bypass, ", ")
|
||||
}
|
||||
|
||||
func (f *formState) setValue(value string) {
|
||||
if f.field == 0 {
|
||||
f.profile.Name = value
|
||||
return
|
||||
}
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
switch f.field {
|
||||
case 1:
|
||||
f.profile.HTTPURL = value
|
||||
case 2:
|
||||
f.profile.HTTPSURL = value
|
||||
case 3:
|
||||
f.profile.AllURL = value
|
||||
default:
|
||||
f.profile.Bypass = []string{value}
|
||||
}
|
||||
return
|
||||
}
|
||||
if f.field == 1 {
|
||||
f.profile.SingleURL = value
|
||||
} else {
|
||||
f.profile.Bypass = []string{value}
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
+33
-11
@@ -209,24 +209,46 @@ func (p terminalPresentation) formView(model presentationState) terminalPage {
|
||||
action = "Edit"
|
||||
}
|
||||
var fields strings.Builder
|
||||
for i, label := range model.form.labels() {
|
||||
cursor := " "
|
||||
if i == model.form.field {
|
||||
cursor = "> "
|
||||
}
|
||||
value := model.form.valueAt(i)
|
||||
if label != "Name" && label != "Bypass" {
|
||||
value = profile.ObscureURL(value)
|
||||
}
|
||||
focusLine, currentLine := 0, 0
|
||||
for i, item := range model.form.inputs {
|
||||
if fields.Len() > 0 {
|
||||
fields.WriteByte('\n')
|
||||
currentLine++
|
||||
}
|
||||
cursor, focus := " ", ""
|
||||
if i == model.form.field {
|
||||
cursor, focus = "> ", " [focused]"
|
||||
focusLine = currentLine
|
||||
}
|
||||
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, model.form.renderedInput(i, model.width))
|
||||
validation := model.form.validationText(i)
|
||||
if item.field == profile.NameField {
|
||||
if conflict := model.form.nameConflict(model.snapshot.Profiles); conflict != "" {
|
||||
validation = conflict
|
||||
}
|
||||
}
|
||||
if validation != "" {
|
||||
fmt.Fprintf(&fields, "\n Error: %s", validation)
|
||||
currentLine++
|
||||
}
|
||||
if item.field == profile.BypassField {
|
||||
analysis := model.form.bypassAnalysis()
|
||||
fields.WriteString("\n Normalized Bypass preview:")
|
||||
currentLine++
|
||||
if len(analysis) == 0 {
|
||||
fields.WriteString(" (none)")
|
||||
} else {
|
||||
for _, line := range analysis {
|
||||
fields.WriteString("\n " + line)
|
||||
currentLine++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&fields, "%s%s: %s", cursor, label, value)
|
||||
}
|
||||
return terminalPage{
|
||||
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, model.form.profile.Mode)),
|
||||
primary: fields.String(),
|
||||
footer: "Tab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Ctrl+D: detect protocol Esc: cancel",
|
||||
focusLine: model.form.field,
|
||||
focusLine: focusLine,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/app"
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
func editorKey(model Model, key tea.KeyType) Model {
|
||||
updated, _ := model.Update(tea.KeyMsg{Type: key})
|
||||
return updated.(Model)
|
||||
}
|
||||
|
||||
func openNewProfileEditor(t *testing.T, module *profileManagementModule) Model {
|
||||
t.Helper()
|
||||
model := resizedModel(t, module, themeModeNoColor, 80, 24)
|
||||
model = updateKey(model, "m")
|
||||
return updateKey(model, "n")
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorSupportsCursorEditingDeletionAndPaste(t *testing.T) {
|
||||
module := &profileManagementModule{}
|
||||
model := openNewProfileEditor(t, module)
|
||||
|
||||
model = updateRunes(model, "ofice")
|
||||
model = editorKey(model, tea.KeyLeft)
|
||||
model = editorKey(model, tea.KeyLeft)
|
||||
model = editorKey(model, tea.KeyLeft)
|
||||
model = updateRunes(model, "f")
|
||||
model = editorKey(model, tea.KeyHome)
|
||||
model = updateRunes(model, "x")
|
||||
model = editorKey(model, tea.KeyBackspace)
|
||||
model = editorKey(model, tea.KeyEnd)
|
||||
model = editorKey(model, tea.KeyBackspace)
|
||||
model = updateRunes(model, "e")
|
||||
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "http://proxy.example:808x0") // multi-rune KeyMsg is Bubble Tea's paste boundary
|
||||
model = editorKey(model, tea.KeyLeft)
|
||||
model = editorKey(model, tea.KeyLeft)
|
||||
model = editorKey(model, tea.KeyDelete)
|
||||
model = updateKey(model, "ctrl+s")
|
||||
|
||||
if len(module.intents) != 1 {
|
||||
t.Fatalf("save dispatched %d intents, want 1; frame:\n%s", len(module.intents), model.View())
|
||||
}
|
||||
got := module.intents[0].Profile
|
||||
if got.Name != "office" || got.SingleURL != "http://proxy.example:8080" {
|
||||
t.Fatalf("edited Profile = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorHorizontallyScrollsLongSecretURLWithoutDisclosure(t *testing.T) {
|
||||
module := &profileManagementModule{}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "office")
|
||||
model = updateKey(model, "tab")
|
||||
const secret = "credential-never-rendered"
|
||||
const raw = "https://operator:" + secret + "@this-is-a-very-long-proxy-hostname-for-horizontal-scroll.example:8443"
|
||||
model = updateRunes(model, raw)
|
||||
|
||||
end := model.View()
|
||||
assertUsableFrame(t, end, 80, 24, "[focused]", "scroll.example:8443")
|
||||
if strings.Contains(end, secret) || strings.Contains(end, "https://operator") {
|
||||
t.Fatalf("end-scrolled input disclosed credentials or failed to scroll:\n%s", end)
|
||||
}
|
||||
|
||||
model = editorKey(model, tea.KeyHome)
|
||||
start := model.View()
|
||||
assertUsableFrame(t, start, 80, 24, "https://operator:")
|
||||
if strings.Contains(start, secret) || strings.Contains(start, "scroll.example:8443") {
|
||||
t.Fatalf("home did not scroll to the safe beginning of the input:\n%s", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorPreservesValuesAcrossSingleAdvancedTransitions(t *testing.T) {
|
||||
module := &profileManagementModule{}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "office")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "proxy.example:8080")
|
||||
model = updateKey(model, "ctrl+t")
|
||||
model = updateKey(model, "tab")
|
||||
|
||||
model = editorKey(model, tea.KeyHome)
|
||||
model = updateRunes(model, "http://")
|
||||
model = updateKey(model, "ctrl+t")
|
||||
model = updateKey(model, "ctrl+t")
|
||||
model = updateKey(model, "ctrl+s")
|
||||
|
||||
if len(module.intents) != 1 {
|
||||
t.Fatalf("save dispatched %d intents; frame:\n%s", len(module.intents), model.View())
|
||||
}
|
||||
got := module.intents[0].Profile
|
||||
if got.Mode != profile.AdvancedMode || got.HTTPURL != "http://proxy.example:8080" || got.HTTPSURL != "http://proxy.example:8080" || got.AllURL != "http://proxy.example:8080" {
|
||||
t.Fatalf("transition discarded values: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorShowsCanonicalFieldValidationAndLiveBypassAnalysis(t *testing.T) {
|
||||
module := &profileManagementModule{}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "office")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "proxy.example")
|
||||
view := model.View()
|
||||
assertUsableFrame(t, view, 80, 24, "Single proxy URL must include a port")
|
||||
|
||||
model = editorKey(model, tea.KeyEnd)
|
||||
model = updateRunes(model, ":8080")
|
||||
if view := model.View(); strings.Contains(view, "must include a port") {
|
||||
t.Fatalf("correcting the URL left a stale field error:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "LOCALHOST, .Example.COM, localhost, bad entry, 10.0.0.7/24")
|
||||
view = model.View()
|
||||
assertUsableFrame(t, view, 80, 24,
|
||||
"Normalized Bypass preview", "localhost", ".example.com", "10.0.0.0/24", "bad entry", "invalid")
|
||||
if strings.Count(view, "• localhost") != 1 {
|
||||
t.Fatalf("Bypass preview did not deduplicate localhost:\n%s", view)
|
||||
}
|
||||
|
||||
model = updateKey(model, "ctrl+s")
|
||||
if len(module.intents) != 0 {
|
||||
t.Fatalf("invalid Bypass dispatched %#v", module.intents)
|
||||
}
|
||||
model = editorKey(model, tea.KeyCtrlU)
|
||||
model = updateRunes(model, "LOCALHOST, .Example.COM, localhost, 10.0.0.7/24")
|
||||
if view := model.View(); strings.Contains(view, "bad entry") || strings.Contains(view, "invalid") {
|
||||
t.Fatalf("correcting Bypass left stale item validation:\n%s", view)
|
||||
}
|
||||
model = updateKey(model, "ctrl+s")
|
||||
|
||||
if len(module.intents) != 1 {
|
||||
t.Fatalf("valid save dispatched %d intents; frame:\n%s", len(module.intents), model.View())
|
||||
}
|
||||
want := []string{"localhost", ".example.com", "10.0.0.0/24"}
|
||||
got := module.intents[0].Profile.Bypass
|
||||
if strings.Join(got, "|") != strings.Join(want, "|") {
|
||||
t.Fatalf("saved Bypass = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorScrollsLongBypassPreviewAt80Columns(t *testing.T) {
|
||||
module := &profileManagementModule{}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "office")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "proxy.example:8080")
|
||||
model = updateKey(model, "tab")
|
||||
items := make([]string, 0, 25)
|
||||
for i := range 24 {
|
||||
items = append(items, fmt.Sprintf("host-%02d.example", i))
|
||||
}
|
||||
items = append(items, "late invalid entry")
|
||||
model = updateRunes(model, strings.Join(items, ","))
|
||||
if strings.Contains(model.View(), "! item 25") {
|
||||
t.Fatal("fixture did not exceed the 80x24 preview viewport")
|
||||
}
|
||||
for range 6 {
|
||||
model = updateKey(model, "pgdown")
|
||||
}
|
||||
model = resize(model, 80, 24)
|
||||
assertUsableFrame(t, model.View(), 80, 24, "! item 25", "late invalid entry", "invalid", "PgUp/PgDn: scroll form")
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorShowsNameConflictsInlineAndBlocksContradictorySave(t *testing.T) {
|
||||
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
|
||||
Name: "office", Mode: profile.SingleMode, SingleURL: "http://proxy.example:8080",
|
||||
}}}}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "office")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "other.example:8080")
|
||||
assertUsableFrame(t, model.View(), 80, 24, "Name", "a Profile named \"office\" already exists")
|
||||
model = updateKey(model, "ctrl+s")
|
||||
if len(module.intents) != 0 {
|
||||
t.Fatalf("conflicting name dispatched %#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorSaveSelectsSavedProfileAndCancelDoesNotPersist(t *testing.T) {
|
||||
module := &profileManagementModule{snapshot: app.Snapshot{Profiles: []profile.Profile{{
|
||||
Name: "existing", Mode: profile.SingleMode, SingleURL: "http://existing.example:8080",
|
||||
}}}}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "discarded")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "http://discarded.example:8080")
|
||||
model = updateKey(model, "esc")
|
||||
if len(module.intents) != 0 || len(module.snapshot.Profiles) != 1 {
|
||||
t.Fatalf("cancel mutated persistence: intents=%#v snapshot=%#v", module.intents, module.snapshot)
|
||||
}
|
||||
|
||||
model = updateKey(model, "n")
|
||||
model = updateRunes(model, "saved")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "saved.example:8080")
|
||||
model = updateKey(model, "ctrl+s")
|
||||
assertUsableFrame(t, model.View(), 80, 24, "> saved", "Proxy: http://saved.example:8080")
|
||||
if len(module.intents) != 1 || module.intents[0].Kind != app.CreateProfile {
|
||||
t.Fatalf("save dispatched %#v", module.intents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteModelProfileEditorResizeAndErrorsNeverRenderAdversarialCredentials(t *testing.T) {
|
||||
module := &profileManagementModule{}
|
||||
model := openNewProfileEditor(t, module)
|
||||
model = updateRunes(model, "office")
|
||||
model = updateKey(model, "tab")
|
||||
const secret = "adversarial-secret"
|
||||
model = updateRunes(model, "http://alice:"+secret+"@proxy.example:notaport")
|
||||
model = updateKey(model, "tab")
|
||||
model = updateRunes(model, "http://bob:"+secret+"@bypass.invalid")
|
||||
|
||||
for _, size := range []struct{ width, height int }{{80, 24}, {120, 36}, {60, 16}, {80, 24}} {
|
||||
model = resize(model, size.width, size.height)
|
||||
view := model.View()
|
||||
assertUsableFrame(t, view, size.width, size.height, "Ctrl+S: save", "Esc: cancel")
|
||||
if strings.Contains(view, secret) {
|
||||
t.Fatalf("secret rendered after resize to %dx%d:\n%s", size.width, size.height, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"proxyctl/internal/profile"
|
||||
)
|
||||
|
||||
type formInput struct {
|
||||
label string
|
||||
field profile.Field
|
||||
input textinput.Model
|
||||
}
|
||||
|
||||
type formDefinition struct {
|
||||
label string
|
||||
field profile.Field
|
||||
value string
|
||||
}
|
||||
|
||||
type formState struct {
|
||||
originalName string
|
||||
profile profile.Profile
|
||||
inputs []formInput
|
||||
field int
|
||||
viewportOffset int
|
||||
validation profile.Validation
|
||||
advancedInitialized bool
|
||||
probing bool
|
||||
probeGen uint64
|
||||
}
|
||||
|
||||
func newFormState(originalName string, value profile.Profile) formState {
|
||||
if value.Mode == "" {
|
||||
value.Mode = profile.SingleMode
|
||||
}
|
||||
form := formState{
|
||||
originalName: originalName,
|
||||
profile: value,
|
||||
advancedInitialized: value.Mode == profile.AdvancedMode,
|
||||
}
|
||||
form.rebuildInputs(0)
|
||||
return form
|
||||
}
|
||||
|
||||
func (f *formState) rebuildInputs(focus int) tea.Cmd {
|
||||
f.viewportOffset = 0
|
||||
definitions := []formDefinition{{"Name", profile.NameField, f.profile.Name}}
|
||||
if f.profile.Mode == profile.AdvancedMode {
|
||||
definitions = append(definitions,
|
||||
formDefinition{"HTTP URL", profile.HTTPURLField, f.profile.HTTPURL},
|
||||
formDefinition{"HTTPS URL", profile.HTTPSURLField, f.profile.HTTPSURL},
|
||||
formDefinition{"ALL/SOCKS URL", profile.AllURLField, f.profile.AllURL},
|
||||
)
|
||||
} else {
|
||||
definitions = append(definitions, formDefinition{"Single proxy URL", profile.SingleURLField, f.profile.SingleURL})
|
||||
}
|
||||
definitions = append(definitions, formDefinition{"Bypass", profile.BypassField, strings.Join(f.profile.Bypass, ", ")})
|
||||
|
||||
f.inputs = make([]formInput, len(definitions))
|
||||
for i, definition := range definitions {
|
||||
input := textinput.New()
|
||||
input.Prompt = ""
|
||||
input.Placeholder = fieldPlaceholder(definition.field)
|
||||
input.CharLimit = 4096
|
||||
input.Width = 48
|
||||
input.SetValue(definition.value)
|
||||
input.CursorEnd()
|
||||
f.inputs[i] = formInput{label: definition.label, field: definition.field, input: input}
|
||||
}
|
||||
f.field = min(max(0, focus), len(f.inputs)-1)
|
||||
command := f.inputs[f.field].input.Focus()
|
||||
f.refreshValidation()
|
||||
return command
|
||||
}
|
||||
|
||||
func fieldPlaceholder(field profile.Field) string {
|
||||
switch field {
|
||||
case profile.NameField:
|
||||
return "required"
|
||||
case profile.BypassField:
|
||||
return "comma-separated; optional"
|
||||
default:
|
||||
return "host:port or protocol://host:port"
|
||||
}
|
||||
}
|
||||
|
||||
func (f *formState) move(delta int) tea.Cmd {
|
||||
if len(f.inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
f.inputs[f.field].input.Blur()
|
||||
f.field = (f.field + delta + len(f.inputs)) % len(f.inputs)
|
||||
f.viewportOffset = 0
|
||||
return f.inputs[f.field].input.Focus()
|
||||
}
|
||||
|
||||
func (f *formState) updateInput(key tea.KeyMsg) tea.Cmd {
|
||||
if len(f.inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Bubbles textinput intentionally collapses pasted newlines. Bypass uses
|
||||
// canonical comma/newline delimiters, so preserve that boundary as commas.
|
||||
if f.inputs[f.field].field == profile.BypassField && key.Type == tea.KeyRunes {
|
||||
key.Runes = []rune(strings.NewReplacer("\r\n", ",", "\n", ",", "\r", ",").Replace(string(key.Runes)))
|
||||
}
|
||||
previous := f.inputs[f.field].input.Value()
|
||||
var commands []tea.Cmd
|
||||
if key.Type == tea.KeyRunes && len(key.Runes) > 1 {
|
||||
// A pasted word such as "home" must remain text rather than matching a
|
||||
// named navigation binding in textinput's string-based key map.
|
||||
for _, pasted := range key.Runes {
|
||||
updated, command := f.inputs[f.field].input.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{pasted}})
|
||||
f.inputs[f.field].input = updated
|
||||
commands = append(commands, command)
|
||||
}
|
||||
} else {
|
||||
updated, command := f.inputs[f.field].input.Update(key)
|
||||
f.inputs[f.field].input = updated
|
||||
commands = append(commands, command)
|
||||
}
|
||||
if f.inputs[f.field].input.Value() != previous {
|
||||
f.viewportOffset = 0
|
||||
f.syncFocusedValue()
|
||||
f.refreshValidation()
|
||||
}
|
||||
return tea.Batch(commands...)
|
||||
}
|
||||
|
||||
func (f *formState) syncFocusedValue() {
|
||||
if len(f.inputs) == 0 {
|
||||
return
|
||||
}
|
||||
value := f.inputs[f.field].input.Value()
|
||||
switch f.inputs[f.field].field {
|
||||
case profile.NameField:
|
||||
f.profile.Name = value
|
||||
case profile.SingleURLField:
|
||||
f.profile.SingleURL = value
|
||||
case profile.HTTPURLField:
|
||||
f.profile.HTTPURL = value
|
||||
case profile.HTTPSURLField:
|
||||
f.profile.HTTPSURL = value
|
||||
case profile.AllURLField:
|
||||
f.profile.AllURL = value
|
||||
case profile.BypassField:
|
||||
f.profile.Bypass = []string{value}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *formState) refreshValidation() {
|
||||
f.validation = profile.ValidateFields(f.profile)
|
||||
}
|
||||
|
||||
func (f *formState) toggleMode() tea.Cmd {
|
||||
f.syncFocusedValue()
|
||||
if f.profile.Mode == profile.SingleMode {
|
||||
f.profile.Mode = profile.AdvancedMode
|
||||
if !f.advancedInitialized {
|
||||
f.profile.HTTPURL = f.profile.SingleURL
|
||||
f.profile.HTTPSURL = f.profile.SingleURL
|
||||
f.profile.AllURL = f.profile.SingleURL
|
||||
f.advancedInitialized = true
|
||||
}
|
||||
} else {
|
||||
f.profile.Mode = profile.SingleMode
|
||||
}
|
||||
return f.rebuildInputs(0)
|
||||
}
|
||||
|
||||
func (f formState) fieldCount() int { return len(f.inputs) }
|
||||
|
||||
func (f formState) currentField() profile.Field {
|
||||
if f.field < 0 || f.field >= len(f.inputs) {
|
||||
return ""
|
||||
}
|
||||
return f.inputs[f.field].field
|
||||
}
|
||||
|
||||
func (f formState) renderedInput(index, width int) string {
|
||||
if index < 0 || index >= len(f.inputs) {
|
||||
return ""
|
||||
}
|
||||
input := f.inputs[index].input
|
||||
input.Width = max(12, width-len(f.inputs[index].label)-18)
|
||||
position := input.Position()
|
||||
// Mask before horizontal clipping for every field. This prevents a
|
||||
// malformed secret-bearing URL pasted into Name or Bypass from becoming
|
||||
// unrecognizable to the final frame redactor when its prefix scrolls away.
|
||||
input.SetValue(profile.ObscureURLForEditing(input.Value()))
|
||||
input.SetCursor(position)
|
||||
return input.View()
|
||||
}
|
||||
|
||||
func (f formState) nameConflict(profiles []profile.Profile) string {
|
||||
name := strings.TrimSpace(f.profile.Name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range profiles {
|
||||
if candidate.Name == name && candidate.Name != f.originalName {
|
||||
return fmt.Sprintf("a Profile named %q already exists", name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f formState) validationText(index int) string {
|
||||
if index < 0 || index >= len(f.inputs) {
|
||||
return ""
|
||||
}
|
||||
field := f.inputs[index].field
|
||||
if field == profile.BypassField {
|
||||
return ""
|
||||
}
|
||||
if err := f.validation.FieldError(field); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f formState) bypassAnalysis() []string {
|
||||
var lines []string
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range f.validation.BypassItems {
|
||||
if item.Err != nil {
|
||||
lines = append(lines, fmt.Sprintf("! item %d %q — invalid: %s", item.Index, item.Input, item.Err))
|
||||
continue
|
||||
}
|
||||
if !seen[item.Normalized] {
|
||||
seen[item.Normalized] = true
|
||||
lines = append(lines, "• "+item.Normalized)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
Reference in New Issue
Block a user