diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 7b1becd..8eeafa0 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -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 @@ -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() @@ -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 @@ -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() { @@ -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() } @@ -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) @@ -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) } @@ -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) + } } // ============================================================================= @@ -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) @@ -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() + }) + }() + } +} diff --git a/internal/adapters/ui/hint_bar.go b/internal/adapters/ui/hint_bar.go deleted file mode 100644 index de94973..0000000 --- a/internal/adapters/ui/hint_bar.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2025. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package ui - -import ( - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" -) - -func NewHintBar() *tview.TextView { - hint := tview.NewTextView().SetDynamicColors(true) - hint.SetBackgroundColor(tcell.Color233) - hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]") - return hint -} diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 8e0a634..48befc3 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -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) } diff --git a/internal/adapters/ui/status_bar.go b/internal/adapters/ui/status_bar.go index d8ca0aa..1d3c690 100644 --- a/internal/adapters/ui/status_bar.go +++ b/internal/adapters/ui/status_bar.go @@ -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 { diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 6046071..075a0e4 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -37,7 +37,6 @@ type tui struct { header *AppHeader searchBar *SearchBar - hintBar *tview.TextView serverList *ServerList details *ServerDetails statusBar *tview.TextView @@ -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 { @@ -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() @@ -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). diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 95a3996..0b49ad8 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -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" @@ -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 } diff --git a/internal/core/ports/services.go b/internal/core/ports/services.go index 9394751..2407269 100644 --- a/internal/core/ports/services.go +++ b/internal/core/ports/services.go @@ -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) } diff --git a/internal/core/services/server_service.go b/internal/core/services/server_service.go index c01b18b..87473ba 100644 --- a/internal/core/services/server_service.go +++ b/internal/core/services/server_service.go @@ -24,6 +24,8 @@ import ( "sort" "strconv" "strings" + "sync" + "syscall" "time" "github.com/Adembc/lazyssh/internal/core/domain" @@ -34,6 +36,9 @@ import ( type serverService struct { serverRepository ports.ServerRepository logger *zap.SugaredLogger + + fwMu sync.Mutex + forwards map[string][]*os.Process } // NewServerService creates a new instance of serverService. @@ -168,6 +173,141 @@ func (s *serverService) SSH(alias string) error { return nil } +// SSHWithArgs runs system ssh with provided extra args (e.g., -L/-R/-D) for the given alias. +func (s *serverService) SSHWithArgs(alias string, extraArgs []string) error { + s.logger.Infow("ssh start (with args)", "alias", alias, "args", extraArgs) + args := append([]string{}, extraArgs...) + args = append(args, alias) + // #nosec G204 + cmd := exec.Command("ssh", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + s.logger.Errorw("ssh (with args) failed", "alias", alias, "error", err) + return err + } + if err := s.serverRepository.RecordSSH(alias); err != nil { + s.logger.Errorw("failed to record ssh metadata", "alias", alias, "error", err) + } + s.logger.Infow("ssh end (with args)", "alias", alias) + return nil +} + +// StartForward starts ssh port forwarding in the background and tracks the process. +func (s *serverService) StartForward(alias string, extraArgs []string) (int, error) { + s.fwMu.Lock() + if s.forwards == nil { + s.forwards = make(map[string][]*os.Process) + } + s.fwMu.Unlock() + + extraArgs = append(extraArgs, "-N", alias) + + // #nosec G204 + cmd := exec.Command("ssh", extraArgs...) + + // Detach from TTY: discard stdio + devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return 0, fmt.Errorf("failed to open devnull: %w", err) + } + defer func() { + if devNull != nil { + _ = devNull.Close() + } + }() + + cmd.Stdin = devNull + cmd.Stdout = devNull + cmd.Stderr = devNull + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, // Create new session to fully detach + } + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start ssh: %w", err) + } + + proc := cmd.Process + if proc == nil { + return 0, fmt.Errorf("process is nil after start") + } + pid := proc.Pid + + // Track process + s.fwMu.Lock() + s.forwards[alias] = append(s.forwards[alias], proc) + s.fwMu.Unlock() + + // Cleanup on exit + go func(a string, c *exec.Cmd, dn *os.File) { + _ = c.Wait() + _ = dn.Close() + + s.fwMu.Lock() + defer s.fwMu.Unlock() + + procs := s.forwards[a] + if len(procs) == 0 { + return + } + + filtered := make([]*os.Process, 0, len(procs)) + for _, p := range procs { + if p != nil && p.Pid != pid { + filtered = append(filtered, p) + } + } + + if len(filtered) == 0 { + delete(s.forwards, a) + } else { + s.forwards[a] = filtered + } + }(alias, cmd, devNull) + + devNull = nil // Prevent defer from closing it + + return pid, nil +} + +// StopForwarding kills all active forward processes for the alias. +func (s *serverService) StopForwarding(alias string) error { + s.fwMu.Lock() + procs := s.forwards[alias] + delete(s.forwards, alias) + s.fwMu.Unlock() + + if len(procs) == 0 { + return nil + } + + var errs []error + for _, p := range procs { + if p != nil { + if err := p.Signal(syscall.SIGTERM); err != nil { + // If SIGTERM fails, try SIGKILL + if killErr := p.Kill(); killErr != nil { + errs = append(errs, fmt.Errorf("failed to kill pid %d: %w", p.Pid, killErr)) + } + } + } + } + + if len(errs) > 0 { + return fmt.Errorf("errors stopping forwards: %v", errs) + } + return nil +} + +// IsForwarding reports whether there is at least one active forward for alias. +func (s *serverService) IsForwarding(alias string) bool { + s.fwMu.Lock() + defer s.fwMu.Unlock() + return len(s.forwards[alias]) > 0 +} + // Ping checks if the server is reachable on its SSH port. func (s *serverService) Ping(server domain.Server) (bool, time.Duration, error) { start := time.Now()