Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 181 additions & 19 deletions internal/adapters/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ import (
// =============================================================================
// Event Handlers (handle user input/events)
// =============================================================================
const (
ForwardTypeLocal = "Local"
ForwardTypeRemote = "Remote"
ForwardTypeDynamic = "Dynamic"

ForwardModeOnlyForward = "Only forward"
ForwardModeForwardSSH = "Forward + SSH"
)

func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
// Don't handle global keys when search has focus
Expand All @@ -40,7 +48,7 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
t.handleQuit()
return nil
case '/':
t.handleSearchToggle()
t.handleSearchFocus()
return nil
case 'a':
t.handleServerAdd()
Expand Down Expand Up @@ -72,6 +80,12 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
case 't':
t.handleTagsEdit()
return nil
case 'f':
t.handlePortForward()
return nil
case 'x':
t.handleStopForwarding()
return nil
case 'j':
t.handleNavigateDown()
return nil
Expand Down Expand Up @@ -163,8 +177,10 @@ func (t *tui) handleSearchInput(query string) {
}
}

func (t *tui) handleSearchToggle() {
t.showSearchBar()
func (t *tui) handleSearchFocus() {
if t.app != nil && t.searchBar != nil {
t.app.SetFocus(t.searchBar)
}
}

func (t *tui) handleServerConnect() {
Expand Down Expand Up @@ -265,7 +281,7 @@ func (t *tui) handleModalClose() {
func (t *tui) handleRefreshBackground() {
currentIdx := t.serverList.GetCurrentItem()
query := ""
if t.searchVisible {
if t.searchBar != nil {
query = t.searchBar.InputField.GetText()
}

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

func (t *tui) showSearchBar() {
t.left.Clear()
t.left.AddItem(t.searchBar, 3, 0, true)
t.left.AddItem(t.serverList, 0, 1, false)
t.app.SetFocus(t.searchBar)
t.searchVisible = true
}

func (t *tui) showDeleteConfirmModal(server domain.Server) {
msg := fmt.Sprintf("Delete server %s (%s@%s:%d)?\n\nThis action cannot be undone.",
server.Alias, server.User, server.Host, server.Port)
Expand Down Expand Up @@ -372,6 +380,143 @@ func (t *tui) showEditTagsForm(server domain.Server) {
form.AddButton("Cancel", func() { t.returnToMain() })
form.SetCancelFunc(func() { t.returnToMain() })

t.app.SetRoot(form, true)
toFocus := form
t.app.SetFocus(toFocus)
}

func (t *tui) handlePortForward() {
if server, ok := t.serverList.GetSelectedServer(); ok {
t.showPortForwardForm(server)
}
}

func (t *tui) showPortForwardForm(server domain.Server) {
typeChoices := []string{ForwardTypeLocal, ForwardTypeRemote, ForwardTypeDynamic}
modeChoices := []string{ForwardModeOnlyForward, ForwardModeForwardSSH}

currentTypeIdx := 0
currentModeIdx := 0
portVal := ""
hostVal := "localhost"
hostPortVal := ""
bindAddrVal := ""

form := tview.NewForm()
form.SetBorder(true).
SetTitle(fmt.Sprintf(" Port Forwarding: %s ", server.Alias)).
SetTitleAlign(tview.AlignCenter)

dd := tview.NewDropDown()
hostField := tview.NewInputField()
hostPortField := tview.NewInputField()
portField := tview.NewInputField()
bindAddrField := tview.NewInputField()

dd.SetOptions(typeChoices, func(text string, index int) {
currentTypeIdx = index
// Toggle fields when switching type
isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic
if isDynamic {
hostField.SetText("").SetDisabled(true)
hostPortField.SetText("").SetDisabled(true)
} else {
hostField.SetDisabled(false)
hostPortField.SetDisabled(false)
}
})
dd.SetCurrentOption(currentTypeIdx)
form.AddFormItem(dd.SetLabel("Type"))

portField.SetLabel("Port").SetText(portVal).SetFieldWidth(8).SetChangedFunc(func(text string) { portVal = strings.TrimSpace(text) })
form.AddFormItem(portField)

hostField.SetLabel("Host").SetText(hostVal).SetFieldWidth(40).SetChangedFunc(func(text string) { hostVal = strings.TrimSpace(text) })
form.AddFormItem(hostField)

hostPortField.SetLabel("Host Port").SetText(hostPortVal).SetFieldWidth(8).SetChangedFunc(func(text string) { hostPortVal = strings.TrimSpace(text) })
form.AddFormItem(hostPortField)

bindAddrField.SetLabel("Bind Address (optional)").SetText(bindAddrVal).SetFieldWidth(40).SetChangedFunc(func(text string) { bindAddrVal = strings.TrimSpace(text) })
form.AddFormItem(bindAddrField)

mode := tview.NewDropDown().SetOptions(modeChoices, func(text string, index int) { currentModeIdx = index })
mode.SetCurrentOption(currentModeIdx)
form.AddFormItem(mode.SetLabel("Mode"))

isDynamic := typeChoices[currentTypeIdx] == ForwardTypeDynamic
if isDynamic {
hostField.SetText("").SetDisabled(true)
hostPortField.SetText("").SetDisabled(true)
}

form.AddButton("Start", func() {
if err := validatePort(portVal); err != nil {
t.showStatusTempColor("Invalid port: "+err.Error(), "#FF6B6B")
return
}
if bindAddrVal != "" {
if err := validateBindAddress(bindAddrVal); err != nil {
t.showStatusTempColor("Invalid bind address: "+err.Error(), "#FF6B6B")
return
}
}

ft := typeChoices[currentTypeIdx]
var args []string
if ft == ForwardTypeDynamic {
spec := portVal
if bindAddrVal != "" {
spec = bindAddrVal + ":" + portVal
}
args = append(args, "-D", spec)
} else {
if err := validateHost(hostVal); err != nil {
t.showStatusTempColor("Invalid host: "+err.Error(), "#FF6B6B")
return
}
if err := validatePort(hostPortVal); err != nil {
t.showStatusTempColor("Invalid host port: "+err.Error(), "#FF6B6B")
return
}
spec := portVal + ":" + hostVal + ":" + hostPortVal
if bindAddrVal != "" {
spec = bindAddrVal + ":" + spec
}
if ft == ForwardTypeLocal {
args = append(args, "-L", spec)
} else {
args = append(args, "-R", spec)
}
}

onlyForward := modeChoices[currentModeIdx] == ForwardModeOnlyForward
alias := server.Alias
if onlyForward {
t.returnToMain()
t.showStatusTemp("Starting port forward…")
go func() {
pid, err := t.serverService.StartForward(alias, args)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Forward failed: "+err.Error(), "#FF6B6B")
} else {
t.refreshServerList()
t.showStatusTemp(fmt.Sprintf("Port forwarding started (pid %d)", pid))
}
})
}()
return
}

t.app.Suspend(func() {
_ = t.serverService.SSHWithArgs(alias, args)
})
t.returnToMain()
})
form.AddButton("Cancel", func() { t.returnToMain() })
form.SetCancelFunc(func() { t.returnToMain() })

t.app.SetRoot(form, true)
t.app.SetFocus(form)
}
Expand All @@ -380,12 +525,11 @@ func (t *tui) showEditTagsForm(server domain.Server) {
// UI State Management (hide UI elements)
// =============================================================================

func (t *tui) hideSearchBar() {
t.left.Clear()
t.left.AddItem(t.hintBar, 1, 0, false)
t.left.AddItem(t.serverList, 0, 1, true)
t.app.SetFocus(t.serverList)
t.searchVisible = false
// blurSearchBar moves focus back to the server list without changing layout.
func (t *tui) blurSearchBar() {
if t.app != nil && t.serverList != nil {
t.app.SetFocus(t.serverList)
}
}

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

func (t *tui) refreshServerList() {
query := ""
if t.searchVisible {
if t.searchBar != nil {
query = t.searchBar.InputField.GetText()
}
filtered, _ := t.serverService.ListServers(query)
Expand Down Expand Up @@ -430,3 +574,21 @@ func (t *tui) showStatusTempColor(msg string, color string) {
}
})
}

// Stop any active port forwarding for the selected server.
func (t *tui) handleStopForwarding() {
if server, ok := t.serverList.GetSelectedServer(); ok {
alias := server.Alias
go func() {
err := t.serverService.StopForwarding(alias)
t.app.QueueUpdateDraw(func() {
if err != nil {
t.showStatusTempColor("Failed to stop forwarding: "+err.Error(), "#FF6B6B")
} else {
t.showStatusTemp("Stopped forwarding for " + alias)
}
t.refreshServerList()
})
}()
}
}
27 changes: 0 additions & 27 deletions internal/adapters/ui/hint_bar.go

This file was deleted.

2 changes: 1 addition & 1 deletion internal/adapters/ui/server_details.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
}

// Commands list
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"
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"

sd.TextView.SetText(text)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/adapters/ui/status_bar.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
)

func DefaultStatusText() string {
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"
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"
}

func NewStatusBar() *tview.TextView {
Expand Down
11 changes: 5 additions & 6 deletions internal/adapters/ui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ type tui struct {

header *AppHeader
searchBar *SearchBar
hintBar *tview.TextView
serverList *ServerList
details *ServerDetails
statusBar *tview.TextView
Expand All @@ -46,8 +45,7 @@ type tui struct {
left *tview.Flex
content *tview.Flex

sortMode SortMode
searchVisible bool
sortMode SortMode
}

func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App {
Expand Down Expand Up @@ -93,8 +91,9 @@ func (t *tui) buildComponents() *tui {
t.header = NewAppHeader(t.version, t.commit, RepoURL)
t.searchBar = NewSearchBar().
OnSearch(t.handleSearchInput).
OnEscape(t.hideSearchBar)
t.hintBar = NewHintBar()
OnEscape(t.blurSearchBar)
IsForwarding = t.serverService.IsForwarding

t.serverList = NewServerList().
OnSelectionChange(t.handleServerSelectionChange)
t.details = NewServerDetails()
Expand All @@ -108,7 +107,7 @@ func (t *tui) buildComponents() *tui {

func (t *tui) buildLayout() *tui {
t.left = tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(t.hintBar, 1, 0, false).
AddItem(t.searchBar, 3, 0, false).
AddItem(t.serverList, 0, 1, true)

right := tview.NewFlex().SetDirection(tview.FlexRow).
Expand Down
17 changes: 15 additions & 2 deletions internal/adapters/ui/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import (
"github.com/mattn/go-runewidth"
)

// IsForwarding is an optional hook supplied by TUI to indicate active forwarding per alias.
var IsForwarding func(alias string) bool

// SSH config value constants
const (
sshYes = "yes"
Expand Down Expand Up @@ -80,8 +83,18 @@ func pinnedIcon(pinnedAt time.Time) string {

func formatServerLine(s domain.Server) (primary, secondary string) {
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
// Use a consistent color for alias; the icon reflects pinning
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))
// forwarding column after Host/IP
fGlyph := ""
isFwd := IsForwarding != nil && IsForwarding(s.Alias)
if isFwd {
fGlyph = "Ⓕ"
}
fCol := cellPad(fGlyph, 2)
if isFwd {
fCol = "[#A0FFA0]" + fCol + "[-]"
}
// Use a consistent color for alias; host/IP fixed width; then forwarding column
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))
secondary = ""
return
}
Expand Down
4 changes: 4 additions & 0 deletions internal/core/ports/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,9 @@ type ServerService interface {
DeleteServer(server domain.Server) error
SetPinned(alias string, pinned bool) error
SSH(alias string) error
SSHWithArgs(alias string, extraArgs []string) error
StartForward(alias string, extraArgs []string) (int, error)
StopForwarding(alias string) error
IsForwarding(alias string) bool
Ping(server domain.Server) (bool, time.Duration, error)
}
Loading