@@ -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
3240func (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
170186func (t * tui ) handleServerConnect () {
@@ -265,7 +281,7 @@ func (t *tui) handleModalClose() {
265281func (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-
309317func (t * tui ) showDeleteConfirmModal (server domain.Server ) {
310318 msg := fmt .Sprintf ("Delete server %s (%s@%s:%d)?\n \n This 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
395539func (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+ }
0 commit comments