cursor: move up on delete, hide size/modified columns in SSH host list, parent '..' empty modified, SSH keepalive 30s

This commit is contained in:
vrubelroman 2026-04-29 13:04:47 +03:00
parent 278b90e5bd
commit 8589187a10
4 changed files with 107 additions and 19 deletions

View file

@ -8,6 +8,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
"github.com/pkg/sftp" "github.com/pkg/sftp"
@ -21,6 +22,9 @@ type SSHClient struct {
sshConn *ssh.Client sshConn *ssh.Client
sftpCli *sftp.Client sftpCli *sftp.Client
keepaliveStop chan struct{}
keepaliveWg sync.WaitGroup
} }
// Connect establishes an SSH connection to the remote host and opens an SFTP session. // 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 nil, fmt.Errorf("sftp client: %w", err)
} }
return &SSHClient{ client := &SSHClient{
Host: host, Host: host,
sshConn: sshConn, sshConn: sshConn,
sftpCli: sftpCli, sftpCli: sftpCli,
}, nil 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. // 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. // Close closes the SFTP session and SSH connection.
func (c *SSHClient) Close() error { 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 var firstErr error
if c.sftpCli != nil { if c.sftpCli != nil {

View file

@ -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) log.Printf("[ACTION] opMsg: Delete done — moved %s to trash", msg.sourcePath)
m.status = "Moved to trash" m.status = "Moved to trash"
m.activePane().ClearMarks() 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: case opPermanentDelete:
log.Printf("[ACTION] opMsg: PermanentDelete done — sourcePath=%s", msg.sourcePath) log.Printf("[ACTION] opMsg: PermanentDelete done — sourcePath=%s", msg.sourcePath)
m.status = "Permanently deleted" m.status = "Permanently deleted"
m.activePane().ClearMarks() 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: case opMkdir:
log.Printf("[ACTION] opMsg: Mkdir done — targetPath=%s", msg.targetPath) log.Printf("[ACTION] opMsg: Mkdir done — targetPath=%s", msg.targetPath)
m.status = fmt.Sprintf("Created %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). // but non-interactive (no cursor, no selection).
if preview.Kind == vfs.PreviewKindDirectory && len(preview.Entries) > 0 { if preview.Kind == vfs.PreviewKindDirectory && len(preview.Entries) > 0 {
dirPane := BrowserPane{Entries: preview.Entries} dirPane := BrowserPane{Entries: preview.Entries}
headerRow := renderColumnsHeader(cfg, innerWidth, palette, palette.Panel, useNerdfont) headerRow := renderColumnsHeader(cfg, innerWidth, palette, palette.Panel, useNerdfont, false)
rows := renderPaneRows(dirPane, cfg, palette, innerWidth, contentHeight, false, -1, palette.Panel, useNerdfont) rows := renderPaneRows(dirPane, cfg, palette, innerWidth, contentHeight, false, -1, palette.Panel, useNerdfont, false)
parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, headerRow, rows)) parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, headerRow, rows))
} else { } else {
parts = append(parts, renderPreviewContent(viewportModel, palette, innerWidth, contentHeight)) parts = append(parts, renderPreviewContent(viewportModel, palette, innerWidth, contentHeight))

View file

@ -379,9 +379,10 @@ func renderPane(
header := lipgloss.NewStyle(). header := lipgloss.NewStyle().
Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg)) Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg))
isSSHHostList := pane.Path == "ssh://"
rowsHeight := max(innerHeight-2, 1) rowsHeight := max(innerHeight-2, 1)
headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons) headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons, isSSHHostList)
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons) rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons, isSSHHostList)
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows) content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
return box.Render(content) 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))) 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 { func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string {
columns := buildColumns(cfg, width, useNerdIcons) columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols)
parts := make([]string, 0, len(columns)) parts := make([]string, 0, len(columns))
for idx, column := range columns { for idx, column := range columns {
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
@ -426,7 +427,7 @@ func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, ba
Render(strings.Join(parts, "")) 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 { if len(pane.Entries) == 0 {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(width). Width(width).
@ -446,7 +447,7 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
entry := pane.Entries[idx] entry := pane.Entries[idx]
isSelected := idx == pane.Cursor && active isSelected := idx == pane.Cursor && active
marked := !entry.IsParent && pane.IsMarked(entry.Path) 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) lines = append(lines, row)
} }
for len(lines) < visibleHeight { for len(lines) < visibleHeight {
@ -459,8 +460,8 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
Render(strings.Join(lines, "\n")) 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 { 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) columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols)
rowBackground := baseBackground rowBackground := baseBackground
switch { switch {
case marked: case marked:
@ -512,7 +513,7 @@ type columnSpec struct {
Value func(entry vfs.Entry, human bool) string 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{} fixed := []columnSpec{}
if cfg.Browser.Columns.Permissions { 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{ fixed = append(fixed, columnSpec{
Key: "size", Key: "size",
Title: "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{ fixed = append(fixed, columnSpec{
Key: "modified", Key: "modified",
Title: "Modified", Title: "Modified",
Width: 11, Width: 11,
MinWidth: 8, MinWidth: 8,
Value: func(entry vfs.Entry, _ bool) string { Value: func(entry vfs.Entry, _ bool) string {
if entry.IsParent {
return ""
}
return vfs.CompactTime(entry.ModifiedAt) return vfs.CompactTime(entry.ModifiedAt)
}, },
}) })

View file

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"path" "path"
"strings" "strings"
@ -27,11 +28,32 @@ func buildSSHHostEntries(store *remote.HostStore, connectedHosts map[string]bool
hosts := store.AllHosts() hosts := store.AllHosts()
entries := make([]vfs.Entry, 0, len(hosts)+1) 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 { for _, h := range hosts {
isConnected := connectedHosts != nil && connectedHosts[h.Name] 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{ entries = append(entries, vfs.Entry{
Name: h.DisplayName(), Name: paddedName + suffix,
Path: "ssh://" + h.Name, Path: "ssh://" + h.Name,
IsDir: true, IsDir: true,
IsRemote: true, IsRemote: true,