diff --git a/internal/fs/remote/client.go b/internal/fs/remote/client.go index 340151e..34e534f 100644 --- a/internal/fs/remote/client.go +++ b/internal/fs/remote/client.go @@ -8,6 +8,7 @@ import ( "path" "path/filepath" "strings" + "sync" "time" "github.com/pkg/sftp" @@ -21,6 +22,9 @@ type SSHClient struct { sshConn *ssh.Client sftpCli *sftp.Client + + keepaliveStop chan struct{} + keepaliveWg sync.WaitGroup } // Connect establishes an SSH connection to the remote host and opens an SFTP session. @@ -55,11 +59,34 @@ func Connect(host SSHHost) (*SSHClient, error) { return nil, fmt.Errorf("sftp client: %w", err) } - return &SSHClient{ - Host: host, - sshConn: sshConn, - sftpCli: sftpCli, - }, nil + client := &SSHClient{ + Host: host, + sshConn: sshConn, + sftpCli: sftpCli, + keepaliveStop: make(chan struct{}), + } + + // Start keepalive goroutine — sends keepalive@openssh.com every 30s + // to prevent the SSH server from dropping the connection during inactivity. + client.keepaliveWg.Add(1) + go func() { + defer client.keepaliveWg.Done() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + _, _, err := sshConn.SendRequest("keepalive@openssh.com", true, nil) + if err != nil { + return + } + case <-client.keepaliveStop: + return + } + } + }() + + return client, nil } // authMethodsForHost returns the appropriate SSH auth methods for the given host. @@ -248,6 +275,17 @@ func (c *SSHClient) Rename(oldPath, newPath string) error { // Close closes the SFTP session and SSH connection. func (c *SSHClient) Close() error { + // Stop the keepalive goroutine first + if c.keepaliveStop != nil { + select { + case <-c.keepaliveStop: + // already closed + default: + close(c.keepaliveStop) + } + c.keepaliveWg.Wait() + } + var firstErr error if c.sftpCli != nil { diff --git a/internal/ui/model.go b/internal/ui/model.go index 8d26a47..ba222c4 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -454,10 +454,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Printf("[ACTION] opMsg: Delete done — moved %s to trash", msg.sourcePath) m.status = "Moved to trash" m.activePane().ClearMarks() + // Move cursor to the item above if not at the top. + // If already at the top (cursor == 0), stay at position 0 — + // the next entry shifts into the deleted item's place. + active := m.activePane() + if active.Cursor > 0 { + active.Cursor-- + } + if m.active == PaneLeft { + leftSelection = "" + } else { + rightSelection = "" + } case opPermanentDelete: log.Printf("[ACTION] opMsg: PermanentDelete done — sourcePath=%s", msg.sourcePath) m.status = "Permanently deleted" m.activePane().ClearMarks() + // Move cursor to the item above if not at the top. + // If already at the top (cursor == 0), stay at position 0 — + // the next entry shifts into the deleted item's place. + active := m.activePane() + if active.Cursor > 0 { + active.Cursor-- + } + if m.active == PaneLeft { + leftSelection = "" + } else { + rightSelection = "" + } case opMkdir: log.Printf("[ACTION] opMsg: Mkdir done — targetPath=%s", msg.targetPath) m.status = fmt.Sprintf("Created %s", msg.targetPath) @@ -3232,8 +3256,8 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c // but non-interactive (no cursor, no selection). if preview.Kind == vfs.PreviewKindDirectory && len(preview.Entries) > 0 { dirPane := BrowserPane{Entries: preview.Entries} - headerRow := renderColumnsHeader(cfg, innerWidth, palette, palette.Panel, useNerdfont) - rows := renderPaneRows(dirPane, cfg, palette, innerWidth, contentHeight, false, -1, palette.Panel, useNerdfont) + headerRow := renderColumnsHeader(cfg, innerWidth, palette, palette.Panel, useNerdfont, false) + rows := renderPaneRows(dirPane, cfg, palette, innerWidth, contentHeight, false, -1, palette.Panel, useNerdfont, false) parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, headerRow, rows)) } else { parts = append(parts, renderPreviewContent(viewportModel, palette, innerWidth, contentHeight)) diff --git a/internal/ui/pane.go b/internal/ui/pane.go index c053ccf..512f50c 100644 --- a/internal/ui/pane.go +++ b/internal/ui/pane.go @@ -379,9 +379,10 @@ func renderPane( header := lipgloss.NewStyle(). Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg)) + isSSHHostList := pane.Path == "ssh://" rowsHeight := max(innerHeight-2, 1) - headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons) - rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons) + headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons, isSSHHostList) + rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons, isSSHHostList) content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows) return box.Render(content) } @@ -403,8 +404,8 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette Render(pathStyle.Render(truncateMiddle(compactPath(pane.DisplayPath(), cfg.UI.PathDisplay), pathWidth))) } -func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool) string { - columns := buildColumns(cfg, width, useNerdIcons) +func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string { + columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols) parts := make([]string, 0, len(columns)) for idx, column := range columns { style := lipgloss.NewStyle(). @@ -426,7 +427,7 @@ func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, ba Render(strings.Join(parts, "")) } -func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, background lipgloss.Color, useNerdIcons bool) string { +func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string { if len(pane.Entries) == 0 { return lipgloss.NewStyle(). Width(width). @@ -446,7 +447,7 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, entry := pane.Entries[idx] isSelected := idx == pane.Cursor && active marked := !entry.IsParent && pane.IsMarked(entry.Path) - row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background, useNerdIcons) + row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background, useNerdIcons, hideExtraCols) lines = append(lines, row) } for len(lines) < visibleHeight { @@ -459,8 +460,8 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, Render(strings.Join(lines, "\n")) } -func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color, useNerdIcons bool) string { - columns := buildColumns(cfg, width, useNerdIcons) +func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string { + columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols) rowBackground := baseBackground switch { case marked: @@ -512,7 +513,7 @@ type columnSpec struct { Value func(entry vfs.Entry, human bool) string } -func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool) []columnSpec { +func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool, hideExtraCols bool) []columnSpec { fixed := []columnSpec{} if cfg.Browser.Columns.Permissions { @@ -540,7 +541,7 @@ func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool) []column }, }) } - if cfg.Browser.Columns.Size { + if cfg.Browser.Columns.Size && !hideExtraCols { fixed = append(fixed, columnSpec{ Key: "size", Title: "Size", @@ -578,13 +579,16 @@ func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool) []column }, }) } - if cfg.Browser.Columns.Modified { + if cfg.Browser.Columns.Modified && !hideExtraCols { fixed = append(fixed, columnSpec{ Key: "modified", Title: "Modified", Width: 11, MinWidth: 8, Value: func(entry vfs.Entry, _ bool) string { + if entry.IsParent { + return "" + } return vfs.CompactTime(entry.ModifiedAt) }, }) diff --git a/internal/ui/ssh.go b/internal/ui/ssh.go index 43857c2..e64f46a 100644 --- a/internal/ui/ssh.go +++ b/internal/ui/ssh.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "path" "strings" @@ -27,11 +28,32 @@ func buildSSHHostEntries(store *remote.HostStore, connectedHosts map[string]bool hosts := store.AllHosts() entries := make([]vfs.Entry, 0, len(hosts)+1) + // Find the longest host name so we can pad them all to the same width, + // making the (user@host) suffix align vertically across entries. + maxNameLen := 0 + for _, h := range hosts { + if l := len(h.Name); l > maxNameLen { + maxNameLen = l + } + } + for _, h := range hosts { isConnected := connectedHosts != nil && connectedHosts[h.Name] + addr := h.HostName + if h.Port != "" && h.Port != "22" { + addr = fmt.Sprintf("%s:%s", addr, h.Port) + } + suffix := "" + if h.User != "" { + suffix = fmt.Sprintf(" (%s@%s)", h.User, addr) + } else { + suffix = fmt.Sprintf(" (%s)", addr) + } + paddedName := h.Name + strings.Repeat(" ", maxNameLen-len(h.Name)) + entries = append(entries, vfs.Entry{ - Name: h.DisplayName(), + Name: paddedName + suffix, Path: "ssh://" + h.Name, IsDir: true, IsRemote: true,