Files
proxy/internal/tui/model.go
T

575 lines
15 KiB
Go

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
helpPage
)
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
targetConfirm *app.Intent
profileConfirm *app.Intent
pendingActivation app.Intent
status string
applying bool
profileTransition bool
progressTarget string
}
type targetOperationDone struct {
result app.Result
}
type profileOperationProgress struct {
result app.Result
updates <-chan app.Result
}
func waitForProfileProgress(updates <-chan app.Result) tea.Cmd {
return func() tea.Msg {
result, ok := <-updates
if !ok {
return profileOperationProgress{result: app.Result{Complete: true}, updates: updates}
}
return profileOperationProgress{result: result, updates: updates}
}
}
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 progress, ok := message.(profileOperationProgress); ok {
m.snapshot = progress.result.Snapshot
m.progressTarget = ""
if len(progress.result.Progress) > 0 {
item := progress.result.Progress[len(progress.result.Progress)-1]
if item.Applying {
m.progressTarget = item.TargetID
}
}
if progress.result.Confirmation != "" {
m.applying = false
m.profileTransition = false
m.page = profilesPage
intent := m.pendingActivation
m.profileConfirm = &intent
m.status = progress.result.Confirmation
return m, nil
}
if progress.result.Err != nil {
m.status = "\x1b[31mError: " + progress.result.Err.Error() + "\x1b[0m"
} else if progress.result.Notice != "" {
m.status = progress.result.Notice
}
if progress.result.Complete {
m.applying = false
m.profileTransition = false
m.progressTarget = ""
m.pendingActivation = app.Intent{}
return m, nil
}
return m, waitForProfileProgress(progress.updates)
}
if completed, ok := message.(targetOperationDone); ok {
m.applying = false
m.snapshot = completed.result.Snapshot
if completed.result.Confirmation != "" {
intent := app.Intent{Kind: app.SetTargetDesired}
if m.targetConfirm != nil {
intent = *m.targetConfirm
}
m.targetConfirm = &intent
m.status = completed.result.Confirmation
} else if completed.result.Err != nil {
m.targetConfirm = nil
m.status = "\x1b[31mError: " + completed.result.Err.Error() + "\x1b[0m"
} else {
m.targetConfirm = nil
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)
case helpPage:
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
}
return m, nil
default:
return m, nil
}
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.targetConfirm != nil && !m.applying {
switch key.String() {
case "y":
intent := *m.targetConfirm
intent.Confirm = true
m.targetConfirm = nil
m.applying = true
m.status = ""
return m, func() tea.Msg { return targetOperationDone{result: m.application.Dispatch(intent)} }
case "n", "esc", "q":
m.targetConfirm = nil
m.status = ""
}
return m, nil
}
targets := m.availableTargets()
switch key.String() {
case "p":
m.page = profilesPage
m.selected = 0
m.status = ""
case "?":
m.page = helpPage
m.status = ""
case "r":
if !m.applying {
result := m.application.Dispatch(app.Intent{Kind: app.RefreshTargets})
m.snapshot = result.Snapshot
m.status = result.Notice
if result.Err != nil {
m.status = "Error: " + result.Err.Error()
}
if m.selected >= len(m.availableTargets()) && m.selected > 0 {
m.selected--
}
}
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.Disabled || selected.State == target.External
m.applying = true
m.status = ""
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
m.targetConfirm = &intent
return m, func() tea.Msg {
return targetOperationDone{result: m.application.Dispatch(intent)}
}
}
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.profileConfirm != nil {
switch key.String() {
case "y":
intent := *m.profileConfirm
intent.Confirm = true
m.profileConfirm = nil
return m.beginProfileActivation(intent)
case "n", "esc", "q":
m.profileConfirm = nil
m.status = ""
}
return m, nil
}
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.selected = 0
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 {
return m.beginProfileActivation(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) beginProfileActivation(intent app.Intent) (tea.Model, tea.Cmd) {
if progressive, ok := m.application.(app.ProgressiveModule); ok {
m.applying = true
m.profileTransition = true
m.pendingActivation = intent
m.page = mainPage
m.selected = 0
m.status = "Changing Active Profile…"
updates := progressive.DispatchProgress(intent)
return m, waitForProfileProgress(updates)
}
m.apply(intent)
return m, nil
}
func (m *Model) apply(intent app.Intent) {
result := m.application.Dispatch(intent)
m.snapshot = result.Snapshot
if result.Confirmation != "" {
m.profileConfirm = &intent
m.status = result.Confirmation
return
}
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()
case helpPage:
return m.helpView()
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 currently Available. Press ? to review detection reasons.\n")
} else {
b.WriteString("\nTargets\n")
for i, item := range targets {
cursor := " "
if i == m.selected {
cursor = "> "
}
state := string(item.State)
if (m.progressTarget != "" && item.ID == m.progressTarget) || (!m.profileTransition && m.progressTarget == "" && 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%s\n", selected.Description.Summary, selected.Description.Details, selected.Evidence)
}
}
if m.targetConfirm != nil && !m.applying {
b.WriteString("\ny: confirm n/Esc: cancel\n")
} else {
b.WriteString("\nSpace: toggle ↑/k ↓/j: select p: manage Proxy Profiles r: refresh ?: help q/Esc: quit\n")
}
if m.status != "" {
fmt.Fprintf(&b, "\n%s\n", m.status)
}
return b.String()
}
func (m Model) helpView() string {
var b strings.Builder
b.WriteString("proxyctl Help — built-in Targets\n\n")
for _, item := range m.snapshot.Targets {
fmt.Fprintf(&b, "%s\n%s\n%s\n", item.Description.Name, item.Description.Summary, item.Description.Details)
if item.Available {
fmt.Fprintf(&b, "Detected: %s\n\n", item.AvailabilityEvidence)
} else {
fmt.Fprintf(&b, "Not detected: %s\n\n", item.AvailabilityEvidence)
}
}
b.WriteString("Keys: ↑/k and ↓/j navigate; Space toggles; p manages Proxy Profiles; r refreshes; ? opens help; q/Escape returns or exits.\n")
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.profileConfirm != nil {
b.WriteString("\ny: confirm n/Esc: cancel\n")
} else if m.confirm {
if len(m.snapshot.Profiles) == 1 {
b.WriteString("\nDelete Profile — this is the final Proxy Profile. Managed configuration will remain in place and become Drifted. y: confirm n/Esc: cancel\n")
} else {
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}
}
}