754 lines
20 KiB
Go
754 lines
20 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
|
|
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
|
|
}
|
|
|
|
// 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 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()
|
|
var detectionUpdates <-chan app.TargetDetectionUpdate
|
|
if progressive, ok := application.(app.ProgressiveDetectionModule); ok {
|
|
snapshot, detectionUpdates = progressive.BeginTargetDetection()
|
|
}
|
|
return Model{
|
|
application: application,
|
|
presentation: terminalPresentation{theme: newSemanticTheme(options.theme, options.output)},
|
|
snapshot: snapshot,
|
|
selectedTargetID: newTargetCatalog(snapshot, false, "").firstID(),
|
|
width: 80,
|
|
height: 24,
|
|
refreshing: detectionUpdates != nil,
|
|
detectionUpdates: detectionUpdates,
|
|
detect: probe.Detect,
|
|
}
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
if m.detectionUpdates != nil {
|
|
return waitForTargetDetection(m.detectionUpdates)
|
|
}
|
|
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.applying || m.refreshing || m.profileTransition
|
|
}
|
|
|
|
func (m Model) dashboardNavigationAvailable() bool {
|
|
return !m.quitRequested && !m.applying && !m.profileTransition
|
|
}
|
|
|
|
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 && m.operationInFlight() && isQuitKey(key) {
|
|
m.quitRequested = true
|
|
return m, nil
|
|
}
|
|
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.
|
|
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 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.busy() {
|
|
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
|
|
}
|
|
catalog := newTargetCatalog(m.snapshot, m.showUnavailable, m.selectedTargetID)
|
|
switch key.String() {
|
|
case "p":
|
|
if !m.busy() {
|
|
m.page = profilesPage
|
|
m.selected = 0
|
|
m.status = ""
|
|
}
|
|
case "?":
|
|
if !m.busy() {
|
|
m.page = helpPage
|
|
m.status = ""
|
|
}
|
|
case "r":
|
|
if !m.busy() {
|
|
m.refreshing = true
|
|
m.status = "Refreshing Target availability and state…"
|
|
if progressive, ok := m.application.(app.ProgressiveDetectionModule); ok {
|
|
m.snapshot, m.detectionUpdates = progressive.BeginTargetDetection()
|
|
m.retainTargetSelection()
|
|
return m, waitForTargetDetection(m.detectionUpdates)
|
|
}
|
|
return m, func() tea.Msg {
|
|
return refreshDone{result: m.application.Dispatch(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.busy() {
|
|
selected, ok := catalog.selected(m.selectedTargetID)
|
|
if !ok || !selected.Available || selected.DetectionPhase != app.TargetReady {
|
|
break
|
|
}
|
|
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
|
|
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() {
|
|
return m, nil
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
status: m.status,
|
|
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}
|
|
}
|
|
}
|