package tui import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "proxyctl/internal/app" "proxyctl/internal/profile" "proxyctl/internal/target" ) type page int const ( mainPage page = iota profilesPage formPage ) type formState struct { originalName string profile profile.Profile field int } type Model struct { application app.Module snapshot app.Snapshot page page selected int form formState confirm bool status string applying bool } type targetOperationDone struct { result app.Result } func NewModel(application app.Module) Model { return Model{application: application, snapshot: application.Snapshot()} } func (m Model) Init() tea.Cmd { return nil } func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if completed, ok := message.(targetOperationDone); ok { m.applying = false m.snapshot = completed.result.Snapshot if completed.result.Err != nil { m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m" } else { m.status = completed.result.Notice } return m, nil } key, ok := message.(tea.KeyMsg) if !ok { return m, nil } switch m.page { case mainPage: return m.updateMain(key) case profilesPage: return m.updateProfiles(key) case formPage: return m.updateForm(key) default: return m, nil } } func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) { targets := m.availableTargets() switch key.String() { case "p": m.page = profilesPage m.status = "" case "up", "k": if !m.applying && m.selected > 0 { m.selected-- } case "down", "j": if !m.applying && m.selected+1 < len(targets) { m.selected++ } case " ": if !m.applying && m.selected >= 0 && m.selected < len(targets) { selected := targets[m.selected] desired := selected.State != target.Enabled m.applying = true m.status = "" return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired})} } } case "q", "esc", "ctrl+c": return m, tea.Quit } return m, nil } func (m Model) updateProfiles(key tea.KeyMsg) (tea.Model, tea.Cmd) { if m.confirm { switch key.String() { case "y": if selected, ok := m.selectedProfile(); ok { m.apply(app.Intent{Kind: app.DeleteProfile, Name: selected.Name, Confirm: true}) } m.confirm = false case "n", "esc", "q": m.confirm = false } return m, nil } switch key.String() { case "q", "esc": m.page = mainPage m.status = "" case "up", "k": if m.selected > 0 { m.selected-- } case "down", "j": if m.selected+1 < len(m.snapshot.Profiles) { m.selected++ } case "n": m.form = formState{profile: profile.Profile{Mode: profile.SingleMode}} m.page = formPage m.status = "" case "e": if selected, ok := m.selectedProfile(); ok { m.form = formState{originalName: selected.Name, profile: selected} m.page = formPage m.status = "" } case "a", "enter": if selected, ok := m.selectedProfile(); ok { m.apply(app.Intent{Kind: app.ActivateProfile, Name: selected.Name}) } case "d": if _, ok := m.selectedProfile(); ok { m.confirm = true } } return m, nil } func (m Model) updateForm(key tea.KeyMsg) (tea.Model, tea.Cmd) { switch key.String() { case "esc": m.page = profilesPage m.status = "" return m, nil case "ctrl+s": 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.application.Dispatch(intent) m.snapshot = result.Snapshot if result.Err != nil { m.status = "Error: " + result.Err.Error() return m, nil } m.status = result.Notice m.page = profilesPage m.selectName(intent.Profile.Name) return m, nil 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 } if key.Type == tea.KeyRunes { m.form.setValue(m.form.value() + string(key.Runes)) } return m, nil } func (m *Model) apply(intent app.Intent) { result := m.application.Dispatch(intent) m.snapshot = result.Snapshot if m.selected >= len(m.snapshot.Profiles) && m.selected > 0 { m.selected-- } if result.Err != nil { m.status = "Error: " + result.Err.Error() } else { m.status = result.Notice } } func (m Model) selectedProfile() (profile.Profile, bool) { if m.selected < 0 || m.selected >= len(m.snapshot.Profiles) { return profile.Profile{}, false } return m.snapshot.Profiles[m.selected], true } func (m *Model) selectName(name string) { for i := range m.snapshot.Profiles { if m.snapshot.Profiles[i].Name == name { m.selected = i return } } } func (m Model) View() string { switch m.page { case profilesPage: return m.profilesView() case formPage: return m.formView() default: return m.mainView() } } func (m Model) availableTargets() []app.TargetSnapshot { var result []app.TargetSnapshot for _, item := range m.snapshot.Targets { if item.Available { result = append(result, item) } } return result } func (m Model) mainView() string { var b strings.Builder b.WriteString("proxyctl — Target panel\n\n") if m.snapshot.ActiveProfile == "" { b.WriteString("No Active Profile\n") b.WriteString("Create and activate a Proxy Profile before enabling Targets.\n") } else { fmt.Fprintf(&b, "Active Profile: %s\n", m.snapshot.ActiveProfile) } targets := m.availableTargets() if len(targets) == 0 { b.WriteString("\nNo Targets are available in this build.\n") } else { b.WriteString("\nTargets\n") for i, item := range targets { cursor := " " if i == m.selected { cursor = "> " } state := string(item.State) if m.applying && i == m.selected { state = "Applying…" } fmt.Fprintf(&b, "%s[%-9s] %s\n", cursor, state, item.Description.Name) } if m.selected >= 0 && m.selected < len(targets) { selected := targets[m.selected] fmt.Fprintf(&b, "\n%s\n%s\n", selected.Description.Summary, selected.Evidence) } } for _, item := range m.snapshot.Targets { if !item.Available { fmt.Fprintf(&b, "\nUnavailable %s: %s\n", item.Description.Name, item.AvailabilityEvidence) } } b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles q/Esc: quit\n") if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } return b.String() } func (m Model) profilesView() string { var b strings.Builder b.WriteString("Proxy Profiles\n\n") if len(m.snapshot.Profiles) == 0 { b.WriteString("No Proxy Profiles. Press n to create one.\n") } for i, item := range m.snapshot.Profiles { cursor, active := " ", " " if i == m.selected { cursor = "> " } if item.Name == m.snapshot.ActiveProfile { active = "*" } fmt.Fprintf(&b, "%s%s %s\n", cursor, active, item.Name) } if selected, ok := m.selectedProfile(); ok { b.WriteString("\nSelected Profile\n") fmt.Fprintf(&b, "Mode: %s\n", selected.Mode) if selected.Mode == profile.SingleMode { fmt.Fprintf(&b, "Proxy: %s\n", profile.ObscureURL(selected.SingleURL)) } else { fmt.Fprintf(&b, "HTTP: %s\nHTTPS: %s\nALL/SOCKS: %s\n", profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL)) } fmt.Fprintf(&b, "Bypass: %s\n", strings.Join(selected.Bypass, ", ")) } if m.confirm { b.WriteString("\nDelete Profile? y: confirm n/Esc: cancel\n") } else { b.WriteString("\nn: new e: edit d: delete a/Enter: activate\n↑/k ↓/j: select q/Esc: back\n") } if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } return b.String() } func (m Model) formView() string { var b strings.Builder action := "Create" if m.form.originalName != "" { action = "Edit" } fmt.Fprintf(&b, "%s Proxy Profile — %s mode\n\n", action, m.form.profile.Mode) labels := m.form.labels() for i, label := range labels { cursor := " " if i == m.form.field { cursor = "> " } value := m.form.valueAt(i) if label != "Name" && label != "Bypass" { value = profile.ObscureURL(value) } fmt.Fprintf(&b, "%s%s: %s\n", cursor, label, value) } b.WriteString("\nTab/↑/↓: move Ctrl+T: toggle Single/Advanced\nCtrl+S: save Esc: cancel\n") if m.status != "" { fmt.Fprintf(&b, "\n%s\n", m.status) } return b.String() } 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} } }