807 lines
17 KiB
Go
807 lines
17 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/pkg/sftp"
|
|
|
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
|
sshclient "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh"
|
|
)
|
|
|
|
type paneType int
|
|
|
|
const (
|
|
paneLocal paneType = iota
|
|
paneRemote
|
|
)
|
|
|
|
// sftpPane holds state for one side of the dual-pane browser
|
|
type sftpPane struct {
|
|
cwd string
|
|
entries []os.FileInfo
|
|
selIdx int
|
|
scroll int
|
|
|
|
filterMode bool
|
|
filter string
|
|
|
|
// Directory listing cache: path → entries
|
|
dirCache map[string][]os.FileInfo
|
|
|
|
// Remote only
|
|
sftpClient *sftp.Client
|
|
|
|
// Local only
|
|
localRoot string
|
|
}
|
|
|
|
// SFTPBrowserTab provides a dual-pane (local ↔ remote) SFTP file browser
|
|
type SFTPBrowserTab struct {
|
|
host *models.Host
|
|
dataDir string
|
|
|
|
sshClient *sshclient.Client
|
|
sftp *sftp.Client
|
|
|
|
left sftpPane
|
|
right sftpPane
|
|
active paneType
|
|
connected bool
|
|
err error
|
|
width int
|
|
height int
|
|
|
|
filterInput textinput.Model
|
|
|
|
transferring bool
|
|
transferMsg string
|
|
|
|
mu sync.Mutex
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func NewSFTPBrowserTab(host *models.Host, dataDir string) *SFTPBrowserTab {
|
|
fi := textinput.New()
|
|
fi.Placeholder = "Filter..."
|
|
|
|
home, _ := os.UserHomeDir()
|
|
|
|
return &SFTPBrowserTab{
|
|
host: host,
|
|
dataDir: dataDir,
|
|
active: paneLocal,
|
|
left: sftpPane{
|
|
cwd: home,
|
|
localRoot: home,
|
|
dirCache: make(map[string][]os.FileInfo),
|
|
},
|
|
right: sftpPane{
|
|
cwd: "/",
|
|
dirCache: make(map[string][]os.FileInfo),
|
|
},
|
|
filterInput: fi,
|
|
}
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) Name() string { return "SFTP: " + t.host.Name }
|
|
|
|
func (t *SFTPBrowserTab) Init() tea.Cmd {
|
|
go t.connect()
|
|
go t.refreshLocal()
|
|
return t.poll
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) poll() tea.Msg {
|
|
t.mu.Lock()
|
|
conn := t.connected
|
|
err := t.err
|
|
t.mu.Unlock()
|
|
|
|
if err != nil {
|
|
return sftpRefreshMsg{err: err}
|
|
}
|
|
if conn {
|
|
return sftpRefreshMsg{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) connect() {
|
|
timeout := 30 * time.Second
|
|
cl := sshclient.NewClient(t.host, timeout)
|
|
|
|
ctx := context.Background()
|
|
if err := cl.Connect(ctx); err != nil {
|
|
t.mu.Lock()
|
|
t.err = fmt.Errorf("SSH connect: %w", err)
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
sftpClient, err := sftp.NewClient(cl.GetClient())
|
|
if err != nil {
|
|
cl.Close()
|
|
t.mu.Lock()
|
|
t.err = fmt.Errorf("SFTP init: %w", err)
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
home, err := sftpClient.Getwd()
|
|
if err != nil {
|
|
home = "/"
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.sshClient = cl
|
|
t.sftp = sftpClient
|
|
t.right.sftpClient = sftpClient
|
|
t.right.cwd = home
|
|
t.connected = true
|
|
t.mu.Unlock()
|
|
|
|
t.refreshRemote()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) refreshLocal() {
|
|
t.mu.Lock()
|
|
p := &t.left
|
|
cwd := p.cwd
|
|
t.mu.Unlock()
|
|
|
|
// Check cache first
|
|
t.mu.Lock()
|
|
if cached, ok := p.dirCache[cwd]; ok {
|
|
p.entries = cached
|
|
if p.selIdx >= len(cached) {
|
|
p.selIdx = 0
|
|
}
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
entries, err := os.ReadDir(cwd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var infos []os.FileInfo
|
|
for _, e := range entries {
|
|
info, err := e.Info()
|
|
if err == nil {
|
|
infos = append(infos, info)
|
|
}
|
|
}
|
|
|
|
sort.Slice(infos, func(i, j int) bool {
|
|
if infos[i].IsDir() != infos[j].IsDir() {
|
|
return infos[i].IsDir()
|
|
}
|
|
return strings.ToLower(infos[i].Name()) < strings.ToLower(infos[j].Name())
|
|
})
|
|
|
|
t.mu.Lock()
|
|
p.dirCache[cwd] = infos
|
|
p.entries = infos
|
|
if p.selIdx >= len(infos) {
|
|
p.selIdx = 0
|
|
}
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) refreshRemote() {
|
|
t.mu.Lock()
|
|
p := &t.right
|
|
sftpClient := p.sftpClient
|
|
cwd := p.cwd
|
|
t.mu.Unlock()
|
|
|
|
if sftpClient == nil {
|
|
return
|
|
}
|
|
|
|
// Check cache first
|
|
t.mu.Lock()
|
|
if cached, ok := p.dirCache[cwd]; ok {
|
|
p.entries = cached
|
|
if p.selIdx >= len(cached) {
|
|
p.selIdx = 0
|
|
}
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
entries, err := sftpClient.ReadDir(cwd)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].IsDir() != entries[j].IsDir() {
|
|
return entries[i].IsDir()
|
|
}
|
|
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
|
})
|
|
|
|
t.mu.Lock()
|
|
p.dirCache[cwd] = entries
|
|
p.entries = entries
|
|
if p.selIdx >= len(entries) {
|
|
p.selIdx = 0
|
|
}
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) activePane() *sftpPane {
|
|
if t.active == paneLocal {
|
|
return &t.left
|
|
}
|
|
return &t.right
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
t.mu.Lock()
|
|
t.width = msg.Width
|
|
t.height = msg.Height
|
|
t.mu.Unlock()
|
|
return t, nil
|
|
|
|
case sftpRefreshMsg:
|
|
if msg.err != nil {
|
|
t.mu.Lock()
|
|
t.err = msg.err
|
|
t.mu.Unlock()
|
|
return t, nil
|
|
}
|
|
// Re-render only
|
|
return t, t.poll
|
|
|
|
case tea.KeyMsg:
|
|
p := t.activePane()
|
|
|
|
if p.filterMode {
|
|
switch msg.String() {
|
|
case "esc", "enter":
|
|
p.filterMode = false
|
|
p.filter = ""
|
|
return t, nil
|
|
case "backspace":
|
|
if len(p.filter) > 0 {
|
|
p.filter = p.filter[:len(p.filter)-1]
|
|
}
|
|
return t, nil
|
|
default:
|
|
if len(msg.Runes) == 1 {
|
|
p.filter += string(msg.Runes[0])
|
|
}
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "tab":
|
|
t.mu.Lock()
|
|
if t.active == paneLocal {
|
|
t.active = paneRemote
|
|
} else {
|
|
t.active = paneLocal
|
|
}
|
|
t.mu.Unlock()
|
|
return t, nil
|
|
|
|
case "up", "k":
|
|
t.mu.Lock()
|
|
if p.selIdx > 0 {
|
|
p.selIdx--
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
case "down", "j":
|
|
t.mu.Lock()
|
|
if p.selIdx < len(p.entries)-1 {
|
|
p.selIdx++
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
case "enter", "right":
|
|
t.mu.Lock()
|
|
entries := p.entries
|
|
idx := p.selIdx
|
|
t.mu.Unlock()
|
|
|
|
if idx >= 0 && idx < len(entries) && entries[idx].IsDir() {
|
|
t.mu.Lock()
|
|
p.cwd = path.Join(p.cwd, entries[idx].Name())
|
|
p.selIdx = 0
|
|
t.mu.Unlock()
|
|
if t.active == paneRemote {
|
|
go t.refreshRemote()
|
|
} else {
|
|
go t.refreshLocal()
|
|
}
|
|
}
|
|
|
|
case "left", "backspace":
|
|
t.mu.Lock()
|
|
parent := path.Dir(p.cwd)
|
|
if parent == "." {
|
|
parent = "/"
|
|
}
|
|
if (t.active == paneLocal && parent != p.cwd) || (t.active == paneRemote && parent != p.cwd) {
|
|
p.cwd = parent
|
|
p.selIdx = 0
|
|
}
|
|
t.mu.Unlock()
|
|
if t.active == paneRemote {
|
|
go t.refreshRemote()
|
|
} else {
|
|
go t.refreshLocal()
|
|
}
|
|
|
|
case "/":
|
|
p.filterMode = true
|
|
p.filter = ""
|
|
|
|
case "r":
|
|
// Force refresh: clear cache then re-read
|
|
t.mu.Lock()
|
|
if t.active == paneRemote {
|
|
delete(t.right.dirCache, t.right.cwd)
|
|
} else {
|
|
delete(t.left.dirCache, t.left.cwd)
|
|
}
|
|
t.mu.Unlock()
|
|
if t.active == paneRemote {
|
|
go t.refreshRemote()
|
|
} else {
|
|
go t.refreshLocal()
|
|
}
|
|
|
|
case "c":
|
|
t.mu.Lock()
|
|
src, dst := t.left, t.right
|
|
srcType, dstType := paneLocal, paneRemote
|
|
if t.active == paneRemote {
|
|
src, dst = t.right, t.left
|
|
srcType, dstType = paneRemote, paneLocal
|
|
}
|
|
idx := src.selIdx
|
|
srcEntries := src.entries
|
|
srcCwd := src.cwd
|
|
dstCwd := dst.cwd
|
|
t.mu.Unlock()
|
|
|
|
if idx >= 0 && idx < len(srcEntries) && !srcEntries[idx].IsDir() {
|
|
srcPath := path.Join(srcCwd, srcEntries[idx].Name())
|
|
dstPath := path.Join(dstCwd, srcEntries[idx].Name())
|
|
|
|
go t.copyFile(srcType, dstType, srcPath, dstPath, srcEntries[idx].Name())
|
|
}
|
|
|
|
case "d":
|
|
t.mu.Lock()
|
|
p := t.activePane()
|
|
entries := p.entries
|
|
idx := p.selIdx
|
|
cwd := p.cwd
|
|
isRemote := t.active == paneRemote
|
|
t.mu.Unlock()
|
|
|
|
if idx >= 0 && idx < len(entries) {
|
|
fullPath := path.Join(cwd, entries[idx].Name())
|
|
go t.deleteItem(fullPath, entries[idx].IsDir(), isRemote)
|
|
}
|
|
|
|
case "n":
|
|
if t.active == paneRemote {
|
|
go t.mkdirRemote()
|
|
}
|
|
|
|
case "esc":
|
|
return t, func() tea.Msg { return closeFormMsg{} }
|
|
|
|
default:
|
|
}
|
|
}
|
|
|
|
return t, t.poll
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) copyFile(srcType, dstType paneType, srcPath, dstPath, name string) {
|
|
t.mu.Lock()
|
|
t.transferring = true
|
|
t.transferMsg = fmt.Sprintf("Copying %s...", name)
|
|
t.mu.Unlock()
|
|
|
|
var err error
|
|
if srcType == paneRemote && dstType == paneLocal {
|
|
// Download
|
|
t.mu.Lock()
|
|
client := t.right.sftpClient
|
|
t.mu.Unlock()
|
|
if client != nil {
|
|
err = downloadFile(client, srcPath, dstPath)
|
|
}
|
|
} else if srcType == paneLocal && dstType == paneRemote {
|
|
// Upload
|
|
t.mu.Lock()
|
|
client := t.right.sftpClient
|
|
t.mu.Unlock()
|
|
if client != nil {
|
|
err = uploadFile(client, srcPath, dstPath)
|
|
}
|
|
}
|
|
|
|
t.mu.Lock()
|
|
if err != nil {
|
|
t.err = fmt.Errorf("copy %s: %w", name, err)
|
|
} else {
|
|
t.transferMsg = ""
|
|
}
|
|
t.transferring = false
|
|
t.mu.Unlock()
|
|
|
|
if dstType == paneRemote {
|
|
t.refreshRemote()
|
|
} else {
|
|
t.refreshLocal()
|
|
}
|
|
}
|
|
|
|
func downloadFile(client *sftp.Client, remotePath, localPath string) error {
|
|
src, err := client.Open(remotePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
|
|
dst, err := os.Create(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dst.Close()
|
|
|
|
_, err = io.Copy(dst, src)
|
|
return err
|
|
}
|
|
|
|
func uploadFile(client *sftp.Client, localPath, remotePath string) error {
|
|
src, err := os.Open(localPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
|
|
dst, err := client.Create(remotePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dst.Close()
|
|
|
|
_, err = io.Copy(dst, src)
|
|
return err
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) deleteItem(fullPath string, isDir bool, isRemote bool) {
|
|
t.mu.Lock()
|
|
t.transferring = true
|
|
t.transferMsg = fmt.Sprintf("Deleting %s...", filepath.Base(fullPath))
|
|
t.mu.Unlock()
|
|
|
|
var err error
|
|
if isRemote {
|
|
t.mu.Lock()
|
|
client := t.right.sftpClient
|
|
t.mu.Unlock()
|
|
if client != nil {
|
|
if isDir {
|
|
err = client.RemoveDirectory(fullPath)
|
|
} else {
|
|
err = client.Remove(fullPath)
|
|
}
|
|
}
|
|
} else {
|
|
if isDir {
|
|
err = os.RemoveAll(fullPath)
|
|
} else {
|
|
err = os.Remove(fullPath)
|
|
}
|
|
}
|
|
|
|
t.mu.Lock()
|
|
if err != nil {
|
|
t.err = fmt.Errorf("delete: %w", err)
|
|
}
|
|
t.transferring = false
|
|
t.transferMsg = ""
|
|
t.mu.Unlock()
|
|
|
|
if isRemote {
|
|
go t.refreshRemote()
|
|
} else {
|
|
go t.refreshLocal()
|
|
}
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) mkdirRemote() {
|
|
t.mu.Lock()
|
|
client := t.right.sftpClient
|
|
cwd := t.right.cwd
|
|
t.mu.Unlock()
|
|
|
|
if client == nil {
|
|
return
|
|
}
|
|
|
|
name := fmt.Sprintf("new-dir-%d", time.Now().Unix())
|
|
if err := client.Mkdir(path.Join(cwd, name)); err != nil {
|
|
t.mu.Lock()
|
|
t.err = fmt.Errorf("mkdir: %w", err)
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
t.refreshRemote()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) View() string {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if !t.connected && t.err != nil {
|
|
var b strings.Builder
|
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
|
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err))))
|
|
b.WriteString("\n\n")
|
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
|
SubtitleStyle.Render("Press Esc to close")))
|
|
return b.String()
|
|
}
|
|
|
|
if !t.connected {
|
|
return lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
|
SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name)))
|
|
}
|
|
|
|
// Responsive pane layout: stack vertically on narrow terminals
|
|
// Each pane renders at maxW+2 total (maxW content + 2 border chars)
|
|
var leftView, rightView string
|
|
var indicator string
|
|
if t.width < 50 {
|
|
// Only show active pane in stacked mode — Tab switches between them
|
|
paneW := t.width - 2
|
|
if paneW < 20 {
|
|
paneW = 20
|
|
}
|
|
// Pane indicator: [Local] Remote or Local [Remote]
|
|
var indicatorParts []string
|
|
for _, name := range []string{"Local", "Remote"} {
|
|
pt := paneLocal
|
|
if name == "Remote" {
|
|
pt = paneRemote
|
|
}
|
|
if t.active == pt {
|
|
indicatorParts = append(indicatorParts, StatusBarStyle.Render(" "+name+" "))
|
|
} else {
|
|
indicatorParts = append(indicatorParts, SubtitleStyle.Render(" "+name+" "))
|
|
}
|
|
}
|
|
indicator = lipgloss.JoinHorizontal(lipgloss.Top, indicatorParts[0], SubtitleStyle.Render(" "), indicatorParts[1])
|
|
|
|
// Height: t.height - 1(title) - 1(indicator) - 2(border) - 2(footer) = t.height - 6
|
|
paneH := t.height - 6
|
|
if paneH < 5 {
|
|
paneH = 5
|
|
}
|
|
|
|
if t.active == paneLocal {
|
|
leftView = t.renderPane(&t.left, paneLocal, paneW, paneH)
|
|
} else {
|
|
leftView = t.renderPane(&t.right, paneRemote, paneW, paneH)
|
|
}
|
|
} else {
|
|
// Side by side — reserve 2 chars border per pane
|
|
halfW := t.width/2 - 2
|
|
if halfW < 20 {
|
|
halfW = 20
|
|
}
|
|
// Height: t.height - 1(title) - 2(border) - 2(footer) = t.height - 5
|
|
paneH := t.height - 5
|
|
if paneH < 5 {
|
|
paneH = 5
|
|
}
|
|
leftView = t.renderPane(&t.left, paneLocal, halfW, paneH)
|
|
rightView = t.renderPane(&t.right, paneRemote, halfW, paneH)
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
|
AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname))))
|
|
b.WriteString("\n")
|
|
|
|
if t.width < 50 {
|
|
b.WriteString(lipgloss.PlaceHorizontal(t.width, lipgloss.Center, indicator))
|
|
b.WriteString("\n")
|
|
b.WriteString(leftView)
|
|
} else {
|
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
|
}
|
|
|
|
if t.err != nil {
|
|
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
|
ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))))
|
|
}
|
|
|
|
if t.transferring && t.transferMsg != "" {
|
|
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center,
|
|
InfoStyle.Render(t.transferMsg)))
|
|
}
|
|
|
|
// Footer (wrapped to terminal width)
|
|
footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav Enter/→:open ←/Backspace:up /:filter C:transfer D:delete N:mkdir R:refresh Esc:close"
|
|
footerWrapped := wrapFooter(footerText, t.width)
|
|
for _, line := range strings.Split(footerWrapped, "\n") {
|
|
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, SubtitleStyle.Render(line)))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) renderPane(p *sftpPane, pt paneType, maxW, maxH int) string {
|
|
isActive := t.active == pt
|
|
|
|
title := "Local"
|
|
if pt == paneRemote {
|
|
title = "Remote"
|
|
}
|
|
|
|
paneStyle := StylePaneInactive
|
|
if isActive {
|
|
paneStyle = StylePaneActive
|
|
}
|
|
|
|
// Inner content width: Width(maxW) sets content area inside border
|
|
// Padding(0,1) takes 2 chars → inner text = maxW - 2
|
|
innerW := maxW - 2
|
|
if innerW < 5 {
|
|
innerW = 5
|
|
}
|
|
|
|
// Title
|
|
titleBar := fmt.Sprintf(" %s ", title)
|
|
if isActive {
|
|
titleBar = StatusBarStyle.Render(" " + title + " ")
|
|
} else {
|
|
titleBar = SubtitleStyle.Render(" " + title + " ")
|
|
}
|
|
|
|
// CWD (truncated with proper visual width)
|
|
cwdDisplay := fmt.Sprintf(" %s", p.cwd)
|
|
if lipgloss.Width(cwdDisplay) > innerW {
|
|
cwdDisplay = truncateStr(cwdDisplay, innerW)
|
|
}
|
|
|
|
var content strings.Builder
|
|
content.WriteString(SubtitleStyle.Render(cwdDisplay))
|
|
content.WriteString("\n\n")
|
|
|
|
// Filter
|
|
var visible []os.FileInfo
|
|
if p.filter != "" {
|
|
lower := strings.ToLower(p.filter)
|
|
for _, e := range p.entries {
|
|
if strings.Contains(strings.ToLower(e.Name()), lower) {
|
|
visible = append(visible, e)
|
|
}
|
|
}
|
|
} else {
|
|
visible = p.entries
|
|
}
|
|
|
|
if len(visible) == 0 {
|
|
content.WriteString(SubtitleStyle.Render(" (empty)"))
|
|
} else {
|
|
maxDisplay := maxH - 4
|
|
if maxDisplay < 1 {
|
|
maxDisplay = 1
|
|
}
|
|
start := 0
|
|
if p.selIdx >= maxDisplay {
|
|
start = p.selIdx - maxDisplay + 1
|
|
}
|
|
end := start + maxDisplay
|
|
if end > len(visible) {
|
|
end = len(visible)
|
|
}
|
|
|
|
for i := start; i < end; i++ {
|
|
entry := visible[i]
|
|
name := entry.Name()
|
|
var line string
|
|
|
|
if entry.IsDir() {
|
|
line = fmt.Sprintf(" %s/", name)
|
|
} else if innerW < 30 {
|
|
line = fmt.Sprintf(" %s", name)
|
|
} else {
|
|
size := formatSize(entry.Size())
|
|
line = fmt.Sprintf(" %s (%s)", name, size)
|
|
}
|
|
|
|
// Truncate — account for SelectedStyle Padding(0,1) = 2 extra chars
|
|
lineMaxW := innerW
|
|
if i == p.selIdx {
|
|
lineMaxW = innerW - 2
|
|
}
|
|
if lipgloss.Width(line) > lineMaxW {
|
|
line = truncateStr(line, lineMaxW)
|
|
}
|
|
|
|
if i == p.selIdx {
|
|
content.WriteString(SelectedStyle.Render(line))
|
|
} else if entry.IsDir() {
|
|
content.WriteString(InfoStyle.Render(line))
|
|
} else {
|
|
content.WriteString(HostDetailStyle.Render(line))
|
|
}
|
|
content.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if p.filterMode {
|
|
filterLine := "Filter: " + p.filter + "_"
|
|
if lipgloss.Width(filterLine) > innerW {
|
|
filterLine = truncateStr(filterLine, innerW)
|
|
}
|
|
content.WriteString("\n" + SubtitleStyle.Render(filterLine))
|
|
}
|
|
|
|
inner := titleBar + "\n" + content.String()
|
|
return paneStyle.Width(maxW).Height(maxH).Render(inner)
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) Close() {
|
|
t.closeOnce.Do(func() {
|
|
if t.sftp != nil {
|
|
t.sftp.Close()
|
|
}
|
|
if t.sshClient != nil {
|
|
t.sshClient.Close()
|
|
}
|
|
})
|
|
}
|
|
|
|
type sftpRefreshMsg struct {
|
|
err error
|
|
}
|
|
|
|
func formatSize(size int64) string {
|
|
switch {
|
|
case size >= 1<<30:
|
|
return fmt.Sprintf("%.1f GiB", float64(size)/float64(1<<30))
|
|
case size >= 1<<20:
|
|
return fmt.Sprintf("%.1f MiB", float64(size)/float64(1<<20))
|
|
case size >= 1<<10:
|
|
return fmt.Sprintf("%.1f KiB", float64(size)/float64(1<<10))
|
|
default:
|
|
return fmt.Sprintf("%d B", size)
|
|
}
|
|
}
|