Files
proxy/internal/tui/model.go
T

927 lines
26 KiB
Go

package tui
import (
"context"
"errors"
"strings"
tea "github.com/charmbracelet/bubbletea"
"proxyctl/internal/app"
"proxyctl/internal/probe"
"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
probing bool
probeGen uint64
}
type Model struct {
application app.Module
presentation terminalPresentation
snapshot app.Snapshot
page page
width int
height int
selected int
selectedTargetID string
dashboardFocus dashboardPane
showUnavailable bool
detailsOffset int
form formState
confirm bool
targetConfirm *app.Intent
profileConfirm *app.Intent
pendingActivation app.Intent
status string
operationStatus app.OperationStatus
currentError *app.OperationStatus
errorExpanded bool
confirmation *app.Confirmation
operationActive bool
operationKind app.IntentKind
operationIntent app.Intent
operationUpdates <-chan app.OperationUpdate
operationProgress map[string]app.TargetProgressPhase
applying bool
refreshing bool
detectionUpdates <-chan app.TargetDetectionUpdate
profileTransition bool
progressTarget string
quitRequested bool
detect func(context.Context, string) (probe.Capabilities, error)
}
type targetOperationDone struct {
result app.Result
}
type refreshDone struct {
result app.Result
}
type targetDetectionProgress struct {
update app.TargetDetectionUpdate
updates <-chan app.TargetDetectionUpdate
closed bool
}
type profileOperationProgress struct {
result app.Result
updates <-chan app.Result
}
type operationProgress struct {
update app.OperationUpdate
updates <-chan app.OperationUpdate
closed bool
}
// probeDone carries one finished Protocol Probe back to the form. address is
// always bare host:port so status text can never leak credentials. gen matches
// the probe generation stored in formState so late results can be ignored after
// the user leaves the form.
type probeDone struct {
address string
caps probe.Capabilities
err error
gen uint64
}
func waitForTargetDetection(updates <-chan app.TargetDetectionUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return targetDetectionProgress{updates: updates, closed: true}
}
return targetDetectionProgress{update: update, updates: updates}
}
}
func waitForOperation(updates <-chan app.OperationUpdate) tea.Cmd {
return func() tea.Msg {
update, ok := <-updates
if !ok {
return operationProgress{updates: updates, closed: true}
}
return operationProgress{update: update, updates: updates}
}
}
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, configure ...ModelOption) Model {
options := defaultModelOptions()
for _, option := range configure {
option(&options)
}
snapshot := application.Snapshot()
model := Model{
application: application,
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
snapshot: snapshot,
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
width: 80,
height: 24,
operationProgress: make(map[string]app.TargetProgressPhase),
detect: probe.Detect,
}
_, progressiveDetection := application.(app.ProgressiveDetectionModule)
if targetsChecking(snapshot) || progressiveDetection {
model.startOperation(app.Intent{Kind: app.RefreshTargets})
}
return model
}
func targetsChecking(snapshot app.Snapshot) bool {
for _, item := range snapshot.Targets {
if item.DetectionPhase == app.TargetChecking {
return true
}
}
return false
}
func (m Model) Init() tea.Cmd {
if m.operationUpdates != nil {
return waitForOperation(m.operationUpdates)
}
return nil
}
// busy is the single-operation guard for asynchronous Application work. Every
// operation start checks it, so a completion can only belong to the one current
// operation rather than racing another operation for shared model state.
func (m Model) busy() bool {
return m.quitRequested || m.operationInFlight() || m.pendingActivation.Kind != 0
}
func (m Model) operationInFlight() bool {
return m.operationActive || m.applying || m.refreshing || m.profileTransition
}
func (m Model) dashboardNavigationAvailable() bool {
return !m.quitRequested
}
func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) {
if size, ok := message.(tea.WindowSizeMsg); ok {
m.width = size.Width
m.height = size.Height
return m, nil
}
// A deferred tea.Quit is queued only after the in-flight completion has
// been consumed. Until Bubble Tea processes that QuitMsg, keep the model
// terminal so intervening input cannot cancel a confirmation or start a
// second mutation.
if m.quitRequested && !m.operationInFlight() {
return m, nil
}
if key, ok := message.(tea.KeyMsg); ok {
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q" && m.targetConfirm == nil && m.profileConfirm == nil)
if exitRequest {
if m.operationInFlight() {
m.quitRequested = true
return m, nil
}
return m, tea.Quit
}
}
if progress, ok := message.(operationProgress); ok {
if !m.operationActive || progress.updates != m.operationUpdates {
return m, nil
}
if progress.closed {
m.snapshot = m.application.Snapshot()
m.retainTargetSelection()
m.finishOperation()
return m, m.deferredQuit()
}
m.applyOperationUpdate(progress.update)
if isTerminalOperationPhase(progress.update.Phase) {
m.finishOperation()
return m, m.deferredQuit()
}
return m, waitForOperation(progress.updates)
}
if progress, ok := message.(targetDetectionProgress); ok {
if !m.refreshing || progress.updates != m.detectionUpdates {
return m, nil
}
if progress.closed {
// A stale Application generation closes without claiming an
// authoritative completion snapshot. Reconcile with the Application
// so this Model does not strand rows in its superseded Checking frame.
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = m.application.Snapshot()
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
m.refreshing = false
m.detectionUpdates = nil
return m, m.deferredQuit()
}
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = progress.update.Snapshot
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
if progress.update.Complete {
m.refreshing = false
m.detectionUpdates = nil
m.status = "Target availability and state checks completed"
return m, m.deferredQuit()
}
return m, waitForTargetDetection(progress.updates)
}
if completed, ok := message.(refreshDone); ok {
if !m.refreshing {
return m, nil
}
m.refreshing = false
formerCatalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
formerIndex := formerCatalog.index(m.selectedTargetID)
m.snapshot = completed.result.Snapshot
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
if completed.result.Err != nil {
m.status = "Error: " + completed.result.Err.Error()
} else {
m.status = completed.result.Notice
}
return m, m.deferredQuit()
}
if progress, ok := message.(profileOperationProgress); ok {
if !m.profileTransition {
return m, nil
}
m.snapshot = progress.result.Snapshot
m.retainTargetSelection()
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, m.deferredQuit()
}
if progress.result.Err != nil {
m.status = "Error: " + progress.result.Err.Error()
} 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, m.deferredQuit()
}
return m, waitForProfileProgress(progress.updates)
}
if completed, ok := message.(targetOperationDone); ok {
if !m.applying || m.profileTransition {
return m, nil
}
m.applying = false
m.snapshot = completed.result.Snapshot
m.retainTargetSelection()
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 = "Error: " + completed.result.Err.Error()
} else {
m.targetConfirm = nil
m.status = completed.result.Notice
}
return m, m.deferredQuit()
}
if done, ok := message.(probeDone); ok {
if !m.form.probing || done.gen != m.form.probeGen {
return m, nil
}
m.form.probing = false
switch {
case done.err != nil:
m.status = "Protocol Probe: no proxy detected at " + done.address + ": " + done.err.Error()
case !done.caps.HTTP && !done.caps.SOCKS5:
m.status = "Protocol Probe: unrecognized protocol at " + done.address
default:
raw := m.form.profile.SingleURL
if done.caps.HTTP && done.caps.SOCKS5 {
httpURL, err := probe.URL(raw, probe.Capabilities{HTTP: true})
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
socksURL, err := probe.URL(raw, probe.Capabilities{SOCKS5: true})
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.profile.Mode = profile.AdvancedMode
m.form.profile.HTTPURL = httpURL
m.form.profile.HTTPSURL = httpURL
m.form.profile.AllURL = strings.Replace(socksURL, "socks5h://", "socks5://", 1)
m.status = "Protocol Probe: detected HTTP and SOCKS5 proxy at " + done.address
return m, nil
}
rewritten, err := probe.URL(raw, done.caps)
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.profile.SingleURL = rewritten
kind := "SOCKS5"
if done.caps.HTTP {
kind = "HTTP"
}
m.status = "Protocol Probe: detected " + kind + " proxy at " + done.address
}
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) startOperation(intent app.Intent) tea.Cmd {
if m.quitRequested {
return nil
}
if m.operationActive {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before starting another modification"}
return nil
}
interactive, ok := m.application.(app.InteractiveModule)
if !ok {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Interactive operation streaming is unavailable"}
return nil
}
stream := interactive.StartOperation(intent)
m.snapshot = stream.Snapshot
m.retainTargetSelection()
m.operationActive = true
m.operationKind = intent.Kind
m.operationIntent = intent
m.operationUpdates = stream.Updates
m.operationProgress = make(map[string]app.TargetProgressPhase)
m.applying = intent.Kind == app.SetTargetDesired || intent.Kind == app.ActivateProfile
m.refreshing = intent.Kind == app.RefreshTargets
m.profileTransition = intent.Kind == app.ActivateProfile
m.progressTarget = ""
m.operationStatus = app.OperationStatus{Kind: app.StatusProgress, Summary: operationStartingSummary(intent.Kind)}
return waitForOperation(stream.Updates)
}
func operationStartingSummary(kind app.IntentKind) string {
switch kind {
case app.RefreshTargets:
return "Checking Target availability and state…"
case app.ActivateProfile:
return "Changing Active Profile…"
default:
return "Starting Target mutation…"
}
}
func (m *Model) finishOperation() {
m.operationActive = false
m.operationKind = 0
m.operationIntent = app.Intent{}
m.operationUpdates = nil
m.applying = false
m.refreshing = false
m.profileTransition = false
m.progressTarget = ""
}
func isTerminalOperationPhase(phase app.OperationPhase) bool {
return phase == app.OperationAwaitingConfirmation || phase == app.OperationSucceeded || phase == app.OperationFailed || phase == app.OperationBlocked
}
func (m *Model) applyOperationUpdate(update app.OperationUpdate) {
m.snapshot = update.Snapshot
m.retainTargetSelection()
for _, progress := range update.Progress {
m.operationProgress[progress.TargetID] = progress.Phase
if progress.Phase == app.TargetProgressRunning {
m.progressTarget = progress.TargetID
} else if m.progressTarget == progress.TargetID {
m.progressTarget = ""
}
}
if update.Status.Kind != 0 {
m.operationStatus = update.Status
}
if update.Status.Kind == app.StatusError {
current := update.Status
if current.Detail == "" && update.Err != nil {
current.Detail = update.Err.Error()
}
m.currentError = &current
m.errorExpanded = false
} else if update.Phase == app.OperationSucceeded && m.currentError != nil && update.Status.TargetID != "" && update.Status.TargetID == m.currentError.TargetID {
m.currentError = nil
m.errorExpanded = false
}
if update.FirstFailedTargetID != "" {
m.selectedTargetID = update.FirstFailedTargetID
m.detailsOffset = 0
}
if isTerminalOperationPhase(update.Phase) && update.Phase != app.OperationAwaitingConfirmation {
m.pendingActivation = app.Intent{}
}
if update.Phase == app.OperationAwaitingConfirmation && update.Confirmation != nil {
intent := m.operationIntent
m.confirmation = update.Confirmation
m.page = mainPage
if intent.Kind == app.ActivateProfile {
m.pendingActivation = intent
m.profileConfirm = &intent
} else {
m.targetConfirm = &intent
}
}
}
func isQuitKey(key tea.KeyMsg) bool {
switch key.String() {
case "q", "esc", "ctrl+c":
return true
default:
return false
}
}
func (m Model) deferredQuit() tea.Cmd {
if m.quitRequested {
return tea.Quit
}
return nil
}
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.targetConfirm != nil || m.profileConfirm != nil {
intent := m.targetConfirm
if intent == nil {
intent = m.profileConfirm
}
switch key.String() {
case "y":
confirmed := *intent
confirmed.Confirm = true
m.targetConfirm = nil
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
m.confirmation = nil
m.status = ""
return m, m.startOperation(confirmed)
case "enter", "n", "esc", "q":
m.targetConfirm = nil
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
m.confirmation = nil
m.operationStatus = app.OperationStatus{}
}
return m, nil
}
if key.String() == "e" && m.currentError != nil {
m.errorExpanded = !m.errorExpanded
return m, nil
}
if key.String() == "x" && m.currentError != nil {
m.currentError = nil
m.errorExpanded = false
return m, nil
}
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
switch key.String() {
case "p":
if m.operationActive || m.quitRequested {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before opening Profile management"}
} else {
m.page = profilesPage
m.selected = 0
m.status = ""
}
case "?":
if !m.quitRequested {
m.page = helpPage
}
case "r":
if m.operationActive || m.quitRequested {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before refreshing Targets"}
} else {
return m, m.startOperation(app.Intent{Kind: app.RefreshTargets})
}
case "tab":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetListPane {
m.dashboardFocus = targetDetailsPane
} else {
m.dashboardFocus = targetListPane
}
}
case "u":
if m.dashboardNavigationAvailable() {
formerIndex := catalog.index(m.selectedTargetID)
m.showUnavailable = !m.showUnavailable
m.selectedTargetID = newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID).retain(m.selectedTargetID, formerIndex)
m.detailsOffset = 0
}
case "up", "k":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-1)
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, -1)
m.detailsOffset = 0
}
}
case "down", "j":
if m.dashboardNavigationAvailable() {
if m.dashboardFocus == targetDetailsPane {
m.detailsOffset++
} else {
m.selectedTargetID = catalog.move(m.selectedTargetID, 1)
m.detailsOffset = 0
}
}
case "pgup":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset = max(0, m.detailsOffset-5)
}
case "pgdown":
if m.dashboardNavigationAvailable() && m.dashboardFocus == targetDetailsPane {
m.detailsOffset += 5
}
case " ":
if m.operationActive || m.quitRequested {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before starting another modification"}
break
}
selected, ok := catalog.selected(m.selectedTargetID)
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
break
}
desired := selected.State == target.Disabled || selected.State == target.External
intent := app.Intent{Kind: app.SetTargetDesired, TargetID: selected.ID, Enabled: desired}
return m, m.startOperation(intent)
case "esc":
if !m.operationActive {
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
m.pendingActivation = app.Intent{}
return m.beginProfileActivation(intent)
case "n", "esc", "q":
m.profileConfirm = nil
m.pendingActivation = app.Intent{}
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
}
if m.busy() {
if key.String() == "q" || key.String() == "esc" {
m.page = mainPage
m.selected = 0
m.status = ""
}
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 = ""
m.form = formState{}
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()
m.form.probeGen++ // discard any in-flight probe result while staying on the form
m.form.probing = false
return m, nil
}
m.status = result.Notice
m.page = profilesPage
m.selectName(intent.Profile.Name)
m.form = formState{}
return m, nil
case "ctrl+d":
if m.form.profile.Mode != profile.SingleMode {
m.status = "Protocol Probe: available in Single mode only"
return m, nil
}
if m.form.field != 1 || m.form.probing {
return m, nil
}
address, err := probeAddress(m.form.profile.SingleURL)
if err != nil {
m.status = "Protocol Probe: " + err.Error()
return m, nil
}
m.form.probing = true
m.form.probeGen++
m.status = "Protocol Probe: checking " + address + "…"
detect := m.detect
gen := m.form.probeGen
return m, func() tea.Msg {
caps, err := detect(context.Background(), address)
return probeDone{address: address, caps: caps, err: err, gen: gen}
}
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 m.busy() {
m.operationStatus = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
return m, nil
}
m.pendingActivation = intent
m.page = mainPage
m.selected = 0
return m, m.startOperation(intent)
}
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
}
}
// probeAddress extracts a bare host:port from the typed endpoint for dialing
// and status text, dropping any userinfo so credentials are never displayed
// or sent anywhere.
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 (m Model) selectedProfile() (profile.Profile, bool) {
return profileAt(m.snapshot, m.selected)
}
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 {
return m.presentation.Render(presentationState{
snapshot: m.snapshot,
page: m.page,
width: m.width,
height: m.height,
selected: m.selected,
selectedTargetID: m.selectedTargetID,
dashboardFocus: m.dashboardFocus,
showUnavailable: m.showUnavailable,
detailsOffset: m.detailsOffset,
form: m.form,
confirm: m.confirm,
targetConfirm: m.targetConfirm != nil,
profileConfirm: m.profileConfirm != nil,
confirmation: m.confirmation,
status: m.status,
operationStatus: m.operationStatus,
currentError: m.currentError,
errorExpanded: m.errorExpanded,
exitDeferred: m.quitRequested && m.operationInFlight(),
operationActive: m.operationActive,
operationKind: m.operationKind,
applying: m.applying,
profileTransition: m.profileTransition,
progressTarget: m.progressTarget,
})
}
func (m Model) availableTargets() []app.TargetSnapshot {
return availableTargets(m.snapshot)
}
func (m *Model) retainTargetSelection() {
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
m.selectedTargetID = catalog.retain(m.selectedTargetID, catalog.index(m.selectedTargetID))
}
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}
}
}