package tui import ( "errors" "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/probe" "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 probeToken struct { formID uint64 generation uint64 source profile.Field } type protocolProbeFieldState struct { active bool token probeToken address string feedback string sourceValue string sourceAttached bool } type formState struct { id uint64 originalName string profile profile.Profile inputs []formInput field int bypassPreviewLine int validation profile.Validation advancedInitialized bool probeGeneration uint64 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 isProxyURLField(changedField) { 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 isProxyURLField(field profile.Field) bool { switch field { case profile.SingleURLField, profile.HTTPURLField, profile.HTTPSURLField, profile.AllURLField: return true default: return false } } func (f *formState) invalidateProbe() { f.probeGeneration++ f.probe = protocolProbeFieldState{} } // startProbe owns applicability, duplicate suppression, source capture, and // token issuance so callers cannot accidentally start work outside formState's // stale-result rules. func (f *formState) startProbe() (string, probeToken, bool) { if f.profile.Mode != profile.SingleMode || f.currentField() != profile.SingleURLField || f.probe.active { return "", probeToken{}, false } f.syncFocusedValue() f.invalidateProbe() token := probeToken{formID: f.id, generation: f.probeGeneration, source: profile.SingleURLField} f.probe.token = token f.probe.sourceValue = f.profile.SingleURL address, err := probeAddress(f.probe.sourceValue) if err != nil { f.probe.feedback = "Protocol Probe: " + err.Error() return "", probeToken{}, false } f.probe.active = true f.probe.address = address f.probe.feedback = "Protocol Probe: checking " + address + "…" return address, token, true } // applyProbeResult validates and applies a completion as one form-owned // operation. Every stale-result invariant stays here: form identity, // generation, source field, active work, mode, and unchanged source value. func (f *formState) applyProbeResult(token probeToken, caps probe.Capabilities, resultErr error) tea.Cmd { if !f.probe.active || token != f.probe.token || token.formID != f.id || token.source != profile.SingleURLField || f.profile.Mode != profile.SingleMode || f.profile.SingleURL != f.probe.sourceValue { return nil } f.probe.active = false address, raw := f.probe.address, f.probe.sourceValue switch { case resultErr != nil: f.probe.feedback = "Protocol Probe failed for " + address case !caps.HTTP && !caps.SOCKS5: f.probe.feedback = "Protocol Probe: unrecognized protocol at " + address case caps.HTTP && caps.SOCKS5: httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true}) if err != nil { f.probe.feedback = "Protocol Probe could not apply the detected capabilities" return nil } socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true}) if err != nil { f.probe.feedback = "Protocol Probe could not apply the detected capabilities" return nil } f.profile.Mode = profile.AdvancedMode f.profile.HTTPURL = httpURL f.profile.HTTPSURL = httpURL f.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1) f.advancedInitialized = true f.probe.feedback = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + address f.probe.sourceAttached = true return f.rebuildInputs(1) default: rewritten, err := probe.URL(raw, caps) if err != nil { f.probe.feedback = "Protocol Probe could not apply the detected capabilities" return nil } f.profile.SingleURL = rewritten f.setInputValue(profile.SingleURLField, rewritten) kind := "SOCKS5" if caps.HTTP { kind = "HTTP" } f.probe.feedback = "Protocol Probe: detected " + kind + " proxy at " + address } f.refreshValidation() return nil } func (f *formState) setInputValue(field profile.Field, value string) { for i := range f.inputs { if f.inputs[i].field != field { continue } position := f.inputs[i].input.Position() f.inputs[i].input.SetValue(value) f.inputs[i].input.SetCursor(position) return } } 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" } return "" } func (f formState) hasProbeSourceAttachment() bool { return f.profile.Mode == profile.AdvancedMode && f.probe.sourceAttached && f.probe.token.source == profile.SingleURLField && f.probe.feedback != "" } func probeAddress(raw string) (string, error) { u, err := profile.ParseProxyURL(raw) if err != nil { return "", errors.New("Single proxy URL must include host:port") } return u.Host, nil } 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 }