Files
HostKeeper/v1/pkg/tui/sftp_browser_tab.go
T
swanadiva 847989df75 refactor: move V1 code into v1/ subdirectory
- git mv cmd/ internal/ pkg/ test/ go.mod go.sum Makefile build.sh docs/ v1/
- Create v1/README.md with V1 documentation
- Update root README for V1 + V2 structure
- V1 still builds (cd v1 && go build ./cmd/hostkeeper) and 105 tests pass
- Root is now clean for V2 development
2026-07-07 11:56:27 +07:00

1038 lines
22 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
renameMode bool
renameInput 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
program *tea.Program
// Security
storagePassword string // master password for encrypted storage
passphraseCallback func() string // callback for SSH key passphrases
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) SetProgram(p *tea.Program) {
t.program = p
}
func (t *SFTPBrowserTab) SetStoragePassword(password string) {
t.storagePassword = password
}
func (t *SFTPBrowserTab) SetPassphraseCallback(cb func() string) {
t.passphraseCallback = cb
}
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)
// Wire up passphrase callback for encrypted keys
if t.passphraseCallback != nil {
cl.SetPassphraseCallback(t.passphraseCallback)
}
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
case "tab", "up", "k", "down", "j":
// Allow navigation and pane switch in filter mode
case "c", "d", "m", "n", "r":
// Allow commands in filter mode
default:
if len(msg.Runes) == 1 {
p.filter += string(msg.Runes[0])
}
return t, nil
}
}
if p.renameMode {
switch msg.String() {
case "esc":
p.renameMode = false
p.renameInput = ""
return t, nil
case "enter":
if p.renameInput != "" {
t.mu.Lock()
entries := p.entries
idx := p.selIdx
cwd := p.cwd
isRemote := t.active == paneRemote
t.mu.Unlock()
if idx >= 0 && idx < len(entries) {
oldPath := path.Join(cwd, entries[idx].Name())
newPath := path.Join(cwd, p.renameInput)
go t.renameItem(oldPath, newPath, isRemote)
}
}
p.renameMode = false
p.renameInput = ""
return t, nil
case "backspace":
if len(p.renameInput) > 0 {
p.renameInput = p.renameInput[:len(p.renameInput)-1]
}
return t, nil
default:
if len(msg.Runes) == 1 {
p.renameInput += 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 "m":
t.mu.Lock()
entries := p.entries
idx := p.selIdx
t.mu.Unlock()
if idx >= 0 && idx < len(entries) {
p.renameMode = true
p.renameInput = entries[idx].Name()
}
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) {
// Get file size for progress bar
var totalSize int64
if srcType == paneRemote {
t.mu.Lock()
client := t.right.sftpClient
t.mu.Unlock()
if client != nil {
if info, err := client.Stat(srcPath); err == nil {
totalSize = info.Size()
}
}
} else {
if info, err := os.Stat(srcPath); err == nil {
totalSize = info.Size()
}
}
t.mu.Lock()
t.transferring = true
t.transferMsg = fmt.Sprintf("Copying %s... 0%%", name)
t.mu.Unlock()
progressFn := func(transferred int64) {
if totalSize <= 0 {
return
}
pct := int(transferred * 100 / totalSize)
barWidth := 20
filled := int(float64(barWidth) * float64(transferred) / float64(totalSize))
if filled > barWidth {
filled = barWidth
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
t.mu.Lock()
t.transferMsg = fmt.Sprintf("Copying %s... [%s] %d%% %s/%s",
name, bar, pct, formatSize(transferred), formatSize(totalSize))
t.mu.Unlock()
}
var err error
if srcType == paneRemote && dstType == paneLocal {
t.mu.Lock()
client := t.right.sftpClient
t.mu.Unlock()
if client != nil {
err = downloadFile(client, srcPath, dstPath, progressFn)
}
} else if srcType == paneLocal && dstType == paneRemote {
t.mu.Lock()
client := t.right.sftpClient
t.mu.Unlock()
if client != nil {
err = uploadFile(client, srcPath, dstPath, progressFn)
}
}
t.mu.Lock()
if err != nil {
t.err = fmt.Errorf("copy %s: %w", name, err)
} else {
t.transferMsg = fmt.Sprintf("Done: %s", name)
}
t.transferring = false
t.mu.Unlock()
// Auto-refresh destination pane (clear cache first)
t.mu.Lock()
if dstType == paneRemote {
delete(t.right.dirCache, t.right.cwd)
} else {
delete(t.left.dirCache, t.left.cwd)
}
t.mu.Unlock()
if dstType == paneRemote {
t.refreshRemote()
} else {
t.refreshLocal()
}
// Trigger re-render in Bubble Tea
if t.program != nil {
t.program.Send(sftpRefreshMsg{})
}
// Clear success message after brief delay
time.Sleep(1 * time.Second)
t.mu.Lock()
t.transferMsg = ""
t.mu.Unlock()
// Trigger re-render again after clearing message
if t.program != nil {
t.program.Send(sftpRefreshMsg{})
}
}
func downloadFile(client *sftp.Client, remotePath, localPath string, progressFn func(int64)) 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()
pw := &progressWriter{w: dst, total: 0, fn: progressFn}
_, err = io.Copy(pw, src)
return err
}
func uploadFile(client *sftp.Client, localPath, remotePath string, progressFn func(int64)) 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()
pw := &progressWriter{w: dst, total: 0, fn: progressFn}
_, err = io.Copy(pw, src)
return err
}
// progressWriter wraps an io.Writer and reports progress via callback
type progressWriter struct {
w io.Writer
total int64
fn func(int64)
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.w.Write(p)
pw.total += int64(n)
if pw.fn != nil {
pw.fn(pw.total)
}
return n, 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()
// Clear cache and refresh
t.mu.Lock()
if isRemote {
delete(t.right.dirCache, t.right.cwd)
} else {
delete(t.left.dirCache, t.left.cwd)
}
t.mu.Unlock()
if isRemote {
t.refreshRemote()
} else {
t.refreshLocal()
}
// Trigger re-render
if t.program != nil {
t.program.Send(sftpRefreshMsg{})
}
}
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
}
// Clear cache and refresh
t.mu.Lock()
delete(t.right.dirCache, cwd)
t.mu.Unlock()
t.refreshRemote()
// Trigger re-render
if t.program != nil {
t.program.Send(sftpRefreshMsg{})
}
}
func (t *SFTPBrowserTab) renameItem(oldPath, newPath string, isRemote bool) {
t.mu.Lock()
t.transferring = true
t.transferMsg = fmt.Sprintf("Renaming %s → %s...", filepath.Base(oldPath), filepath.Base(newPath))
t.mu.Unlock()
var err error
if isRemote {
t.mu.Lock()
client := t.right.sftpClient
t.mu.Unlock()
if client != nil {
err = client.Rename(oldPath, newPath)
}
} else {
err = os.Rename(oldPath, newPath)
}
t.mu.Lock()
if err != nil {
t.err = fmt.Errorf("rename: %w", err)
}
t.transferring = false
t.transferMsg = ""
t.mu.Unlock()
// Clear cache and refresh
t.mu.Lock()
if isRemote {
delete(t.right.dirCache, t.right.cwd)
} else {
delete(t.left.dirCache, t.left.cwd)
}
t.mu.Unlock()
if isRemote {
t.refreshRemote()
} else {
t.refreshLocal()
}
// Trigger re-render
if t.program != nil {
t.program.Send(sftpRefreshMsg{})
}
}
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 M:rename 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))
}
if p.renameMode {
renameLine := "Rename: " + p.renameInput + "_"
if lipgloss.Width(renameLine) > innerW {
renameLine = truncateStr(renameLine, innerW)
}
content.WriteString("\n" + SubtitleStyle.Render(renameLine))
}
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)
}
}