sshkeeper: v0.2.0 — Phase 3: Port Forwarding Manager (DB, TUI screens, SSH args)
This commit is contained in:
parent
700724e93b
commit
912b17e1f1
|
|
@ -41,6 +41,7 @@ var connectCmd = &cobra.Command{
|
|||
AuthMethod: server.AuthMethod,
|
||||
IdentityFile: server.IdentityFile,
|
||||
ProxyJump: server.ProxyJump,
|
||||
Route: server.Route,
|
||||
}, vaultFunc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -82,6 +83,7 @@ var testCmd = &cobra.Command{
|
|||
AuthMethod: server.AuthMethod,
|
||||
IdentityFile: server.IdentityFile,
|
||||
ProxyJump: server.ProxyJump,
|
||||
Route: server.Route,
|
||||
}, vaultFunc)
|
||||
|
||||
if ok {
|
||||
|
|
|
|||
|
|
@ -124,6 +124,15 @@ func runTUI() error {
|
|||
}
|
||||
return ssh.RunCommandOutput(cfg, fresh, vaultFunc, command)
|
||||
}
|
||||
tui.ListForwards = func(serverID int64) ([]*model.Forward, error) {
|
||||
return appDB.GetForwards(serverID)
|
||||
}
|
||||
tui.SaveForward = func(fwd *model.Forward) error {
|
||||
return appDB.AddForward(fwd.ServerID, fwd.Type, fwd.LocalAddr, fwd.LocalPort, fwd.RemoteAddr, fwd.RemotePort)
|
||||
}
|
||||
tui.DeleteForward = func(forwardID int64) error {
|
||||
return appDB.DeleteForward(forwardID)
|
||||
}
|
||||
tui.UpdateTestResult = func(alias string, status model.TestStatus, testErr string) error {
|
||||
return appDB.UpdateTestResult(alias, status, testErr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,6 +349,11 @@ func (db *DB) GetForwards(serverID int64) ([]*model.Forward, error) {
|
|||
return forwards, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) DeleteForward(forwardID int64) error {
|
||||
_, err := db.conn.Exec("DELETE FROM forwards WHERE id=?", forwardID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure time import is used
|
||||
var _ time.Time
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import (
|
|||
type VaultFunc func(serverAlias string, secretType string) (string, error)
|
||||
|
||||
func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error {
|
||||
args := BuildSSHArgs(server)
|
||||
args := BuildSSHArgsSimple(server)
|
||||
if strings.TrimSpace(server.StartupCommand) != "" {
|
||||
args = append(args, server.StartupCommand)
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ func Connect(cfg *config.Config, server *model.Server, getVault VaultFunc) error
|
|||
}
|
||||
|
||||
func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) error {
|
||||
args := BuildSSHArgs(server)
|
||||
args := BuildSSHArgsSimple(server)
|
||||
args = append(args, command)
|
||||
|
||||
switch server.AuthMethod {
|
||||
|
|
@ -78,7 +78,7 @@ func RunCommand(cfg *config.Config, server *model.Server, getVault VaultFunc, co
|
|||
}
|
||||
|
||||
func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFunc, command string) (string, error) {
|
||||
args := BuildSSHArgs(server)
|
||||
args := BuildSSHArgsSimple(server)
|
||||
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
|
||||
|
||||
switch server.AuthMethod {
|
||||
|
|
@ -116,7 +116,7 @@ func RunCommandOutput(cfg *config.Config, server *model.Server, getVault VaultFu
|
|||
}
|
||||
|
||||
func Test(cfg *config.Config, server *model.Server, getVault VaultFunc) (bool, string) {
|
||||
args := BuildSSHArgs(server)
|
||||
args := BuildSSHArgsSimple(server)
|
||||
args = append(args, "-o", fmt.Sprintf("ConnectTimeout=%d", cfg.SSH.ConnectTimeoutSec))
|
||||
|
||||
switch server.AuthMethod {
|
||||
|
|
@ -178,8 +178,31 @@ func testWithPassword(cfg *config.Config, args []string, password string) (bool,
|
|||
return false, result
|
||||
}
|
||||
|
||||
// BuildForwardArgs builds SSH port forwarding arguments.
|
||||
func BuildForwardArgs(forwards []*model.Forward, exitOnForwardFailure bool) []string {
|
||||
var args []string
|
||||
for _, f := range forwards {
|
||||
switch f.Type {
|
||||
case model.ForwardLocal:
|
||||
listen := fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
|
||||
target := fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
|
||||
args = append(args, "-L", listen+":"+target)
|
||||
case model.ForwardRemote:
|
||||
listen := fmt.Sprintf("%s:%d", f.RemoteAddr, f.RemotePort)
|
||||
target := fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort)
|
||||
args = append(args, "-R", listen+":"+target)
|
||||
case model.ForwardDynamic:
|
||||
args = append(args, "-D", fmt.Sprintf("%s:%d", f.LocalAddr, f.LocalPort))
|
||||
}
|
||||
}
|
||||
if exitOnForwardFailure && len(forwards) > 0 {
|
||||
args = append(args, "-o", "ExitOnForwardFailure=yes")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// BuildSSHArgs builds the SSH command arguments for a server profile.
|
||||
func BuildSSHArgs(server *model.Server) []string {
|
||||
func BuildSSHArgs(server *model.Server, forwards []*model.Forward, forwardOnly bool) []string {
|
||||
var args []string
|
||||
|
||||
args = append(args, "-p", fmt.Sprintf("%d", server.Port))
|
||||
|
|
@ -196,10 +219,24 @@ func BuildSSHArgs(server *model.Server) []string {
|
|||
args = append(args, "-J", server.ProxyJump)
|
||||
}
|
||||
|
||||
// Port forwarding
|
||||
if len(forwards) > 0 {
|
||||
args = append(args, BuildForwardArgs(forwards, true)...)
|
||||
}
|
||||
|
||||
args = append(args, "-o", "StrictHostKeyChecking=accept-new")
|
||||
|
||||
if forwardOnly {
|
||||
args = append(args, "-N")
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s@%s", server.User, server.Host)
|
||||
args = append(args, target)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// BuildSSHArgsSimple builds SSH args without forwards (backward compatible).
|
||||
func BuildSSHArgsSimple(server *model.Server) []string {
|
||||
return BuildSSHArgs(server, nil, false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ func TestBuildSSHArgs_WithRoute(t *testing.T) {
|
|||
{Alias: "bastion", IsProfile: true},
|
||||
}},
|
||||
}
|
||||
args := BuildSSHArgs(server)
|
||||
args := BuildSSHArgsSimple(server)
|
||||
// Should contain -J bastion
|
||||
found := false
|
||||
for i, a := range args {
|
||||
|
|
@ -126,7 +126,7 @@ func TestBuildSSHArgs_FallbackToProxyJump(t *testing.T) {
|
|||
User: "root",
|
||||
ProxyJump: "old-bastion",
|
||||
}
|
||||
args := BuildSSHArgs(server)
|
||||
args := BuildSSHArgsSimple(server)
|
||||
found := false
|
||||
for i, a := range args {
|
||||
if a == "-J" && i+1 < len(args) && args[i+1] == "old-bastion" {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,16 @@ type templateRunRequestMsg struct {
|
|||
command string
|
||||
}
|
||||
|
||||
type forwardsLoadedMsg struct {
|
||||
forwards []*model.Forward
|
||||
err error
|
||||
}
|
||||
|
||||
type forwardDeletedMsg struct {
|
||||
id int64
|
||||
err error
|
||||
}
|
||||
|
||||
// --- List items ---
|
||||
|
||||
type serverItem struct {
|
||||
|
|
@ -144,6 +154,9 @@ var (
|
|||
SaveCommandTemplate func(oldName string, template *model.CommandTemplate) error
|
||||
DeleteCommandTemplate func(name string) error
|
||||
RunTemplateBackground func(server *model.Server, command string) (string, error)
|
||||
ListForwards func(serverID int64) ([]*model.Forward, error)
|
||||
SaveForward func(fwd *model.Forward) error
|
||||
DeleteForward func(forwardID int64) error
|
||||
)
|
||||
|
||||
// --- Screen type ---
|
||||
|
|
@ -163,6 +176,8 @@ const (
|
|||
screenBackgroundResults
|
||||
screenHelp
|
||||
screenActionMenu
|
||||
screenForwardList
|
||||
screenForwardForm
|
||||
)
|
||||
|
||||
// --- Result type — returned from TUI to caller ---
|
||||
|
|
@ -201,6 +216,8 @@ type tuiModel struct {
|
|||
result *TUIResult
|
||||
helpScreen *helpScreenModel
|
||||
actionMenu *actionMenuModel
|
||||
forwardScreen *forwardScreenModel
|
||||
forwardForm *forwardFormModel
|
||||
}
|
||||
|
||||
func New(servers []*model.Server) *tuiModel {
|
||||
|
|
@ -317,6 +334,24 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.pendingTemplate = nil
|
||||
return m, nil
|
||||
|
||||
case forwardsLoadedMsg:
|
||||
if m.forwardScreen != nil {
|
||||
if msg.err != nil {
|
||||
m.forwardScreen.err = msg.err
|
||||
} else {
|
||||
m.forwardScreen.forwards = msg.forwards
|
||||
m.forwardScreen.rebuildList()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case forwardDeletedMsg:
|
||||
if m.forwardScreen != nil && msg.err == nil {
|
||||
// Reload forwards
|
||||
return m, m.forwardScreen.loadForwards()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case testDoneMsg:
|
||||
if m.form != nil {
|
||||
m.form.testing = false
|
||||
|
|
@ -399,6 +434,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.updateHelp(msg)
|
||||
case screenActionMenu:
|
||||
return m.updateActionMenu(msg)
|
||||
case screenForwardList:
|
||||
return m.updateForwardList(msg)
|
||||
case screenForwardForm:
|
||||
return m.updateForwardForm(msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -496,6 +535,14 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
m.screen = screenHelp
|
||||
return m, nil
|
||||
|
||||
case tea.KeyCtrlW:
|
||||
// Open forward manager for selected server
|
||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||
m.forwardScreen = newForwardScreenModel(item.server.ID, item.server.Alias, m.width, m.height)
|
||||
m.screen = screenForwardList
|
||||
return m, m.forwardScreen.loadForwards()
|
||||
}
|
||||
|
||||
case tea.KeyCtrlX:
|
||||
m.actionMenu = newActionMenuModel(m.width, m.height)
|
||||
m.screen = screenActionMenu
|
||||
|
|
@ -850,6 +897,16 @@ func (m *tuiModel) View() string {
|
|||
if m.actionMenu != nil {
|
||||
b.WriteString(m.actionMenu.View())
|
||||
}
|
||||
|
||||
case screenForwardList:
|
||||
if m.forwardScreen != nil {
|
||||
b.WriteString(m.forwardScreen.View())
|
||||
}
|
||||
|
||||
case screenForwardForm:
|
||||
if m.forwardForm != nil {
|
||||
b.WriteString(m.forwardForm.View())
|
||||
}
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
|
|
@ -931,6 +988,63 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
m.screen = screenList
|
||||
m.forwardScreen = nil
|
||||
return m, nil
|
||||
case tea.KeyCtrlA:
|
||||
// Add forward
|
||||
if m.forwardScreen != nil {
|
||||
m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height)
|
||||
m.screen = screenForwardForm
|
||||
return m, nil
|
||||
}
|
||||
case tea.KeyCtrlD:
|
||||
if m.forwardScreen != nil {
|
||||
return m, m.forwardScreen.deleteSelected()
|
||||
}
|
||||
case tea.KeyRunes:
|
||||
switch msg.String() {
|
||||
case "a", "A":
|
||||
if m.forwardScreen != nil {
|
||||
m.forwardForm = newForwardFormModel(m.forwardScreen.serverID, m.width, m.height)
|
||||
m.screen = screenForwardForm
|
||||
return m, nil
|
||||
}
|
||||
case "d", "D":
|
||||
if m.forwardScreen != nil {
|
||||
return m, m.forwardScreen.deleteSelected()
|
||||
}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.forwardScreen.list, cmd = m.forwardScreen.list.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *tuiModel) updateForwardForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if msg.Type == tea.KeyEsc {
|
||||
m.screen = screenForwardList
|
||||
m.forwardForm = nil
|
||||
return m, nil
|
||||
}
|
||||
updated, cmd := m.forwardForm.Update(msg)
|
||||
if fm, ok := updated.(*forwardFormModel); ok {
|
||||
m.forwardForm = fm
|
||||
if fm.saved {
|
||||
m.screen = screenForwardList
|
||||
m.forwardForm = nil
|
||||
// Reload forward list
|
||||
if m.forwardScreen != nil {
|
||||
return m, m.forwardScreen.loadForwards()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *tuiModel) viewServerList() string {
|
||||
var b strings.Builder
|
||||
selectedAlias := ""
|
||||
|
|
@ -1379,6 +1493,7 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
|
|||
helpItem{Key: "Ctrl+F", Action: "search"},
|
||||
helpItem{Key: "Ctrl+P", Action: "tmpl"},
|
||||
helpItem{Key: "Ctrl+G", Action: "tags"},
|
||||
helpItem{Key: "Ctrl+W", Action: "forwards"},
|
||||
helpItem{Key: "Ins", Action: insAction},
|
||||
helpItem{Key: "?", Action: "help"},
|
||||
helpItem{Key: "Ctrl+Q", Action: "quit"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
"github.com/charmbracelet/bubbletea"
|
||||
"github.com/mirivlad/sshkeeper/internal/model"
|
||||
)
|
||||
|
||||
// --- Forward list screen model ---
|
||||
|
||||
type forwardScreenModel struct {
|
||||
serverID int64
|
||||
serverAlias string
|
||||
list list.Model
|
||||
forwards []*model.Forward
|
||||
width int
|
||||
height int
|
||||
err error
|
||||
}
|
||||
|
||||
func newForwardScreenModel(serverID int64, serverAlias string, w, h int) *forwardScreenModel {
|
||||
l := list.New([]list.Item{}, list.NewDefaultDelegate(), w, h-6)
|
||||
l.Title = "Port Forwards — " + serverAlias
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.Styles.Title = titleStyle
|
||||
|
||||
return &forwardScreenModel{
|
||||
serverID: serverID,
|
||||
serverAlias: serverAlias,
|
||||
list: l,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
type forwardItem struct {
|
||||
forward *model.Forward
|
||||
}
|
||||
|
||||
func (i forwardItem) Title() string {
|
||||
return fmt.Sprintf("[%s] %s", i.forward.Type, forwardSummary(i.forward))
|
||||
}
|
||||
func (i forwardItem) Description() string {
|
||||
return forwardPreview(i.forward)
|
||||
}
|
||||
func (i forwardItem) FilterValue() string {
|
||||
return string(i.forward.Type) + " " + fmt.Sprintf("%d", i.forward.LocalPort) + " " + i.forward.RemoteAddr
|
||||
}
|
||||
|
||||
func forwardSummary(f *model.Forward) string {
|
||||
switch f.Type {
|
||||
case model.ForwardLocal:
|
||||
return fmt.Sprintf("%s:%d → %s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
|
||||
case model.ForwardRemote:
|
||||
return fmt.Sprintf("%s:%d ← %s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
|
||||
case model.ForwardDynamic:
|
||||
return fmt.Sprintf("SOCKS %s:%d", f.LocalAddr, f.LocalPort)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s:%d", f.Type, f.LocalAddr, f.LocalPort)
|
||||
}
|
||||
}
|
||||
|
||||
func forwardPreview(f *model.Forward) string {
|
||||
switch f.Type {
|
||||
case model.ForwardLocal:
|
||||
return fmt.Sprintf("-L %s:%d:%s:%d", f.LocalAddr, f.LocalPort, f.RemoteAddr, f.RemotePort)
|
||||
case model.ForwardRemote:
|
||||
return fmt.Sprintf("-R %s:%d:%s:%d", f.RemoteAddr, f.RemotePort, f.LocalAddr, f.LocalPort)
|
||||
case model.ForwardDynamic:
|
||||
return fmt.Sprintf("-D %s:%d", f.LocalAddr, f.LocalPort)
|
||||
default:
|
||||
return string(f.Type)
|
||||
}
|
||||
}
|
||||
|
||||
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) rebuildList() {
|
||||
items := make([]list.Item, len(m.forwards))
|
||||
for i, f := range m.forwards {
|
||||
items[i] = forwardItem{forward: f}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
}
|
||||
|
||||
func (m *forwardScreenModel) deleteSelected() tea.Cmd {
|
||||
if item, ok := m.list.SelectedItem().(forwardItem); ok && DeleteForward != nil {
|
||||
f := item.forward
|
||||
return func() tea.Msg {
|
||||
return forwardDeletedMsg{id: f.ID, err: DeleteForward(f.ID)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *forwardScreenModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(m.list.View())
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
{Key: "Ctrl+A (a)", Action: "add"},
|
||||
{Key: "Ctrl+D (d)", Action: "delete"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, m.width))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Forward form screen model ---
|
||||
|
||||
type forwardFormModel struct {
|
||||
serverID int64
|
||||
inputs []textinput.Model
|
||||
labels []string
|
||||
focusIdx int
|
||||
err error
|
||||
saved bool
|
||||
typeList list.Model
|
||||
showList bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newForwardFormModel(serverID int64, w, h int) *forwardFormModel {
|
||||
labels := []string{"Type (local/remote/dynamic)", "Listen Addr", "Listen Port", "Target Addr", "Target Port"}
|
||||
inputs := make([]textinput.Model, len(labels))
|
||||
for i, label := range labels {
|
||||
inputs[i] = textinput.New()
|
||||
inputs[i].Placeholder = forwardPlaceholder(label)
|
||||
inputs[i].CharLimit = 128
|
||||
}
|
||||
inputs[0].Focus()
|
||||
|
||||
typeList := newStringList([]string{"local", "remote", "dynamic"}, "Select type", 30, 8)
|
||||
|
||||
return &forwardFormModel{
|
||||
serverID: serverID,
|
||||
inputs: inputs,
|
||||
labels: labels,
|
||||
focusIdx: 0,
|
||||
typeList: typeList,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
func forwardPlaceholder(label string) string {
|
||||
switch label {
|
||||
case "Type (local/remote/dynamic)":
|
||||
return "local"
|
||||
case "Listen Addr":
|
||||
return "0.0.0.0"
|
||||
case "Listen Port":
|
||||
return "8080"
|
||||
case "Target Addr":
|
||||
return "internal.web"
|
||||
case "Target Port":
|
||||
return "80"
|
||||
default:
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *forwardFormModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fm *forwardFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case saveDoneMsg:
|
||||
fm.saved = (msg.err == nil)
|
||||
fm.err = msg.err
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
if fm.showList {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEsc:
|
||||
fm.showList = false
|
||||
return fm, nil
|
||||
case tea.KeyEnter:
|
||||
if item, ok := fm.typeList.SelectedItem().(groupItem); ok {
|
||||
fm.inputs[0].SetValue(item.name)
|
||||
}
|
||||
fm.showList = false
|
||||
return fm, nil
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
fm.typeList, cmd = fm.typeList.Update(msg)
|
||||
return fm, cmd
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyTab:
|
||||
fm.focusIdx++
|
||||
total := len(fm.inputs) + 1
|
||||
if fm.focusIdx >= total {
|
||||
fm.focusIdx = 0
|
||||
}
|
||||
fm.updateFocus()
|
||||
return fm, nil
|
||||
case tea.KeyShiftTab:
|
||||
fm.focusIdx--
|
||||
if fm.focusIdx < 0 {
|
||||
total := len(fm.inputs) + 1
|
||||
fm.focusIdx = total - 1
|
||||
}
|
||||
fm.updateFocus()
|
||||
return fm, nil
|
||||
case tea.KeyRunes:
|
||||
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 0 {
|
||||
fm.showList = true
|
||||
return fm, nil
|
||||
}
|
||||
case tea.KeyEnter:
|
||||
if fm.focusIdx == len(fm.inputs) {
|
||||
return fm, fm.runSave()
|
||||
}
|
||||
fm.focusIdx++
|
||||
fm.updateFocus()
|
||||
return fm, nil
|
||||
case tea.KeyEsc:
|
||||
return fm, nil
|
||||
case tea.KeyDown:
|
||||
fm.focusIdx++
|
||||
total := len(fm.inputs) + 1
|
||||
if fm.focusIdx >= total {
|
||||
fm.focusIdx = 0
|
||||
}
|
||||
fm.updateFocus()
|
||||
return fm, nil
|
||||
case tea.KeyUp:
|
||||
fm.focusIdx--
|
||||
if fm.focusIdx < 0 {
|
||||
total := len(fm.inputs) + 1
|
||||
fm.focusIdx = total - 1
|
||||
}
|
||||
fm.updateFocus()
|
||||
return fm, nil
|
||||
}
|
||||
}
|
||||
|
||||
if fm.focusIdx < len(fm.inputs) {
|
||||
var cmd tea.Cmd
|
||||
fm.inputs[fm.focusIdx], cmd = fm.inputs[fm.focusIdx].Update(msg)
|
||||
return fm, cmd
|
||||
}
|
||||
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
func (fm *forwardFormModel) updateFocus() {
|
||||
for i := range fm.inputs {
|
||||
fm.inputs[i].Blur()
|
||||
fm.inputs[i].Prompt = blurredStyle.Render(fm.labels[i] + ": ")
|
||||
}
|
||||
if fm.focusIdx < len(fm.inputs) {
|
||||
fm.inputs[fm.focusIdx].Focus()
|
||||
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labels[fm.focusIdx] + "> ")
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *forwardFormModel) runSave() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
fwdType := model.ForwardType(strings.TrimSpace(fm.inputs[0].Value()))
|
||||
if fwdType == "" {
|
||||
fwdType = model.ForwardLocal
|
||||
}
|
||||
localPort := 0
|
||||
fmt.Sscanf(fm.inputs[2].Value(), "%d", &localPort)
|
||||
remotePort := 0
|
||||
fmt.Sscanf(fm.inputs[4].Value(), "%d", &remotePort)
|
||||
|
||||
fwd := &model.Forward{
|
||||
ServerID: fm.serverID,
|
||||
Type: fwdType,
|
||||
LocalAddr: fm.inputs[1].Value(),
|
||||
LocalPort: localPort,
|
||||
RemoteAddr: fm.inputs[3].Value(),
|
||||
RemotePort: remotePort,
|
||||
}
|
||||
|
||||
if SaveForward == nil {
|
||||
return saveDoneMsg{err: fmt.Errorf("forward storage is unavailable")}
|
||||
}
|
||||
if err := SaveForward(fwd); err != nil {
|
||||
return saveDoneMsg{err: err}
|
||||
}
|
||||
return saveDoneMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *forwardFormModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(titleStyle.Render("Add Port Forward"))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
for i := range fm.inputs {
|
||||
b.WriteString(fm.inputs[i].View())
|
||||
b.WriteString("\n")
|
||||
if i == 0 && fm.showList {
|
||||
b.WriteString("\n" + renderDropdown(fm.typeList) + "\n")
|
||||
b.WriteString(renderHelp([]helpItem{{Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}}, fm.width))
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Preview
|
||||
fwdType := strings.TrimSpace(fm.inputs[0].Value())
|
||||
localAddr := fm.inputs[1].Value()
|
||||
localPort := fm.inputs[2].Value()
|
||||
remoteAddr := fm.inputs[3].Value()
|
||||
remotePort := fm.inputs[4].Value()
|
||||
|
||||
if fwdType != "" && localPort != "" {
|
||||
b.WriteString("\n" + sectionStyle.Render("Preview") + "\n")
|
||||
switch fwdType {
|
||||
case "local":
|
||||
b.WriteString(fmt.Sprintf(" -L %s:%s:%s:%s\n", localAddr, localPort, remoteAddr, remotePort))
|
||||
case "remote":
|
||||
b.WriteString(fmt.Sprintf(" -R %s:%s:%s:%s\n", remoteAddr, remotePort, localAddr, localPort))
|
||||
case "dynamic":
|
||||
b.WriteString(fmt.Sprintf(" -D %s:%s\n", localAddr, localPort))
|
||||
}
|
||||
b.WriteString(" -o ExitOnForwardFailure=yes\n")
|
||||
}
|
||||
|
||||
button := "\n[ Save ]"
|
||||
if fm.focusIdx == len(fm.inputs) {
|
||||
button = selectedStyle.Render(button)
|
||||
}
|
||||
b.WriteString(button)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if fm.err != nil {
|
||||
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", fm.err)) + "\n\n")
|
||||
}
|
||||
if fm.saved {
|
||||
b.WriteString(successStyle.Render("✓ Saved.") + "\n\n")
|
||||
}
|
||||
|
||||
b.WriteString(renderHelp([]helpItem{
|
||||
{Key: "Tab/↓", Action: "next"},
|
||||
{Key: "↑", Action: "prev"},
|
||||
{Key: "/", Action: "pick type"},
|
||||
{Key: "Enter", Action: "save"},
|
||||
{Key: "Esc", Action: "back"},
|
||||
}, fm.width))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
Loading…
Reference in New Issue