cursor: move up on delete, hide size/modified columns in SSH host list, parent '..' empty modified, SSH keepalive 30s
This commit is contained in:
parent
278b90e5bd
commit
8589187a10
4 changed files with 107 additions and 19 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue