feat: dual-pane SFTP browser (local ↔ remote)
Phase B — Tamagosh-inspired dual-pane file browser: - Left pane: local filesystem (starts at /Users/swanadiva) - Right pane: remote filesystem via SFTP - Tab key switches active pane (green border = active) - Local CWD indicator per pane - File listing with dir/ file size display - Filter mode with / key - Copy files: C key copies from active pane to opposite pane - Delete (D), mkdir (N on remote), Refresh (R) - Esc to close - File size formatting (B/KiB/MiB/GiB) - Progress message during transfers - StylePaneActive (green border) / StylePaneInactive (muted border)
This commit is contained in:
+447
-155
@@ -3,8 +3,10 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -12,80 +14,102 @@ import (
|
||||
|
||||
"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 connectionState int
|
||||
type paneType int
|
||||
|
||||
const (
|
||||
stateConnecting connectionState = iota
|
||||
stateConnected
|
||||
stateError
|
||||
stateDone
|
||||
paneLocal paneType = iota
|
||||
paneRemote
|
||||
)
|
||||
|
||||
// SFTPBrowserTab provides a remote file browser via SFTP
|
||||
// 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
|
||||
|
||||
// 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
|
||||
cwd string
|
||||
|
||||
entries []os.FileInfo
|
||||
selectedIdx int
|
||||
state connectionState
|
||||
left sftpPane
|
||||
right sftpPane
|
||||
active paneType
|
||||
connected bool
|
||||
err error
|
||||
width int
|
||||
height int
|
||||
|
||||
mu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
filterInput textinput.Model
|
||||
|
||||
// search/filter
|
||||
searchMode bool
|
||||
searchInput textinput.Model
|
||||
filterText string
|
||||
transferring bool
|
||||
transferMsg string
|
||||
|
||||
mu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewSFTPBrowserTab creates a new SFTP browser tab
|
||||
func NewSFTPBrowserTab(host *models.Host, dataDir string) *SFTPBrowserTab {
|
||||
si := textinput.New()
|
||||
si.Placeholder = "Filter files..."
|
||||
fi := textinput.New()
|
||||
fi.Placeholder = "Filter..."
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
return &SFTPBrowserTab{
|
||||
host: host,
|
||||
dataDir: dataDir,
|
||||
cwd: "/",
|
||||
state: stateConnecting,
|
||||
searchInput: si,
|
||||
active: paneRemote,
|
||||
left: sftpPane{
|
||||
cwd: home,
|
||||
localRoot: home,
|
||||
},
|
||||
right: sftpPane{
|
||||
cwd: "/",
|
||||
},
|
||||
filterInput: fi,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SFTPBrowserTab) Name() string {
|
||||
return "SFTP: " + t.host.Name
|
||||
}
|
||||
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()
|
||||
state := t.state
|
||||
conn := t.connected
|
||||
err := t.err
|
||||
t.mu.Unlock()
|
||||
|
||||
if state == stateError {
|
||||
return sftpDoneMsg{err: err}
|
||||
if err != nil {
|
||||
return sftpRefreshMsg{err: err}
|
||||
}
|
||||
if state == stateConnected {
|
||||
return sftpReadyMsg{}
|
||||
if conn {
|
||||
return sftpRefreshMsg{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -97,8 +121,7 @@ func (t *SFTPBrowserTab) connect() {
|
||||
ctx := context.Background()
|
||||
if err := cl.Connect(ctx); err != nil {
|
||||
t.mu.Lock()
|
||||
t.state = stateError
|
||||
t.err = fmt.Errorf("SSH connect failed: %w", err)
|
||||
t.err = fmt.Errorf("SSH connect: %w", err)
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -107,45 +130,75 @@ func (t *SFTPBrowserTab) connect() {
|
||||
if err != nil {
|
||||
cl.Close()
|
||||
t.mu.Lock()
|
||||
t.state = stateError
|
||||
t.err = fmt.Errorf("SFTP init failed: %w", err)
|
||||
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()
|
||||
|
||||
if err := t.refresh(); err != nil {
|
||||
cl.Close()
|
||||
sftpClient.Close()
|
||||
t.mu.Lock()
|
||||
t.state = stateError
|
||||
t.err = err
|
||||
t.mu.Unlock()
|
||||
t.refreshRemote()
|
||||
}
|
||||
|
||||
func (t *SFTPBrowserTab) refreshLocal() {
|
||||
t.mu.Lock()
|
||||
p := &t.left
|
||||
p.entries = nil
|
||||
t.mu.Unlock()
|
||||
|
||||
entries, err := os.ReadDir(p.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()
|
||||
t.state = stateConnected
|
||||
p.entries = infos
|
||||
if p.selIdx >= len(infos) {
|
||||
p.selIdx = 0
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *SFTPBrowserTab) refresh() error {
|
||||
func (t *SFTPBrowserTab) refreshRemote() {
|
||||
t.mu.Lock()
|
||||
sftpClient := t.sftp
|
||||
cwd := t.cwd
|
||||
p := &t.right
|
||||
sftpClient := p.sftpClient
|
||||
cwd := p.cwd
|
||||
t.mu.Unlock()
|
||||
|
||||
if sftpClient == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := sftpClient.ReadDir(cwd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dir %s: %w", cwd, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
@@ -156,12 +209,18 @@ func (t *SFTPBrowserTab) refresh() error {
|
||||
})
|
||||
|
||||
t.mu.Lock()
|
||||
t.entries = entries
|
||||
if t.selectedIdx >= len(entries) {
|
||||
t.selectedIdx = 0
|
||||
p.entries = entries
|
||||
if p.selIdx >= len(entries) {
|
||||
p.selIdx = 0
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -171,169 +230,406 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||
t.width = msg.Width
|
||||
t.height = msg.Height
|
||||
t.mu.Unlock()
|
||||
|
||||
case sftpReadyMsg:
|
||||
return t, nil
|
||||
|
||||
case sftpDoneMsg:
|
||||
t.mu.Lock()
|
||||
t.state = stateDone
|
||||
if msg.err != nil {
|
||||
t.err = msg.err
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return t, nil
|
||||
|
||||
case sftpRefreshMsg:
|
||||
if err := t.refresh(); err != nil {
|
||||
if msg.err != nil {
|
||||
t.mu.Lock()
|
||||
t.err = err
|
||||
t.err = msg.err
|
||||
t.mu.Unlock()
|
||||
return t, nil
|
||||
}
|
||||
// Re-render only
|
||||
return t, t.poll
|
||||
|
||||
case tea.KeyMsg:
|
||||
if t.searchMode {
|
||||
p := t.activePane()
|
||||
|
||||
if p.filterMode {
|
||||
switch msg.String() {
|
||||
case "esc", "enter":
|
||||
t.searchMode = false
|
||||
t.filterText = ""
|
||||
p.filterMode = false
|
||||
p.filter = ""
|
||||
return t, nil
|
||||
case "backspace":
|
||||
t.searchInput.Update(msg)
|
||||
t.filterText = t.searchInput.Value()
|
||||
if len(p.filter) > 0 {
|
||||
p.filter = p.filter[:len(p.filter)-1]
|
||||
}
|
||||
return t, nil
|
||||
default:
|
||||
t.searchInput.Update(msg)
|
||||
t.filterText = t.searchInput.Value()
|
||||
if len(msg.Runes) == 1 {
|
||||
p.filter += string(msg.Runes[0])
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "/":
|
||||
t.searchMode = true
|
||||
t.searchInput.Reset()
|
||||
t.filterText = ""
|
||||
t.searchInput.Focus()
|
||||
case "tab":
|
||||
t.mu.Lock()
|
||||
if t.active == paneLocal {
|
||||
t.active = paneRemote
|
||||
} else {
|
||||
t.active = paneLocal
|
||||
}
|
||||
t.mu.Unlock()
|
||||
return t, nil
|
||||
|
||||
case "esc":
|
||||
return t, func() tea.Msg { return closeFormMsg{} }
|
||||
|
||||
case "up", "k":
|
||||
t.mu.Lock()
|
||||
if t.selectedIdx > 0 {
|
||||
t.selectedIdx--
|
||||
if p.selIdx > 0 {
|
||||
p.selIdx--
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
case "down", "j":
|
||||
t.mu.Lock()
|
||||
if t.selectedIdx < len(t.entries)-1 {
|
||||
t.selectedIdx++
|
||||
if p.selIdx < len(p.entries)-1 {
|
||||
p.selIdx++
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
case "enter":
|
||||
case "enter", "right":
|
||||
t.mu.Lock()
|
||||
entries := t.entries
|
||||
idx := t.selectedIdx
|
||||
entries := p.entries
|
||||
idx := p.selIdx
|
||||
t.mu.Unlock()
|
||||
|
||||
if idx < 0 || idx >= len(entries) {
|
||||
return t, nil
|
||||
}
|
||||
entry := entries[idx]
|
||||
|
||||
if entry.IsDir() {
|
||||
if idx >= 0 && idx < len(entries) && entries[idx].IsDir() {
|
||||
t.mu.Lock()
|
||||
t.cwd = path.Join(t.cwd, entry.Name())
|
||||
t.selectedIdx = 0
|
||||
p.cwd = path.Join(p.cwd, entries[idx].Name())
|
||||
p.selIdx = 0
|
||||
t.mu.Unlock()
|
||||
if err := t.refresh(); err != nil {
|
||||
t.mu.Lock()
|
||||
t.err = err
|
||||
t.mu.Unlock()
|
||||
if t.active == paneRemote {
|
||||
go t.refreshRemote()
|
||||
} else {
|
||||
go t.refreshLocal()
|
||||
}
|
||||
}
|
||||
|
||||
case "backspace":
|
||||
case "left", "backspace":
|
||||
t.mu.Lock()
|
||||
parent := path.Dir(t.cwd)
|
||||
parent := path.Dir(p.cwd)
|
||||
if parent == "." {
|
||||
parent = "/"
|
||||
}
|
||||
t.cwd = parent
|
||||
t.selectedIdx = 0
|
||||
if (t.active == paneLocal && parent != p.cwd) || (t.active == paneRemote && parent != p.cwd) {
|
||||
p.cwd = parent
|
||||
p.selIdx = 0
|
||||
}
|
||||
t.mu.Unlock()
|
||||
if err := t.refresh(); err != nil {
|
||||
t.mu.Lock()
|
||||
t.err = err
|
||||
t.mu.Unlock()
|
||||
if t.active == paneRemote {
|
||||
go t.refreshRemote()
|
||||
} else {
|
||||
go t.refreshLocal()
|
||||
}
|
||||
|
||||
case "/":
|
||||
p.filterMode = true
|
||||
p.filter = ""
|
||||
|
||||
case "r":
|
||||
if err := t.refresh(); err != nil {
|
||||
t.mu.Lock()
|
||||
t.err = err
|
||||
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()
|
||||
|
||||
switch t.state {
|
||||
case stateConnecting:
|
||||
return HighlightStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name))
|
||||
case stateError:
|
||||
if !t.connected && t.err != nil {
|
||||
return fmt.Sprintf("%s\n\n%s",
|
||||
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)),
|
||||
SubtitleStyle.Render("Press Esc to close this tab"))
|
||||
case stateDone:
|
||||
return fmt.Sprintf("%s\n\n%s",
|
||||
SubtitleStyle.Render("SFTP session ended"),
|
||||
SubtitleStyle.Render("Press Esc to close this tab"))
|
||||
SubtitleStyle.Render("Press Esc to close"))
|
||||
}
|
||||
|
||||
if !t.connected {
|
||||
return SubtitleStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name))
|
||||
}
|
||||
|
||||
contentW := t.width
|
||||
if contentW < 40 {
|
||||
contentW = 80
|
||||
}
|
||||
halfW := contentW/2 - 1
|
||||
if halfW < 20 {
|
||||
halfW = 20
|
||||
}
|
||||
|
||||
leftView := t.renderPane(&t.left, paneLocal, halfW)
|
||||
rightView := t.renderPane(&t.right, paneRemote, halfW)
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(SubtitleStyle.Render(fmt.Sprintf(" %s", t.cwd)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftView, rightView))
|
||||
|
||||
if t.err != nil {
|
||||
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
||||
}
|
||||
|
||||
if t.sftp == nil {
|
||||
b.WriteString(SubtitleStyle.Render("Not connected."))
|
||||
return b.String()
|
||||
if t.transferring && t.transferMsg != "" {
|
||||
b.WriteString("\n" + InfoStyle.Render(t.transferMsg))
|
||||
}
|
||||
|
||||
b.WriteString("\n" + SubtitleStyle.Render(
|
||||
"Tab:switch ↑↓:nav /:filter C:copy D:delete N:mkdir R:refresh Esc:close"))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t *SFTPBrowserTab) renderPane(p *sftpPane, pt paneType, maxW int) string {
|
||||
isActive := t.active == pt
|
||||
|
||||
title := "Local"
|
||||
if pt == paneRemote {
|
||||
title = "Remote"
|
||||
}
|
||||
|
||||
paneStyle := StylePaneInactive
|
||||
if isActive {
|
||||
paneStyle = StylePaneActive
|
||||
}
|
||||
|
||||
// Title
|
||||
titleBar := fmt.Sprintf(" %s ", title)
|
||||
if isActive {
|
||||
titleBar = StatusBarStyle.Render(" " + title + " ")
|
||||
} else {
|
||||
titleBar = SubtitleStyle.Render(" " + title + " ")
|
||||
}
|
||||
|
||||
// CWD
|
||||
cwdStr := p.cwd
|
||||
if len(cwdStr) > maxW-4 {
|
||||
cwdStr = "..." + cwdStr[len(cwdStr)-maxW+7:]
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString(SubtitleStyle.Render(fmt.Sprintf(" %s", cwdStr)))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Filter
|
||||
var visible []os.FileInfo
|
||||
if t.searchMode || t.filterText != "" {
|
||||
lower := strings.ToLower(t.filterText)
|
||||
for _, e := range t.entries {
|
||||
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 = t.entries
|
||||
visible = p.entries
|
||||
}
|
||||
|
||||
if len(visible) == 0 {
|
||||
b.WriteString(SubtitleStyle.Render(" (empty)"))
|
||||
content.WriteString(SubtitleStyle.Render(" (empty)"))
|
||||
} else {
|
||||
for i, entry := range visible {
|
||||
maxDisplay := t.height - 6
|
||||
if maxDisplay < 5 {
|
||||
maxDisplay = 5
|
||||
}
|
||||
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
|
||||
|
||||
@@ -344,26 +640,27 @@ func (t *SFTPBrowserTab) View() string {
|
||||
line = fmt.Sprintf(" %s (%s)", name, size)
|
||||
}
|
||||
|
||||
if i == t.selectedIdx {
|
||||
b.WriteString(SelectedStyle.Render(line))
|
||||
} else {
|
||||
style := HostDetailStyle
|
||||
if entry.IsDir() {
|
||||
style = InfoStyle
|
||||
}
|
||||
b.WriteString(style.Render(line))
|
||||
if len(line) > maxW {
|
||||
line = line[:maxW-1] + "…"
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
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 t.searchMode {
|
||||
b.WriteString("\n" + SubtitleStyle.Render("Filter: ") + t.searchInput.View())
|
||||
} else {
|
||||
b.WriteString("\n" + SubtitleStyle.Render("/:filter r:refresh Esc:close"))
|
||||
if p.filterMode {
|
||||
content.WriteString("\n" + SubtitleStyle.Render("Filter: "+p.filter+"_"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
inner := titleBar + "\n" + content.String()
|
||||
return paneStyle.Width(maxW).Render(inner)
|
||||
}
|
||||
|
||||
func (t *SFTPBrowserTab) Close() {
|
||||
@@ -377,6 +674,10 @@ func (t *SFTPBrowserTab) Close() {
|
||||
})
|
||||
}
|
||||
|
||||
type sftpRefreshMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func formatSize(size int64) string {
|
||||
switch {
|
||||
case size >= 1<<30:
|
||||
@@ -389,12 +690,3 @@ func formatSize(size int64) string {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
}
|
||||
|
||||
// Message types for SFTP tab
|
||||
type sftpReadyMsg struct{}
|
||||
|
||||
type sftpDoneMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type sftpRefreshMsg struct{}
|
||||
|
||||
@@ -45,3 +45,9 @@ func TabBarWidth(totalWidth int) int {
|
||||
}
|
||||
return totalWidth - 2
|
||||
}
|
||||
|
||||
// Pane styles for dual-pane layout
|
||||
var (
|
||||
StylePaneActive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbGreen).Padding(0, 1)
|
||||
StylePaneInactive = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(gbBorder).Padding(0, 1)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user