pi-agent: Implement mature Profile editor
This commit is contained in:
@@ -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