287 lines
7.8 KiB
Go
287 lines
7.8 KiB
Go
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 protocolProbeFieldState struct {
|
|
active bool
|
|
generation uint64
|
|
address string
|
|
feedback string
|
|
field profile.Field
|
|
}
|
|
|
|
type formState struct {
|
|
id uint64
|
|
originalName string
|
|
profile profile.Profile
|
|
inputs []formInput
|
|
field int
|
|
bypassPreviewLine int
|
|
validation profile.Validation
|
|
advancedInitialized bool
|
|
probe protocolProbeFieldState
|
|
}
|
|
|
|
func newFormState(id uint64, originalName string, value profile.Profile) formState {
|
|
if value.Mode == "" {
|
|
value.Mode = profile.SingleMode
|
|
}
|
|
form := formState{
|
|
id: id,
|
|
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
|
|
changedField := f.inputs[f.field].field
|
|
f.syncFocusedValue()
|
|
if changedField == profile.SingleURLField {
|
|
f.invalidateProbe()
|
|
}
|
|
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()
|
|
f.invalidateProbe()
|
|
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) invalidateProbe() {
|
|
f.probe.generation++
|
|
f.probe.active = false
|
|
f.probe.address = ""
|
|
f.probe.feedback = ""
|
|
f.probe.field = ""
|
|
}
|
|
|
|
func (f formState) probeText(field profile.Field) string {
|
|
if f.profile.Mode == profile.SingleMode && field == profile.SingleURLField {
|
|
if f.probe.feedback != "" {
|
|
return f.probe.feedback
|
|
}
|
|
return "Protocol Probe [Ctrl+D]: available"
|
|
}
|
|
if field == f.probe.field {
|
|
return f.probe.feedback
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|