278 lines
10 KiB
Go
278 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"proxyctl/internal/app"
|
|
"proxyctl/internal/profile"
|
|
)
|
|
|
|
func (p terminalPresentation) mainView(model presentationState, width, height int) terminalPage {
|
|
dashboard, operation := model.dashboard, model.operation
|
|
activeProfile := model.snapshot.ActiveProfile
|
|
if activeProfile == "" {
|
|
activeProfile = "No Active Profile"
|
|
}
|
|
header := fmt.Sprintf("%s Profile: %s", p.theme.title.Render("proxyctl"), activeProfile)
|
|
|
|
catalog := newTargetCatalog(model.snapshot, dashboard.showUnavailable, dashboard.selectedTargetID)
|
|
footer := p.dashboardFooter(model, catalog)
|
|
if operation.currentError != nil {
|
|
footer += "\ne: expand/collapse current error x: acknowledge error"
|
|
if operation.errorExpanded {
|
|
footer += " PgUp/PgDn: scroll error details"
|
|
}
|
|
}
|
|
page := terminalPage{
|
|
header: header,
|
|
footer: footer,
|
|
dashboard: &dashboardRenderState{
|
|
catalog: catalog,
|
|
selectedTargetID: dashboard.selectedTargetID,
|
|
focus: dashboard.focus,
|
|
detailsOffset: dashboard.detailsOffset,
|
|
operationProgress: operation.progress,
|
|
applyingFrame: operation.spinnerFrame,
|
|
terminalWidth: width,
|
|
terminalHeight: height,
|
|
},
|
|
}
|
|
if dashboard.picker.open {
|
|
page.dashboard = nil
|
|
page.primary = dashboard.picker.view(model.snapshot, width, max(5, height-9))
|
|
page.footer = "Type to search ↑/↓ PgUp/PgDn: select Enter: activate\nEsc: cancel ?: help"
|
|
if width < 70 {
|
|
page.footer = "Type: search ↑/↓: select Enter: activate\nEsc: cancel ?: help"
|
|
}
|
|
}
|
|
if dashboard.driftResolutionTargetID != "" && operation.confirmation == nil {
|
|
if selected, ok := newTargetCatalog(model.snapshot, true, dashboard.driftResolutionTargetID).selected(dashboard.driftResolutionTargetID); ok {
|
|
page.dashboard = nil
|
|
page.primary, page.context = p.driftResolutionView(selected)
|
|
page.wrapPrimary = true
|
|
page.wrapContext = true
|
|
page.footer = "r: Reapply Active Profile o: Restore Original\nEnter/q/Esc: cancel ?: help"
|
|
}
|
|
}
|
|
if operation.confirmation != nil {
|
|
dialog := newConfirmationDialog(*operation.confirmation)
|
|
if operation.confirmationDialog != nil {
|
|
dialog = *operation.confirmationDialog
|
|
}
|
|
page.dashboard = nil
|
|
page.primary = dialog.view()
|
|
page.primaryOffset = dialog.offset
|
|
page.focusLine = -1
|
|
page.wrapPrimary = true
|
|
page.context = "Cancellation is selected by default."
|
|
page.footer = "←/→/Tab: choose PgUp/PgDn: scroll y: confirm Enter: choose\nn/q/Esc: cancel ?: help"
|
|
}
|
|
return page
|
|
}
|
|
|
|
func (p terminalPresentation) driftResolutionView(selected app.TargetSnapshot) (string, string) {
|
|
var choices strings.Builder
|
|
fmt.Fprintf(&choices, "%s\nChoose how to resolve %s without an automatic recovery direction.", p.theme.section.Render("Drift Resolution — "+selected.Description.Name), selected.Description.Name)
|
|
for _, kind := range []app.TargetActionKind{app.ReapplyActiveProfileAction, app.RestoreOriginalAction} {
|
|
action, ok := driftResolutionAction(selected, kind)
|
|
label := targetActionLabel(kind)
|
|
if !ok || !action.Available {
|
|
reason := action.DisabledReason
|
|
if reason == "" {
|
|
reason = "unavailable"
|
|
}
|
|
fmt.Fprintf(&choices, "\n\n%s — unavailable: %s", label, reason)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&choices, "\n\n%s", label)
|
|
}
|
|
context := "Reapply Active Profile writes the Active Profile again. Restore Original asks the Target Adapter to use its existing ownership and restoration safeguards; insufficient evidence fails safely without overwriting external changes."
|
|
return choices.String(), context
|
|
}
|
|
|
|
func (p terminalPresentation) dashboardFooter(model presentationState, catalog targetCatalog) string {
|
|
dashboard, operation := model.dashboard, model.operation
|
|
visibility := "u: Show hidden"
|
|
if dashboard.showUnavailable {
|
|
visibility = "u: Hide unavailable"
|
|
}
|
|
if operation.exitDeferred {
|
|
return "Operation stopping; changes disabled ?: Help"
|
|
}
|
|
if dashboard.focus == targetDetailsPane {
|
|
if operation.active {
|
|
return "↑/↓: Scroll Tab: Targets ?: Help q: Stop"
|
|
}
|
|
return "↑/↓: Scroll Tab: Targets " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit"
|
|
}
|
|
selected, ok := catalog.selected(dashboard.selectedTargetID)
|
|
availability := targetMutationAvailability(selected, ok, operation.active)
|
|
action := "Space: " + targetActionLabel(selected.PrimaryAction.Kind)
|
|
if !availability.available {
|
|
action += " unavailable"
|
|
}
|
|
if operation.active {
|
|
return action + " ↑/↓: Select Tab: Details\n?: Help q: Stop"
|
|
}
|
|
return action + " ↑/↓: Select Tab: Details " + visibility + "\np: Profile m: Profiles r: Refresh ?: Help q: Quit"
|
|
}
|
|
|
|
func (p terminalPresentation) profilesView(model presentationState) terminalPage {
|
|
state := model.profile
|
|
var list strings.Builder
|
|
if len(model.snapshot.Profiles) == 0 {
|
|
list.WriteString("No Proxy Profiles saved. Press n to create one.")
|
|
}
|
|
for i, item := range model.snapshot.Profiles {
|
|
cursor, active := " ", " "
|
|
if i == state.selected {
|
|
cursor = "> "
|
|
}
|
|
if item.Name == model.snapshot.ActiveProfile {
|
|
active = "*"
|
|
}
|
|
if list.Len() > 0 {
|
|
list.WriteByte('\n')
|
|
}
|
|
fmt.Fprintf(&list, "%s%s %s", cursor, active, item.Name)
|
|
}
|
|
|
|
context := selectedProfileDetails(p, model)
|
|
footer := "n: create e: edit Enter: edit d: delete PgUp/PgDn: scroll details\n↑/k ↓/j: select ?: help q/Esc: back"
|
|
if model.terminal.width < 80 {
|
|
footer = "↑/k ↓/j: select PgUp/PgDn: details n: create\ne/Enter: edit d: delete ?: help q/Esc: back"
|
|
}
|
|
if len(model.snapshot.Profiles) == 0 {
|
|
footer = "n: create a Proxy Profile ?: help q/Esc: back"
|
|
}
|
|
if state.deletion != nil {
|
|
return terminalPage{
|
|
header: p.theme.title.Render("Proxy Profile management"),
|
|
primary: state.deletion.dialog.view(),
|
|
primaryOffset: state.deletion.dialog.offset,
|
|
wrapPrimary: true,
|
|
footer: "←/→/Tab: choose PgUp/PgDn: scroll Enter: choose y: delete\nn/q/Esc: cancel ?: help",
|
|
focusLine: -1,
|
|
}
|
|
}
|
|
return terminalPage{
|
|
header: p.theme.title.Render("Proxy Profile management"),
|
|
primary: list.String(),
|
|
context: context,
|
|
footer: footer,
|
|
focusLine: state.selected,
|
|
primaryOffset: state.listOffset,
|
|
contextOffset: state.detailsOffset,
|
|
wrapContext: true,
|
|
}
|
|
}
|
|
|
|
func sanitizeFormText(value string) string {
|
|
return sanitizeUntrustedControls(profile.RedactUserinfoText(value))
|
|
}
|
|
|
|
func selectedProfileDetails(p terminalPresentation, model presentationState) string {
|
|
selected, ok := profileAt(model.snapshot, model.profile.selected)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var details strings.Builder
|
|
details.WriteString(p.theme.section.Render("Selected Profile"))
|
|
fmt.Fprintf(&details, "\nMode: %s", selected.Mode)
|
|
if selected.Mode == profile.SingleMode {
|
|
fmt.Fprintf(&details, "\nProxy: %s", profile.ObscureURL(selected.SingleURL))
|
|
} else {
|
|
fmt.Fprintf(&details, "\nHTTP: %s\nHTTPS: %s\nALL/SOCKS: %s",
|
|
profile.ObscureURL(selected.HTTPURL), profile.ObscureURL(selected.HTTPSURL), profile.ObscureURL(selected.AllURL))
|
|
}
|
|
details.WriteString("\nBypass:")
|
|
if len(selected.Bypass) == 0 {
|
|
details.WriteString(" (none)")
|
|
} else {
|
|
for _, item := range selected.Bypass {
|
|
fmt.Fprintf(&details, "\n • %s", item)
|
|
}
|
|
}
|
|
// The list gives short names the strongest scan position. Repeating the
|
|
// complete value at the end of the scrollable details makes a clipped,
|
|
// wide Unicode name fully reachable without displacing connection details.
|
|
fmt.Fprintf(&details, "\nFull name: %s", selected.Name)
|
|
return details.String()
|
|
}
|
|
|
|
func (p terminalPresentation) formView(model presentationState) terminalPage {
|
|
form, width := model.form.state, model.terminal.width
|
|
action := "Create"
|
|
if form.originalName != "" {
|
|
action = "Edit"
|
|
}
|
|
var fields, bypassPreview strings.Builder
|
|
focusLine, currentLine := 0, 0
|
|
for i, item := range form.inputs {
|
|
if fields.Len() > 0 {
|
|
fields.WriteByte('\n')
|
|
currentLine++
|
|
}
|
|
cursor, focus := " ", ""
|
|
if i == form.field {
|
|
cursor, focus = "> ", " [focused]"
|
|
focusLine = currentLine
|
|
}
|
|
fmt.Fprintf(&fields, "%s%s%s: %s", cursor, item.label, focus, form.renderedInput(i, width))
|
|
if probeText := form.probeText(item.field); probeText != "" {
|
|
fmt.Fprintf(&fields, "\n %s", probeText)
|
|
currentLine++
|
|
}
|
|
if item.field == profile.NameField && form.hasProbeSourceAttachment() {
|
|
fmt.Fprintf(&fields, "\n Single proxy URL probe [read-only]: %s", profile.ObscureURLForEditing(form.probe.sourceValue))
|
|
fmt.Fprintf(&fields, "\n %s", form.probe.feedback)
|
|
fields.WriteString("\n Generated Advanced URL fields from this Single proxy URL probe:")
|
|
currentLine += 3
|
|
}
|
|
validation := form.validationText(i)
|
|
if item.field == profile.NameField {
|
|
if conflict := form.nameConflict(model.snapshot.Profiles); conflict != "" {
|
|
validation = conflict
|
|
}
|
|
}
|
|
validation = sanitizeFormText(validation)
|
|
if validation != "" {
|
|
fmt.Fprintf(&fields, "\n Error: %s", validation)
|
|
currentLine++
|
|
}
|
|
if item.field == profile.BypassField {
|
|
analysis := form.bypassAnalysis()
|
|
bypassPreview.WriteString("Normalized Bypass preview:")
|
|
if len(analysis) == 0 {
|
|
bypassPreview.WriteString(" (none)")
|
|
} else {
|
|
for _, line := range analysis {
|
|
bypassPreview.WriteString("\n " + sanitizeFormText(line))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
secondaryHints := "?: help q/Esc: cancel"
|
|
if form.currentField() == profile.BypassField {
|
|
secondaryHints = "PgUp/PgDn: scroll form preview " + secondaryHints
|
|
}
|
|
if form.profile.Mode == profile.SingleMode && form.currentField() == profile.SingleURLField {
|
|
probeHint := "Ctrl+D: probe"
|
|
if form.probe.active {
|
|
probeHint = "Protocol Probe in progress"
|
|
}
|
|
secondaryHints = probeHint + " " + secondaryHints
|
|
}
|
|
footer := "Tab/Shift+Tab/↑/↓: move Ctrl+T: mode Ctrl+S: save\n" + secondaryHints
|
|
return terminalPage{
|
|
header: p.theme.title.Render(fmt.Sprintf("%s Proxy Profile — %s mode", action, form.profile.Mode)),
|
|
primary: fields.String(),
|
|
context: bypassPreview.String(),
|
|
footer: footer,
|
|
focusLine: focusLine,
|
|
contextAnchor: form.bypassPreviewLine,
|
|
anchorContext: true,
|
|
}
|
|
}
|