709 lines
20 KiB
Go
709 lines
20 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbletea"
|
|
"github.com/mirivlad/sshkeeper/internal/model"
|
|
)
|
|
|
|
// --- Forward type items ---
|
|
|
|
type forwardTypeItem struct {
|
|
value model.ForwardType
|
|
label string
|
|
description string
|
|
}
|
|
|
|
// --- Forward list screen model ---
|
|
|
|
type forwardScreenModel struct {
|
|
serverID int64
|
|
serverAlias string
|
|
list []*model.Forward
|
|
width int
|
|
height int
|
|
err error
|
|
selected int
|
|
}
|
|
|
|
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel {
|
|
return &forwardScreenModel{
|
|
serverID: serverID,
|
|
serverAlias: serverAlias,
|
|
width: w,
|
|
height: h,
|
|
}
|
|
}
|
|
|
|
func (m *forwardScreenModel) loadForwards() tea.Cmd {
|
|
return func() tea.Msg {
|
|
if ListForwards == nil {
|
|
return forwardsLoadedMsg{err: fmt.Errorf("forward storage is unavailable")}
|
|
}
|
|
forwards, err := ListForwards(m.serverID)
|
|
return forwardsLoadedMsg{forwards: forwards, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
|
|
if m.selected < 0 || m.selected >= len(m.list) {
|
|
return nil
|
|
}
|
|
f := m.list[m.selected]
|
|
return func() tea.Msg {
|
|
return forwardDeleteConfirmMsg{id: f.ID, name: f.Name}
|
|
}
|
|
}
|
|
|
|
func (m *forwardScreenModel) confirmDelete() tea.Cmd {
|
|
if m.selected < 0 || m.selected >= len(m.list) {
|
|
return nil
|
|
}
|
|
f := m.list[m.selected]
|
|
return func() tea.Msg {
|
|
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
|
|
}
|
|
}
|
|
|
|
func (m *forwardScreenModel) editSelected() tea.Cmd {
|
|
if m.selected < 0 || m.selected >= len(m.list) {
|
|
return nil
|
|
}
|
|
return func() tea.Msg {
|
|
return forwardEditSignal{}
|
|
}
|
|
}
|
|
|
|
func (m *forwardScreenModel) View() string {
|
|
notification := ""
|
|
if m.err != nil {
|
|
notification = errorStyle.Render(fmt.Sprintf("Error: %v", m.err))
|
|
}
|
|
body := func(width, height int) string {
|
|
switch classifyShellContent(width) {
|
|
case sizeWide:
|
|
leftWidth := width * 70 / 100
|
|
rightWidth := width - leftWidth - 1
|
|
return joinPanelColumns(
|
|
renderPaddedPanel(leftWidth, height, m.forwardListLines(leftWidth-4, height-2, false)), leftWidth,
|
|
renderPaddedPanel(rightWidth, height, m.forwardDetailLines(rightWidth-4, false)), rightWidth,
|
|
)
|
|
case sizeMedium:
|
|
detailHeight := min(7, max(4, height/3))
|
|
listHeight := max(3, height-detailHeight-1)
|
|
listPanel := renderPaddedPanel(width, listHeight, m.forwardListLines(width-4, listHeight-2, false))
|
|
detailPanel := renderPaddedPanel(width, detailHeight, m.forwardDetailLines(width-4, true))
|
|
return listPanel + "\n" + detailPanel
|
|
default:
|
|
return renderPaddedPanel(width, height, m.forwardListLines(width-4, height-2, true))
|
|
}
|
|
}
|
|
return renderScreenShell(screenShell{
|
|
breadcrumb: "Port Forwards / " + m.serverAlias,
|
|
status: fmt.Sprintf("%d rules", len(m.list)),
|
|
notification: notification,
|
|
width: m.width,
|
|
height: m.height,
|
|
body: body,
|
|
footer: []helpItem{
|
|
{Key: "Ctrl+A (a)", Action: "add"},
|
|
{Key: "Ctrl+E/Enter", Action: "edit"},
|
|
{Key: "Ctrl+D (d)", Action: "delete"},
|
|
{Key: "Ctrl+H", Action: "help"},
|
|
{Key: "Esc", Action: "back"},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (m *forwardScreenModel) forwardListLines(width, capacity int, compact bool) []string {
|
|
if len(m.list) == 0 {
|
|
return []string{helpStyle.Copy().MarginLeft(0).Render("No port forwards configured. Ctrl+A adds one.")}
|
|
}
|
|
lines := []string{m.renderForwardRow(nil, false, width, compact)}
|
|
rowCapacity := max(1, capacity-1)
|
|
showRange := len(m.list) > rowCapacity
|
|
if showRange {
|
|
rowCapacity = max(1, rowCapacity-1)
|
|
}
|
|
start, end := visibleServerRange(len(m.list), m.selected, rowCapacity)
|
|
for index := start; index < end; index++ {
|
|
lines = append(lines, m.renderForwardRow(m.list[index], index == m.selected, width, compact))
|
|
}
|
|
if showRange {
|
|
lines = append(lines, dashboardHelp(fmt.Sprintf("Showing %d-%d of %d", start+1, end, len(m.list))))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (m *forwardScreenModel) forwardDetailLines(width int, compact bool) []string {
|
|
lines := []string{dashboardSection("Selected rule")}
|
|
if m.selected < 0 || m.selected >= len(m.list) {
|
|
return append(lines, "", dashboardHelp("No rule selected."))
|
|
}
|
|
forward := m.list[m.selected]
|
|
name := forward.Name
|
|
if name == "" {
|
|
name = forward.ForwardListen()
|
|
}
|
|
if compact {
|
|
lines = append(lines, name+" · "+string(forward.Type))
|
|
} else {
|
|
lines = append(lines, "", name, string(forward.Type))
|
|
}
|
|
lines = append(lines, wrapCells(forward.ForwardHumanExplanation(m.serverAlias), max(1, width))...)
|
|
if !compact {
|
|
lines = append(lines, "")
|
|
}
|
|
lines = append(lines, dashboardHelp("ssh "+strings.Join(forward.ForwardSSHArgs(), " ")))
|
|
return lines
|
|
}
|
|
|
|
func (m *forwardScreenModel) renderForwardRow(forward *model.Forward, selected bool, width int, compact bool) string {
|
|
marker, name, kind, listen, target, enabled := " ", "NAME", "TYPE", "LISTEN", "TARGET", "ON"
|
|
if forward != nil {
|
|
if selected {
|
|
marker = ">"
|
|
}
|
|
name = forward.Name
|
|
if name == "" {
|
|
name = forward.ForwardListen()
|
|
}
|
|
kind = string(forward.Type)
|
|
listen = forward.ForwardListen()
|
|
target = forward.ForwardTarget()
|
|
enabled = "yes"
|
|
if !forward.Enabled {
|
|
enabled = "no"
|
|
}
|
|
}
|
|
typeWidth, enabledWidth := 8, 3
|
|
if !compact && width >= 58 {
|
|
flexible := max(3, width-typeWidth-enabledWidth-7)
|
|
nameWidth := max(1, flexible*30/100)
|
|
listenWidth := max(1, flexible*32/100)
|
|
targetWidth := max(1, flexible-nameWidth-listenWidth)
|
|
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(listen, listenWidth) + " " + padCells(target, targetWidth) + " " + padCells(enabled, enabledWidth)
|
|
if forward == nil {
|
|
return listHeaderStyle.Render(fitLine(line, width))
|
|
}
|
|
if selected {
|
|
return selectedRowStyle.Render(fitLine(line, width))
|
|
}
|
|
return fitLine(line, width)
|
|
}
|
|
nameWidth := max(1, width-typeWidth-enabledWidth-5)
|
|
line := padCells(marker, 2) + " " + padCells(name, nameWidth) + " " + padCells(kind, typeWidth) + " " + padCells(enabled, enabledWidth)
|
|
if forward == nil {
|
|
return listHeaderStyle.Render(fitLine(line, width))
|
|
}
|
|
if selected {
|
|
return selectedRowStyle.Render(fitLine(line, width))
|
|
}
|
|
return fitLine(line, width)
|
|
}
|
|
|
|
// --- Forward form screen model ---
|
|
|
|
type forwardFormModel struct {
|
|
serverID int64
|
|
editMode bool
|
|
editID int64
|
|
inputs []textinput.Model
|
|
labels []string
|
|
focusIdx int
|
|
err error
|
|
saved bool
|
|
currentType model.ForwardType
|
|
nameInput textinput.Model
|
|
descInput textinput.Model
|
|
typeIdx int // 0=local, 1=remote, 2=socks
|
|
width int
|
|
height int
|
|
initial forwardFormSnapshot
|
|
}
|
|
|
|
type forwardFormSnapshot struct {
|
|
name string
|
|
description string
|
|
values []string
|
|
forwardType model.ForwardType
|
|
}
|
|
|
|
var forwardTypes = []forwardTypeItem{
|
|
{value: model.ForwardLocal, label: "Local", description: "port on my machine → service on SSH server"},
|
|
{value: model.ForwardRemote, label: "Remote", description: "port on SSH server → service on my machine"},
|
|
{value: model.ForwardDynamic, label: "SOCKS", description: "local dynamic SOCKS proxy through SSH"},
|
|
}
|
|
|
|
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
|
nameInput := textinput.New()
|
|
nameInput.Placeholder = "Local PostgreSQL"
|
|
nameInput.CharLimit = 128
|
|
|
|
descInput := textinput.New()
|
|
descInput.Placeholder = "optional"
|
|
descInput.CharLimit = 256
|
|
|
|
inputs := make([]textinput.Model, 4)
|
|
placeholders := []string{"127.0.0.1", "15432", "127.0.0.1", "5432"}
|
|
for i := range inputs {
|
|
inputs[i] = textinput.New()
|
|
inputs[i].Placeholder = placeholders[i]
|
|
inputs[i].CharLimit = 128
|
|
}
|
|
|
|
fm := &forwardFormModel{
|
|
serverID: serverID,
|
|
inputs: inputs,
|
|
focusIdx: 0,
|
|
currentType: model.ForwardLocal,
|
|
typeIdx: 0,
|
|
nameInput: nameInput,
|
|
descInput: descInput,
|
|
width: w,
|
|
height: h,
|
|
}
|
|
fm.updateFocus()
|
|
fm.initial = fm.snapshot()
|
|
return fm
|
|
}
|
|
|
|
func newForwardEditModel(serverID int64, fwd *model.Forward, w, h int) *forwardFormModel {
|
|
fm := newForwardFormModel(serverID, w, h)
|
|
fm.editMode = true
|
|
fm.editID = fwd.ID
|
|
fm.nameInput.SetValue(fwd.Name)
|
|
fm.descInput.SetValue(fwd.Description)
|
|
fm.currentType = fwd.Type
|
|
fm.typeIdx = typeIndex(fwd.Type)
|
|
if fwd.Type == model.ForwardRemote {
|
|
fm.inputs[0].SetValue(fwd.RemoteAddr)
|
|
fm.inputs[1].SetValue(strconv.Itoa(fwd.RemotePort))
|
|
fm.inputs[2].SetValue(fwd.LocalAddr)
|
|
fm.inputs[3].SetValue(strconv.Itoa(fwd.LocalPort))
|
|
} else {
|
|
fm.inputs[0].SetValue(fwd.LocalAddr)
|
|
fm.inputs[1].SetValue(strconv.Itoa(fwd.LocalPort))
|
|
fm.inputs[2].SetValue(fwd.RemoteAddr)
|
|
fm.inputs[3].SetValue(strconv.Itoa(fwd.RemotePort))
|
|
}
|
|
fm.updateFocus()
|
|
fm.initial = fm.snapshot()
|
|
return fm
|
|
}
|
|
|
|
func (fm *forwardFormModel) snapshot() forwardFormSnapshot {
|
|
values := make([]string, len(fm.inputs))
|
|
for i := range fm.inputs {
|
|
values[i] = fm.inputs[i].Value()
|
|
}
|
|
return forwardFormSnapshot{
|
|
name: fm.nameInput.Value(),
|
|
description: fm.descInput.Value(),
|
|
values: values,
|
|
forwardType: fm.currentType,
|
|
}
|
|
}
|
|
|
|
func (fm *forwardFormModel) Dirty() bool {
|
|
current := fm.snapshot()
|
|
if current.name != fm.initial.name || current.description != fm.initial.description || current.forwardType != fm.initial.forwardType || len(current.values) != len(fm.initial.values) {
|
|
return true
|
|
}
|
|
for i := range current.values {
|
|
if current.values[i] != fm.initial.values[i] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func typeIndex(t model.ForwardType) int {
|
|
switch t {
|
|
case model.ForwardLocal:
|
|
return 0
|
|
case model.ForwardRemote:
|
|
return 1
|
|
case model.ForwardDynamic:
|
|
return 2
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (fm *forwardFormModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (fm *forwardFormModel) visibleFields() []int {
|
|
switch fm.currentType {
|
|
case model.ForwardLocal:
|
|
return []int{0, 1, 2, 3}
|
|
case model.ForwardRemote:
|
|
return []int{0, 1, 2, 3}
|
|
case model.ForwardDynamic:
|
|
return []int{0, 1}
|
|
default:
|
|
return []int{0, 1, 2, 3}
|
|
}
|
|
}
|
|
|
|
func (fm *forwardFormModel) labelForField(idx int) string {
|
|
var labels []string
|
|
switch fm.currentType {
|
|
case model.ForwardLocal:
|
|
labels = []string{"Listen Address", "Listen Port", "Target Host", "Target Port"}
|
|
case model.ForwardRemote:
|
|
labels = []string{"Remote Listen Addr", "Remote Listen Port", "Local Target Host", "Local Target Port"}
|
|
case model.ForwardDynamic:
|
|
labels = []string{"Listen Address", "Listen Port"}
|
|
default:
|
|
return ""
|
|
}
|
|
if idx < 0 || idx >= len(labels) {
|
|
return ""
|
|
}
|
|
return labels[idx] + " *"
|
|
}
|
|
|
|
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case saveDoneMsg:
|
|
fm.saved = (msg.err == nil)
|
|
fm.applySaveError(msg.err)
|
|
return fm, nil
|
|
}
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.Type {
|
|
case tea.KeyTab:
|
|
fm.focusIdx++
|
|
total := 2 + 3 + len(fm.visibleFields()) + 1 // name + desc + type(3) + fields + save
|
|
if fm.focusIdx >= total {
|
|
fm.focusIdx = 0
|
|
}
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case tea.KeyShiftTab:
|
|
fm.focusIdx--
|
|
if fm.focusIdx < 0 {
|
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
|
fm.focusIdx = total - 1
|
|
}
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case tea.KeyEnter:
|
|
// Check if on type selector
|
|
if fm.focusIdx >= 2 && fm.focusIdx < 2+3 {
|
|
fm.typeIdx = fm.focusIdx - 2
|
|
fm.currentType = forwardTypes[fm.typeIdx].value
|
|
fm.focusIdx++
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
}
|
|
if fm.focusIdx == 2+3+len(fm.visibleFields()) {
|
|
return fm, fm.runSave()
|
|
}
|
|
fm.focusIdx++
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case tea.KeyEsc:
|
|
return fm, nil
|
|
case tea.KeyDown:
|
|
fm.focusIdx++
|
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
|
if fm.focusIdx >= total {
|
|
fm.focusIdx = 0
|
|
}
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case tea.KeyUp:
|
|
fm.focusIdx--
|
|
if fm.focusIdx < 0 {
|
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
|
fm.focusIdx = total - 1
|
|
}
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case tea.KeyRunes:
|
|
// Direct number keys select a type only while the type selector has focus.
|
|
if fm.focusIdx >= 2 && fm.focusIdx < 2+len(forwardTypes) && len(msg.Runes) == 1 {
|
|
switch msg.Runes[0] {
|
|
case '1':
|
|
fm.typeIdx = 0
|
|
fm.currentType = model.ForwardLocal
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case '2':
|
|
fm.typeIdx = 1
|
|
fm.currentType = model.ForwardRemote
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
case '3':
|
|
fm.typeIdx = 2
|
|
fm.currentType = model.ForwardDynamic
|
|
fm.updateFocus()
|
|
return fm, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Route to focused input
|
|
if fm.focusIdx == 0 {
|
|
var cmd tea.Cmd
|
|
fm.nameInput, cmd = fm.nameInput.Update(msg)
|
|
return fm, cmd
|
|
}
|
|
if fm.focusIdx == 1 {
|
|
var cmd tea.Cmd
|
|
fm.descInput, cmd = fm.descInput.Update(msg)
|
|
return fm, cmd
|
|
}
|
|
visible := fm.visibleFields()
|
|
if fm.focusIdx >= 2+3 && fm.focusIdx < 2+3+len(visible) {
|
|
fieldIdx := visible[fm.focusIdx-(2+3)]
|
|
var cmd tea.Cmd
|
|
fm.inputs[fieldIdx], cmd = fm.inputs[fieldIdx].Update(msg)
|
|
return fm, cmd
|
|
}
|
|
|
|
return fm, nil
|
|
}
|
|
|
|
func (fm *forwardFormModel) updateFocus() {
|
|
fm.nameInput.Blur()
|
|
fm.nameInput.Prompt = blurredStyle.Render("Name *: ")
|
|
fm.descInput.Blur()
|
|
fm.descInput.Prompt = blurredStyle.Render("Description: ")
|
|
for i := range fm.inputs {
|
|
fm.inputs[i].Blur()
|
|
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelForField(i) + ": ")
|
|
}
|
|
|
|
total := 2 + 3 + len(fm.visibleFields()) + 1
|
|
switch {
|
|
case fm.focusIdx == 0:
|
|
fm.nameInput.Focus()
|
|
fm.nameInput.Prompt = focusedStyle.Render("Name *> ")
|
|
case fm.focusIdx == 1:
|
|
fm.descInput.Focus()
|
|
fm.descInput.Prompt = focusedStyle.Render("Description> ")
|
|
case fm.focusIdx >= 2 && fm.focusIdx < 2+3:
|
|
// Type selector focused — no input to focus
|
|
case fm.focusIdx >= 2+3 && fm.focusIdx < total-1:
|
|
visible := fm.visibleFields()
|
|
fieldIdx := visible[fm.focusIdx-(2+3)]
|
|
fm.inputs[fieldIdx].Focus()
|
|
fm.inputs[fieldIdx].Prompt = focusedStyle.Render(fm.labelForField(fieldIdx) + "> ")
|
|
}
|
|
}
|
|
|
|
func (fm *forwardFormModel) runSave() tea.Cmd {
|
|
return func() tea.Msg {
|
|
name := strings.TrimSpace(fm.nameInput.Value())
|
|
desc := strings.TrimSpace(fm.descInput.Value())
|
|
localAddr, remoteAddr := "", ""
|
|
localPort, remotePort := 0, 0
|
|
var err error
|
|
|
|
if name == "" {
|
|
return saveDoneMsg{err: fmt.Errorf("name is required")}
|
|
}
|
|
switch fm.currentType {
|
|
case model.ForwardLocal:
|
|
localAddr = strings.TrimSpace(fm.inputs[0].Value())
|
|
if localAddr == "" {
|
|
localAddr = "127.0.0.1"
|
|
}
|
|
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
|
|
if err != nil {
|
|
return saveDoneMsg{err: err}
|
|
}
|
|
remoteAddr = strings.TrimSpace(fm.inputs[2].Value())
|
|
if remoteAddr == "" {
|
|
return saveDoneMsg{err: fmt.Errorf("target host is required for local forward")}
|
|
}
|
|
remotePort, err = parseNamedPort("Target port", fm.inputs[3].Value())
|
|
if err != nil {
|
|
return saveDoneMsg{err: err}
|
|
}
|
|
case model.ForwardRemote:
|
|
remoteAddr = strings.TrimSpace(fm.inputs[0].Value())
|
|
if remoteAddr == "" {
|
|
return saveDoneMsg{err: fmt.Errorf("remote listen address is required")}
|
|
}
|
|
remotePort, err = parseNamedPort("Remote listen port", fm.inputs[1].Value())
|
|
if err != nil {
|
|
return saveDoneMsg{err: err}
|
|
}
|
|
localAddr = strings.TrimSpace(fm.inputs[2].Value())
|
|
if localAddr == "" {
|
|
localAddr = "127.0.0.1"
|
|
}
|
|
localPort, err = parseNamedPort("Local target port", fm.inputs[3].Value())
|
|
if err != nil {
|
|
return saveDoneMsg{err: err}
|
|
}
|
|
case model.ForwardDynamic:
|
|
localAddr = strings.TrimSpace(fm.inputs[0].Value())
|
|
if localAddr == "" {
|
|
localAddr = "127.0.0.1"
|
|
}
|
|
localPort, err = parseNamedPort("Listen port", fm.inputs[1].Value())
|
|
if err != nil {
|
|
return saveDoneMsg{err: err}
|
|
}
|
|
remoteAddr = ""
|
|
remotePort = 0
|
|
}
|
|
|
|
fwd := &model.Forward{
|
|
ServerID: fm.serverID,
|
|
Name: name,
|
|
Description: desc,
|
|
Type: fm.currentType,
|
|
LocalAddr: localAddr,
|
|
LocalPort: localPort,
|
|
RemoteAddr: remoteAddr,
|
|
RemotePort: remotePort,
|
|
Enabled: true,
|
|
}
|
|
|
|
if fm.editMode {
|
|
fwd.ID = fm.editID
|
|
if UpdateForward == nil {
|
|
return saveDoneMsg{err: fmt.Errorf("update not available")}
|
|
}
|
|
return saveDoneMsg{err: UpdateForward(fwd)}
|
|
}
|
|
|
|
if SaveForward == nil {
|
|
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
|
|
}
|
|
err = SaveForward(fwd)
|
|
return saveDoneMsg{err: err}
|
|
}
|
|
}
|
|
|
|
func (fm *forwardFormModel) applySaveError(err error) {
|
|
fm.err = err
|
|
if err == nil {
|
|
return
|
|
}
|
|
message := strings.ToLower(err.Error())
|
|
fieldIndex := -1
|
|
switch {
|
|
case strings.Contains(message, "name is required"):
|
|
fm.focusIdx = 0
|
|
fm.updateFocus()
|
|
return
|
|
case strings.Contains(message, "listen address"):
|
|
fieldIndex = 0
|
|
case strings.Contains(message, "listen port"):
|
|
fieldIndex = 1
|
|
case strings.Contains(message, "target host"):
|
|
fieldIndex = 2
|
|
case strings.Contains(message, "target port"):
|
|
fieldIndex = 3
|
|
}
|
|
if fieldIndex >= 0 {
|
|
fm.focusIdx = 2 + len(forwardTypes) + fieldIndex
|
|
fm.updateFocus()
|
|
}
|
|
}
|
|
|
|
func parseNamedPort(label, value string) (int, error) {
|
|
port, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a number from 1 to 65535", label)
|
|
}
|
|
if port < 1 || port > 65535 {
|
|
return 0, fmt.Errorf("%s must be between 1 and 65535", label)
|
|
}
|
|
return port, nil
|
|
}
|
|
|
|
func (fm *forwardFormModel) View() string {
|
|
title := "Add Port Forward"
|
|
if fm.editMode {
|
|
title = "Edit Port Forward"
|
|
}
|
|
notification := ""
|
|
if fm.err != nil {
|
|
notification = errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err))
|
|
} else if fm.saved {
|
|
notification = successStyle.Render("✓ Saved.")
|
|
}
|
|
body := func(width, height int) string {
|
|
contentWidth := max(1, width-4)
|
|
lines := []string{fm.nameInput.View(), fm.descInput.View()}
|
|
typeParts := make([]string, len(forwardTypes))
|
|
for i, forwardType := range forwardTypes {
|
|
selected := "○"
|
|
if i == fm.typeIdx {
|
|
selected = "●"
|
|
}
|
|
focus := " "
|
|
if fm.focusIdx == 2+i {
|
|
focus = ">"
|
|
}
|
|
typeParts[i] = fmt.Sprintf("%s%s %d %s", focus, selected, i+1, forwardType.label)
|
|
}
|
|
lines = append(lines, "Type "+strings.Join(typeParts, " "))
|
|
if width >= 100 {
|
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render(forwardTypes[fm.typeIdx].description))
|
|
}
|
|
visible := fm.visibleFields()
|
|
for _, idx := range visible {
|
|
lines = append(lines, fm.inputs[idx].View())
|
|
}
|
|
if strings.TrimSpace(fm.inputs[0].Value()) == "0.0.0.0" {
|
|
lines = append(lines, helpStyle.Copy().MarginLeft(0).Render("⚠ This port will be accessible from the network."))
|
|
}
|
|
if width >= 70 && fm.currentType != "" && fm.inputs[1].Value() != "" {
|
|
fwd := &model.Forward{Type: fm.currentType, LocalAddr: fm.inputs[0].Value(), RemoteAddr: fm.inputs[2].Value()}
|
|
fmt.Sscanf(fm.inputs[1].Value(), "%d", &fwd.LocalPort)
|
|
fmt.Sscanf(fm.inputs[3].Value(), "%d", &fwd.RemotePort)
|
|
preview := "Preview ssh " + strings.Join(fwd.ForwardSSHArgs(), " ") + " -o ExitOnForwardFailure=yes"
|
|
lines = append(lines, wrapCells(preview, contentWidth)...)
|
|
}
|
|
total := 2 + 3 + len(visible) + 1
|
|
button := " [ Save ]"
|
|
if fm.focusIdx == total-1 {
|
|
button = selectedStyle.Render("> [ Save ]")
|
|
}
|
|
lines = append(lines, "", button)
|
|
return renderPaddedPanel(width, height, lines)
|
|
}
|
|
return renderScreenShell(screenShell{
|
|
breadcrumb: "Port Forwards / " + title,
|
|
status: string(fm.currentType),
|
|
notification: notification,
|
|
width: fm.width,
|
|
height: fm.height,
|
|
body: body,
|
|
footer: []helpItem{
|
|
{Key: "Tab/↓", Action: "next"},
|
|
{Key: "↑", Action: "prev"},
|
|
{Key: "1/2/3", Action: "select type"},
|
|
{Key: "Enter", Action: "save"},
|
|
{Key: "Ctrl+H", Action: "help"},
|
|
{Key: "Esc", Action: "back"},
|
|
},
|
|
})
|
|
}
|
|
|
|
// forwardEditSignal is sent when user wants to edit a forward
|
|
type forwardEditSignal struct{}
|
|
|
|
// forwardDeleteConfirmMsg asks for confirmation before deleting
|
|
type forwardDeleteConfirmMsg struct {
|
|
id int64
|
|
name string
|
|
}
|