711 lines
20 KiB
Go
711 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/probe"
|
|
"proxyctl/internal/profile"
|
|
)
|
|
|
|
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.InteractiveModule
|
|
presentation terminalPresentation
|
|
snapshot app.Snapshot
|
|
page page
|
|
width int
|
|
height int
|
|
selected int
|
|
selectedTargetID string
|
|
dashboardFocus dashboardPane
|
|
showUnavailable bool
|
|
detailsOffset int
|
|
driftResolutionTargetID string
|
|
form formState
|
|
confirm bool
|
|
status string
|
|
operation operationCoordinator
|
|
detect func(context.Context, string) (probe.Capabilities, error)
|
|
}
|
|
|
|
// 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 NewModel(application app.InteractiveModule, 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,
|
|
operation: newOperationCoordinator(),
|
|
detect: probe.Detect,
|
|
}
|
|
if targetsChecking(snapshot) {
|
|
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.operation.updates != nil {
|
|
return waitForOperation(m.operation.updates)
|
|
}
|
|
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.operation.busy() }
|
|
|
|
func (m Model) operationInFlight() bool { return m.operation.active }
|
|
|
|
func (m Model) dashboardNavigationAvailable() bool {
|
|
// A deferred exit rejects new work, not read-only browsing. Selection,
|
|
// details, and Help remain useful until the current mutation is safe.
|
|
return true
|
|
}
|
|
|
|
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.operation.quitRequested && !m.operationInFlight() {
|
|
return m, nil
|
|
}
|
|
if key, ok := message.(tea.KeyMsg); ok {
|
|
// Dashboard q always means exit, even when confirmation is visible.
|
|
// Escape remains the deterministic cancel/back key.
|
|
exitRequest := key.String() == "ctrl+c" || (m.page == mainPage && key.String() == "q")
|
|
if exitRequest {
|
|
return m, m.operation.requestQuit()
|
|
}
|
|
}
|
|
if progress, ok := message.(operationProgress); ok {
|
|
if !m.operation.active || progress.updates != m.operation.updates {
|
|
return m, nil
|
|
}
|
|
if progress.closed {
|
|
m.snapshot = m.application.Snapshot()
|
|
m.retainTargetSelection()
|
|
m.operation.unexpectedClose()
|
|
return m, m.operation.deferredQuit()
|
|
}
|
|
m.snapshot = progress.update.Snapshot
|
|
m.retainTargetSelection()
|
|
if firstFailed := m.operation.apply(progress.update); firstFailed != "" {
|
|
m.selectedTargetID = firstFailed
|
|
m.detailsOffset = 0
|
|
}
|
|
if m.operation.confirmation != nil {
|
|
m.page = mainPage
|
|
}
|
|
if progress.update.IsTerminal() {
|
|
return m, m.operation.deferredQuit()
|
|
}
|
|
return m, waitForOperation(progress.updates)
|
|
}
|
|
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 {
|
|
snapshot, command, started := m.operation.start(m.application, intent)
|
|
if started {
|
|
m.snapshot = snapshot
|
|
m.retainTargetSelection()
|
|
}
|
|
return command
|
|
}
|
|
|
|
type mutationAvailability struct {
|
|
available bool
|
|
reason string
|
|
compactReason string
|
|
}
|
|
|
|
// targetMutationAvailability consumes the Application's structured action
|
|
// policy. It adds only Model-owned serialization state and never derives an
|
|
// action from Target state or display text.
|
|
func targetMutationAvailability(selected app.TargetSnapshot, selectedOK bool, operationBlocked bool) mutationAvailability {
|
|
switch {
|
|
case operationBlocked:
|
|
return mutationAvailability{reason: "Wait for the current mutation to finish before starting another modification", compactReason: "mutation in progress"}
|
|
case !selectedOK:
|
|
return mutationAvailability{reason: "Target mutation is disabled because no Target is selected", compactReason: "no Target selected"}
|
|
case !selected.PrimaryAction.Available:
|
|
reason := selected.PrimaryAction.DisabledReason
|
|
if reason == "" {
|
|
reason = "This Target action is unavailable"
|
|
}
|
|
return mutationAvailability{reason: reason, compactReason: "unavailable"}
|
|
default:
|
|
return mutationAvailability{available: true}
|
|
}
|
|
}
|
|
|
|
func driftResolutionAction(selected app.TargetSnapshot, kind app.TargetActionKind) (app.TargetAction, bool) {
|
|
for _, action := range selected.DriftResolutionActions {
|
|
if action.Kind == kind {
|
|
return action, true
|
|
}
|
|
}
|
|
return app.TargetAction{Kind: kind}, false
|
|
}
|
|
|
|
func targetIntent(kind app.TargetActionKind, targetID string) (app.Intent, bool) {
|
|
intent := app.Intent{TargetID: targetID}
|
|
switch kind {
|
|
case app.EnableTargetAction:
|
|
intent.Kind = app.EnableTarget
|
|
case app.DisableTargetAction:
|
|
intent.Kind = app.DisableTarget
|
|
case app.ApplyProfileTargetAction:
|
|
intent.Kind = app.ApplyProfileToTarget
|
|
case app.ReapplyActiveProfileAction:
|
|
intent.Kind = app.ReapplyActiveProfile
|
|
case app.RestoreOriginalAction:
|
|
intent.Kind = app.RestoreOriginal
|
|
default:
|
|
return app.Intent{}, false
|
|
}
|
|
return intent, true
|
|
}
|
|
|
|
func (m Model) updateMain(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|
if m.operation.confirmation != nil {
|
|
switch key.String() {
|
|
case "y":
|
|
confirmed, ok := m.operation.confirmedIntent()
|
|
if !ok {
|
|
return m, nil
|
|
}
|
|
m.status = ""
|
|
return m, m.startOperation(confirmed)
|
|
case "enter", "n", "esc":
|
|
m.operation.cancelConfirmation()
|
|
}
|
|
return m, nil
|
|
}
|
|
if m.driftResolutionTargetID != "" {
|
|
switch key.String() {
|
|
case "enter", "esc", "n":
|
|
m.driftResolutionTargetID = ""
|
|
return m, nil
|
|
case "r", "o":
|
|
kind := app.ReapplyActiveProfileAction
|
|
if key.String() == "o" {
|
|
kind = app.RestoreOriginalAction
|
|
}
|
|
selected, ok := newTargetCatalog(m.snapshot, true, m.driftResolutionTargetID).selected(m.driftResolutionTargetID)
|
|
if !ok {
|
|
m.driftResolutionTargetID = ""
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Drift Resolution is unavailable because the Target is no longer present"}
|
|
return m, nil
|
|
}
|
|
action, ok := driftResolutionAction(selected, kind)
|
|
if !ok || !action.Available {
|
|
reason := action.DisabledReason
|
|
if reason == "" {
|
|
reason = "This Drift Resolution action is unavailable"
|
|
}
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: reason}
|
|
return m, nil
|
|
}
|
|
intent, _ := targetIntent(kind, selected.ID)
|
|
m.driftResolutionTargetID = ""
|
|
return m, m.startOperation(intent)
|
|
}
|
|
return m, nil
|
|
}
|
|
if key.String() == "e" && m.operation.currentError != nil {
|
|
m.operation.errorExpanded = !m.operation.errorExpanded
|
|
m.operation.errorOffset = 0
|
|
return m, nil
|
|
}
|
|
if key.String() == "x" && m.operation.currentError != nil {
|
|
m.operation.acknowledgeError()
|
|
return m, nil
|
|
}
|
|
if m.operation.errorExpanded {
|
|
switch key.String() {
|
|
case "pgup":
|
|
m.operation.errorOffset = max(0, m.operation.errorOffset-5)
|
|
return m, nil
|
|
case "pgdown":
|
|
m.operation.errorOffset += 5
|
|
return m, nil
|
|
}
|
|
}
|
|
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
|
switch key.String() {
|
|
case "p":
|
|
if m.operation.active || m.operation.quitRequested {
|
|
m.operation.status = 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 = ""
|
|
if m.operation.status.Kind != app.StatusError {
|
|
m.operation.status = app.OperationStatus{}
|
|
}
|
|
}
|
|
case "?":
|
|
m.page = helpPage
|
|
case "r":
|
|
if m.operation.active || m.operation.quitRequested {
|
|
m.operation.status = 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 " ":
|
|
selected, ok := catalog.selected(m.selectedTargetID)
|
|
availability := targetMutationAvailability(selected, ok, m.operation.active || m.operation.quitRequested)
|
|
if !availability.available {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: availability.reason}
|
|
break
|
|
}
|
|
if selected.PrimaryAction.Kind == app.ResolveDriftTargetAction {
|
|
m.driftResolutionTargetID = selected.ID
|
|
break
|
|
}
|
|
intent, ok := targetIntent(selected.PrimaryAction.Kind, selected.ID)
|
|
if !ok {
|
|
m.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "This Target action is unavailable"}
|
|
break
|
|
}
|
|
return m, m.startOperation(intent)
|
|
}
|
|
|
|
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
|
|
}
|
|
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.operation.status = app.OperationStatus{Kind: app.StatusBlocked, Summary: "Wait for the current mutation to finish before changing the Active Profile"}
|
|
return m, nil
|
|
}
|
|
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 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,
|
|
driftResolutionTargetID: m.driftResolutionTargetID,
|
|
form: m.form,
|
|
confirm: m.confirm,
|
|
confirmation: m.operation.confirmation,
|
|
status: m.status,
|
|
operationStatus: m.operation.status,
|
|
currentError: m.operation.currentError,
|
|
errorExpanded: m.operation.errorExpanded,
|
|
errorOffset: m.operation.errorOffset,
|
|
exitDeferred: m.operation.quitRequested && m.operationInFlight(),
|
|
operationActive: m.operation.active,
|
|
operationProgress: m.operation.progress,
|
|
})
|
|
}
|
|
|
|
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}
|
|
}
|
|
}
|