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 }