Skip to content

Commit d4be74b

Browse files
authored
feat: support interactive SSH port forwarding (#67)
1 parent 6ad0719 commit d4be74b

File tree

8 files changed

+347
-56
lines changed

8 files changed

+347
-56
lines changed

internal/adapters/ui/handlers.go

Lines changed: 181 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ import (
2828
// =============================================================================
2929
// Event Handlers (handle user input/events)
3030
// =============================================================================
31+
const (
32+
ForwardTypeLocal = "Local"
33+
ForwardTypeRemote = "Remote"
34+
ForwardTypeDynamic = "Dynamic"
35+
36+
ForwardModeOnlyForward = "Only forward"
37+
ForwardModeForwardSSH = "Forward + SSH"
38+
)
3139

3240
func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
3341
// Don't handle global keys when search has focus
@@ -40,7 +48,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
4048
t.handleQuit()
4149
return nil
4250
case '/':
43-
t.handleSearchToggle()
51+
t.handleSearchFocus()
4452
return nil
4553
case 'a':
4654
t.handleServerAdd()
@@ -72,6 +80,12 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
7280
case 't':
7381
t.handleTagsEdit()
7482
return nil
83+
case 'f':
84+
t.handlePortForward()
85+
return nil
86+
case 'x':
87+
t.handleStopForwarding()
88+
return nil
7589
case 'j':
7690
t.handleNavigateDown()
7791
return nil
@@ -163,8 +177,10 @@ func (t *tui) handleSearchInput(query string) {
163177
}
164178
}
165179

166-
func (t *tui) handleSearchToggle() {
167-
t.showSearchBar()
180+
func (t *tui) handleSearchFocus() {
181+
if t.app != nil && t.searchBar != nil {
182+
t.app.SetFocus(t.searchBar)
183+
}
168184
}
169185

170186
func (t *tui) handleServerConnect() {
@@ -265,7 +281,7 @@ func (t *tui) handleModalClose() {
265281
func (t *tui) handleRefreshBackground() {
266282
currentIdx := t.serverList.GetCurrentItem()
267283
query := ""
268-
if t.searchVisible {
284+
if t.searchBar != nil {
269285
query = t.searchBar.InputField.GetText()
270286
}
271287

@@ -298,14 +314,6 @@ func (t *tui) handleRefreshBackground() {
298314
// UI Display Functions (show UI elements/modals)
299315
// =============================================================================
300316

301-
func (t *tui) showSearchBar() {
302-
t.left.Clear()
303-
t.left.AddItem(t.searchBar, 3, 0, true)
304-
t.left.AddItem(t.serverList, 0, 1, false)
305-
t.app.SetFocus(t.searchBar)
306-
t.searchVisible = true
307-
}
308-
309317
func (t *tui) showDeleteConfirmModal(server domain.Server) {
310318
msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.",
311319
server.Alias, server.User, server.Host, server.Port)
@@ -372,6 +380,143 @@ func (t *tui) showEditTagsForm(server domain.Server) {
372380
form.AddButton("Cancel", func() { t.returnToMain() })
373381
form.SetCancelFunc(func() { t.returnToMain() })
374382

383+
t.app.SetRoot(form, true)
384+
toFocus := form
385+
t.app.SetFocus(toFocus)
386+
}
387+
388+
func (t *tui) handlePortForward() {
389+
if server, ok := t.serverList.GetSelectedServer(); ok {
390+
t.showPortForwardForm(server)
391+
}
392+
}
393+
394+
func (t *tui) showPortForwardForm(server domain.Server) {
395+
typeChoices := []string{ForwardTypeLocal, ForwardTypeRemote, ForwardTypeDynamic}
396+
modeChoices := []string{ForwardModeOnlyForward, ForwardModeForwardSSH}
397+
398+
currentTypeIdx := 0
399+
currentModeIdx := 0
400+
portVal := ""
401+
hostVal := "localhost"
402+
hostPortVal := ""
403+
bindAddrVal := ""
404+
405+
form := tview.NewForm()
406+
form.SetBorder(true).
407+
SetTitle(fmt.Sprintf(" Port Forwarding: %s ", server.Alias)).
408+
SetTitleAlign(tview.AlignCenter)
409+
410+
dd := tview.NewDropDown()
411+
hostField := tview.NewInputField()
412+
hostPortField := tview.NewInputField()
413+
portField := tview.NewInputField()
414+
bindAddrField := tview.NewInputField()
415+
416+
dd.SetOptions(typeChoices, func(text string, index int) {
417+
currentTypeIdx = index
418+
// Toggle fields when switching type
419+
isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic
420+
if isDynamic {
421+
hostField.SetText("").SetDisabled(true)
422+
hostPortField.SetText("").SetDisabled(true)
423+
} else {
424+
hostField.SetDisabled(false)
425+
hostPortField.SetDisabled(false)
426+
}
427+
})
428+
dd.SetCurrentOption(currentTypeIdx)
429+
form.AddFormItem(dd.SetLabel("Type"))
430+
431+
portField.SetLabel("Port").SetText(portVal).SetFieldWidth(8).SetChangedFunc(func(text string) { portVal = strings.TrimSpace(text) })
432+
form.AddFormItem(portField)
433+
434+
hostField.SetLabel("Host").SetText(hostVal).SetFieldWidth(40).SetChangedFunc(func(text string) { hostVal = strings.TrimSpace(text) })
435+
form.AddFormItem(hostField)
436+
437+
hostPortField.SetLabel("Host Port").SetText(hostPortVal).SetFieldWidth(8).SetChangedFunc(func(text string) { hostPortVal = strings.TrimSpace(text) })
438+
form.AddFormItem(hostPortField)
439+
440+
bindAddrField.SetLabel("Bind Address (optional)").SetText(bindAddrVal).SetFieldWidth(40).SetChangedFunc(func(text string) { bindAddrVal = strings.TrimSpace(text) })
441+
form.AddFormItem(bindAddrField)
442+
443+
mode := tview.NewDropDown().SetOptions(modeChoices, func(text string, index int) { currentModeIdx = index })
444+
mode.SetCurrentOption(currentModeIdx)
445+
form.AddFormItem(mode.SetLabel("Mode"))
446+
447+
isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic
448+
if isDynamic {
449+
hostField.SetText("").SetDisabled(true)
450+
hostPortField.SetText("").SetDisabled(true)
451+
}
452+
453+
form.AddButton("Start", func() {
454+
if err := validatePort(portVal); err != nil {
455+
t.showStatusTempColor("Invalid port: "+err.Error(), "#FF6B6B")
456+
return
457+
}
458+
if bindAddrVal != "" {
459+
if err := validateBindAddress(bindAddrVal); err != nil {
460+
t.showStatusTempColor("Invalid bind address: "+err.Error(), "#FF6B6B")
461+
return
462+
}
463+
}
464+
465+
ft := typeChoices[currentTypeIdx]
466+
var args []string
467+
if ft == ForwardTypeDynamic {
468+
spec := portVal
469+
if bindAddrVal != "" {
470+
spec = bindAddrVal + ":" + portVal
471+
}
472+
args = append(args, "-D", spec)
473+
} else {
474+
if err := validateHost(hostVal); err != nil {
475+
t.showStatusTempColor("Invalid host: "+err.Error(), "#FF6B6B")
476+
return
477+
}
478+
if err := validatePort(hostPortVal); err != nil {
479+
t.showStatusTempColor("Invalid host port: "+err.Error(), "#FF6B6B")
480+
return
481+
}
482+
spec := portVal + ":" + hostVal + ":" + hostPortVal
483+
if bindAddrVal != "" {
484+
spec = bindAddrVal + ":" + spec
485+
}
486+
if ft == ForwardTypeLocal {
487+
args = append(args, "-L", spec)
488+
} else {
489+
args = append(args, "-R", spec)
490+
}
491+
}
492+
493+
onlyForward := modeChoices[currentModeIdx] == ForwardModeOnlyForward
494+
alias := server.Alias
495+
if onlyForward {
496+
t.returnToMain()
497+
t.showStatusTemp("Starting port forward…")
498+
go func() {
499+
pid, err := t.serverService.StartForward(alias, args)
500+
t.app.QueueUpdateDraw(func() {
501+
if err != nil {
502+
t.showStatusTempColor("Forward failed: "+err.Error(), "#FF6B6B")
503+
} else {
504+
t.refreshServerList()
505+
t.showStatusTemp(fmt.Sprintf("Port forwarding started (pid %d)", pid))
506+
}
507+
})
508+
}()
509+
return
510+
}
511+
512+
t.app.Suspend(func() {
513+
_ = t.serverService.SSHWithArgs(alias, args)
514+
})
515+
t.returnToMain()
516+
})
517+
form.AddButton("Cancel", func() { t.returnToMain() })
518+
form.SetCancelFunc(func() { t.returnToMain() })
519+
375520
t.app.SetRoot(form, true)
376521
t.app.SetFocus(form)
377522
}
@@ -380,12 +525,11 @@ func (t *tui) showEditTagsForm(server domain.Server) {
380525
// UI State Management (hide UI elements)
381526
// =============================================================================
382527

383-
func (t *tui) hideSearchBar() {
384-
t.left.Clear()
385-
t.left.AddItem(t.hintBar, 1, 0, false)
386-
t.left.AddItem(t.serverList, 0, 1, true)
387-
t.app.SetFocus(t.serverList)
388-
t.searchVisible = false
528+
// blurSearchBar moves focus back to the server list without changing layout.
529+
func (t *tui) blurSearchBar() {
530+
if t.app != nil && t.serverList != nil {
531+
t.app.SetFocus(t.serverList)
532+
}
389533
}
390534

391535
// =============================================================================
@@ -394,7 +538,7 @@ func (t *tui) hideSearchBar() {
394538

395539
func (t *tui) refreshServerList() {
396540
query := ""
397-
if t.searchVisible {
541+
if t.searchBar != nil {
398542
query = t.searchBar.InputField.GetText()
399543
}
400544
filtered, _ := t.serverService.ListServers(query)
@@ -430,3 +574,21 @@ func (t *tui) showStatusTempColor(msg string, color string) {
430574
}
431575
})
432576
}
577+
578+
// Stop any active port forwarding for the selected server.
579+
func (t *tui) handleStopForwarding() {
580+
if server, ok := t.serverList.GetSelectedServer(); ok {
581+
alias := server.Alias
582+
go func() {
583+
err := t.serverService.StopForwarding(alias)
584+
t.app.QueueUpdateDraw(func() {
585+
if err != nil {
586+
t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), "#FF6B6B")
587+
} else {
588+
t.showStatusTemp("Stopped forwarding for " + alias)
589+
}
590+
t.refreshServerList()
591+
})
592+
}()
593+
}
594+
}

internal/adapters/ui/hint_bar.go

Lines changed: 0 additions & 27 deletions
This file was deleted.

internal/adapters/ui/server_details.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
213213
}
214214

215215
// Commands list
216-
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin"
216+
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n f: Port forward\n x: Stop forwarding\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin"
217217

218218
sd.TextView.SetText(text)
219219
}

internal/adapters/ui/status_bar.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
)
2121

2222
func DefaultStatusText() string {
23-
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
23+
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]f[-] Forward • [white]x[-] Stop Forward • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
2424
}
2525

2626
func NewStatusBar() *tview.TextView {

internal/adapters/ui/tui.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ type tui struct {
3737

3838
header *AppHeader
3939
searchBar *SearchBar
40-
hintBar *tview.TextView
4140
serverList *ServerList
4241
details *ServerDetails
4342
statusBar *tview.TextView
@@ -46,8 +45,7 @@ type tui struct {
4645
left *tview.Flex
4746
content *tview.Flex
4847

49-
sortMode SortMode
50-
searchVisible bool
48+
sortMode SortMode
5149
}
5250

5351
func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App {
@@ -93,8 +91,9 @@ func (t *tui) buildComponents() *tui {
9391
t.header = NewAppHeader(t.version, t.commit, RepoURL)
9492
t.searchBar = NewSearchBar().
9593
OnSearch(t.handleSearchInput).
96-
OnEscape(t.hideSearchBar)
97-
t.hintBar = NewHintBar()
94+
OnEscape(t.blurSearchBar)
95+
IsForwarding = t.serverService.IsForwarding
96+
9897
t.serverList = NewServerList().
9998
OnSelectionChange(t.handleServerSelectionChange)
10099
t.details = NewServerDetails()
@@ -108,7 +107,7 @@ func (t *tui) buildComponents() *tui {
108107

109108
func (t *tui) buildLayout() *tui {
110109
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
111-
AddItem(t.hintBar, 1, 0, false).
110+
AddItem(t.searchBar, 3, 0, false).
112111
AddItem(t.serverList, 0, 1, true)
113112

114113
right := tview.NewFlex().SetDirection(tview.FlexRow).

internal/adapters/ui/utils.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ import (
2525
"github.com/mattn/go-runewidth"
2626
)
2727

28+
// IsForwarding is an optional hook supplied by TUI to indicate active forwarding per alias.
29+
var IsForwarding func(alias string) bool
30+
2831
// SSH config value constants
2932
const (
3033
sshYes = "yes"
@@ -80,8 +83,18 @@ func pinnedIcon(pinnedAt time.Time) string {
8083

8184
func formatServerLine(s domain.Server) (primary, secondary string) {
8285
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
83-
// Use a consistent color for alias; the icon reflects pinning
84-
primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
86+
// forwarding column after Host/IP
87+
fGlyph := ""
88+
isFwd := IsForwarding != nil && IsForwarding(s.Alias)
89+
if isFwd {
90+
fGlyph = "Ⓕ"
91+
}
92+
fCol := cellPad(fGlyph, 2)
93+
if isFwd {
94+
fCol = "[#A0FFA0]" + fCol + "[-]"
95+
}
96+
// Use a consistent color for alias; host/IP fixed width; then forwarding column
97+
primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] %s [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, fCol, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
8598
secondary = ""
8699
return
87100
}

internal/core/ports/services.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,9 @@ type ServerService interface {
2727
DeleteServer(server domain.Server) error
2828
SetPinned(alias string, pinned bool) error
2929
SSH(alias string) error
30+
SSHWithArgs(alias string, extraArgs []string) error
31+
StartForward(alias string, extraArgs []string) (int, error)
32+
StopForwarding(alias string) error
33+
IsForwarding(alias string) bool
3034
Ping(server domain.Server) (bool, time.Duration, error)
3135
}

0 commit comments

Comments
 (0)