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:
swanadiva
2026-06-23 15:38:48 +07:00
parent 7b06793002
commit c957a97cdb
2 changed files with 453 additions and 155 deletions
+441 -149
View File
@@ -3,8 +3,10 @@ package tui
import ( import (
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"path" "path"
"path/filepath"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -12,80 +14,102 @@ import (
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/pkg/sftp" "github.com/pkg/sftp"
"git.tukangketik.id/swanadiva/hostkeeper/internal/models" "git.tukangketik.id/swanadiva/hostkeeper/internal/models"
sshclient "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh" sshclient "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh"
) )
type connectionState int type paneType int
const ( const (
stateConnecting connectionState = iota paneLocal paneType = iota
stateConnected paneRemote
stateError
stateDone
) )
// 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 { type SFTPBrowserTab struct {
host *models.Host host *models.Host
dataDir string dataDir string
sshClient *sshclient.Client sshClient *sshclient.Client
sftp *sftp.Client sftp *sftp.Client
cwd string
entries []os.FileInfo left sftpPane
selectedIdx int right sftpPane
state connectionState active paneType
connected bool
err error err error
width int width int
height int height int
filterInput textinput.Model
transferring bool
transferMsg string
mu sync.Mutex mu sync.Mutex
closeOnce sync.Once closeOnce sync.Once
// search/filter
searchMode bool
searchInput textinput.Model
filterText string
} }
// NewSFTPBrowserTab creates a new SFTP browser tab
func NewSFTPBrowserTab(host *models.Host, dataDir string) *SFTPBrowserTab { func NewSFTPBrowserTab(host *models.Host, dataDir string) *SFTPBrowserTab {
si := textinput.New() fi := textinput.New()
si.Placeholder = "Filter files..." fi.Placeholder = "Filter..."
home, _ := os.UserHomeDir()
return &SFTPBrowserTab{ return &SFTPBrowserTab{
host: host, host: host,
dataDir: dataDir, dataDir: dataDir,
active: paneRemote,
left: sftpPane{
cwd: home,
localRoot: home,
},
right: sftpPane{
cwd: "/", cwd: "/",
state: stateConnecting, },
searchInput: si, filterInput: fi,
} }
} }
func (t *SFTPBrowserTab) Name() string { func (t *SFTPBrowserTab) Name() string { return "SFTP: " + t.host.Name }
return "SFTP: " + t.host.Name
}
func (t *SFTPBrowserTab) Init() tea.Cmd { func (t *SFTPBrowserTab) Init() tea.Cmd {
go t.connect() go t.connect()
go t.refreshLocal()
return t.poll return t.poll
} }
func (t *SFTPBrowserTab) poll() tea.Msg { func (t *SFTPBrowserTab) poll() tea.Msg {
t.mu.Lock() t.mu.Lock()
state := t.state conn := t.connected
err := t.err err := t.err
t.mu.Unlock() t.mu.Unlock()
if state == stateError { if err != nil {
return sftpDoneMsg{err: err} return sftpRefreshMsg{err: err}
} }
if state == stateConnected { if conn {
return sftpReadyMsg{} return sftpRefreshMsg{}
} }
return nil return nil
} }
@@ -97,8 +121,7 @@ func (t *SFTPBrowserTab) connect() {
ctx := context.Background() ctx := context.Background()
if err := cl.Connect(ctx); err != nil { if err := cl.Connect(ctx); err != nil {
t.mu.Lock() t.mu.Lock()
t.state = stateError t.err = fmt.Errorf("SSH connect: %w", err)
t.err = fmt.Errorf("SSH connect failed: %w", err)
t.mu.Unlock() t.mu.Unlock()
return return
} }
@@ -107,45 +130,75 @@ func (t *SFTPBrowserTab) connect() {
if err != nil { if err != nil {
cl.Close() cl.Close()
t.mu.Lock() t.mu.Lock()
t.state = stateError t.err = fmt.Errorf("SFTP init: %w", err)
t.err = fmt.Errorf("SFTP init failed: %w", err)
t.mu.Unlock() t.mu.Unlock()
return return
} }
home, err := sftpClient.Getwd()
if err != nil {
home = "/"
}
t.mu.Lock() t.mu.Lock()
t.sshClient = cl t.sshClient = cl
t.sftp = sftpClient t.sftp = sftpClient
t.right.sftpClient = sftpClient
t.right.cwd = home
t.connected = true
t.mu.Unlock() t.mu.Unlock()
if err := t.refresh(); err != nil { t.refreshRemote()
cl.Close() }
sftpClient.Close()
func (t *SFTPBrowserTab) refreshLocal() {
t.mu.Lock() t.mu.Lock()
t.state = stateError p := &t.left
t.err = err p.entries = nil
t.mu.Unlock() t.mu.Unlock()
entries, err := os.ReadDir(p.cwd)
if err != nil {
return 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.mu.Lock()
t.state = stateConnected p.entries = infos
if p.selIdx >= len(infos) {
p.selIdx = 0
}
t.mu.Unlock() t.mu.Unlock()
} }
func (t *SFTPBrowserTab) refresh() error { func (t *SFTPBrowserTab) refreshRemote() {
t.mu.Lock() t.mu.Lock()
sftpClient := t.sftp p := &t.right
cwd := t.cwd sftpClient := p.sftpClient
cwd := p.cwd
t.mu.Unlock() t.mu.Unlock()
if sftpClient == nil { if sftpClient == nil {
return fmt.Errorf("not connected") return
} }
entries, err := sftpClient.ReadDir(cwd) entries, err := sftpClient.ReadDir(cwd)
if err != nil { if err != nil {
return fmt.Errorf("read dir %s: %w", cwd, err) return
} }
sort.Slice(entries, func(i, j int) bool { sort.Slice(entries, func(i, j int) bool {
@@ -156,12 +209,18 @@ func (t *SFTPBrowserTab) refresh() error {
}) })
t.mu.Lock() t.mu.Lock()
t.entries = entries p.entries = entries
if t.selectedIdx >= len(entries) { if p.selIdx >= len(entries) {
t.selectedIdx = 0 p.selIdx = 0
} }
t.mu.Unlock() 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) { 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.width = msg.Width
t.height = msg.Height t.height = msg.Height
t.mu.Unlock() 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 return t, nil
case sftpRefreshMsg: case sftpRefreshMsg:
if err := t.refresh(); err != nil { if msg.err != nil {
t.mu.Lock() t.mu.Lock()
t.err = err t.err = msg.err
t.mu.Unlock() t.mu.Unlock()
return t, nil
} }
// Re-render only
return t, t.poll
case tea.KeyMsg: case tea.KeyMsg:
if t.searchMode { p := t.activePane()
if p.filterMode {
switch msg.String() { switch msg.String() {
case "esc", "enter": case "esc", "enter":
t.searchMode = false p.filterMode = false
t.filterText = "" p.filter = ""
return t, nil return t, nil
case "backspace": case "backspace":
t.searchInput.Update(msg) if len(p.filter) > 0 {
t.filterText = t.searchInput.Value() p.filter = p.filter[:len(p.filter)-1]
}
return t, nil return t, nil
default: default:
t.searchInput.Update(msg) if len(msg.Runes) == 1 {
t.filterText = t.searchInput.Value() p.filter += string(msg.Runes[0])
}
return t, nil return t, nil
} }
} }
switch msg.String() { switch msg.String() {
case "/": case "tab":
t.searchMode = true t.mu.Lock()
t.searchInput.Reset() if t.active == paneLocal {
t.filterText = "" t.active = paneRemote
t.searchInput.Focus() } else {
t.active = paneLocal
}
t.mu.Unlock()
return t, nil return t, nil
case "esc":
return t, func() tea.Msg { return closeFormMsg{} }
case "up", "k": case "up", "k":
t.mu.Lock() t.mu.Lock()
if t.selectedIdx > 0 { if p.selIdx > 0 {
t.selectedIdx-- p.selIdx--
} }
t.mu.Unlock() t.mu.Unlock()
case "down", "j": case "down", "j":
t.mu.Lock() t.mu.Lock()
if t.selectedIdx < len(t.entries)-1 { if p.selIdx < len(p.entries)-1 {
t.selectedIdx++ p.selIdx++
} }
t.mu.Unlock() t.mu.Unlock()
case "enter": case "enter", "right":
t.mu.Lock() t.mu.Lock()
entries := t.entries entries := p.entries
idx := t.selectedIdx idx := p.selIdx
t.mu.Unlock() t.mu.Unlock()
if idx < 0 || idx >= len(entries) { if idx >= 0 && idx < len(entries) && entries[idx].IsDir() {
return t, nil
}
entry := entries[idx]
if entry.IsDir() {
t.mu.Lock() t.mu.Lock()
t.cwd = path.Join(t.cwd, entry.Name()) p.cwd = path.Join(p.cwd, entries[idx].Name())
t.selectedIdx = 0 p.selIdx = 0
t.mu.Unlock()
if err := t.refresh(); err != nil {
t.mu.Lock()
t.err = err
t.mu.Unlock() t.mu.Unlock()
if t.active == paneRemote {
go t.refreshRemote()
} else {
go t.refreshLocal()
} }
} }
case "backspace": case "left", "backspace":
t.mu.Lock() t.mu.Lock()
parent := path.Dir(t.cwd) parent := path.Dir(p.cwd)
if parent == "." { if parent == "." {
parent = "/" parent = "/"
} }
t.cwd = parent if (t.active == paneLocal && parent != p.cwd) || (t.active == paneRemote && parent != p.cwd) {
t.selectedIdx = 0 p.cwd = parent
t.mu.Unlock() p.selIdx = 0
if err := t.refresh(); err != nil { }
t.mu.Lock()
t.err = err
t.mu.Unlock() t.mu.Unlock()
if t.active == paneRemote {
go t.refreshRemote()
} else {
go t.refreshLocal()
} }
case "/":
p.filterMode = true
p.filter = ""
case "r": case "r":
if err := t.refresh(); err != nil { if t.active == paneRemote {
t.mu.Lock() go t.refreshRemote()
t.err = err } else {
t.mu.Unlock() 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 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 { func (t *SFTPBrowserTab) View() string {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
switch t.state { if !t.connected && t.err != nil {
case stateConnecting:
return HighlightStyle.Render(fmt.Sprintf("Connecting SFTP to %s...", t.host.Name))
case stateError:
return fmt.Sprintf("%s\n\n%s", return fmt.Sprintf("%s\n\n%s",
ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)), ErrorStyle.Render(fmt.Sprintf("SFTP Error: %v", t.err)),
SubtitleStyle.Render("Press Esc to close this tab")) SubtitleStyle.Render("Press Esc to close"))
case stateDone:
return fmt.Sprintf("%s\n\n%s",
SubtitleStyle.Render("SFTP session ended"),
SubtitleStyle.Render("Press Esc to close this tab"))
} }
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 var b strings.Builder
b.WriteString(AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname))) b.WriteString(AppTitleStyle.Render(fmt.Sprintf("SFTP: %s@%s", t.host.Username, t.host.Hostname)))
b.WriteString("\n") 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 { if t.err != nil {
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err))) b.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
b.WriteString("\n\n")
} }
if t.sftp == nil { if t.transferring && t.transferMsg != "" {
b.WriteString(SubtitleStyle.Render("Not connected.")) 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() 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 var visible []os.FileInfo
if t.searchMode || t.filterText != "" { if p.filter != "" {
lower := strings.ToLower(t.filterText) lower := strings.ToLower(p.filter)
for _, e := range t.entries { for _, e := range p.entries {
if strings.Contains(strings.ToLower(e.Name()), lower) { if strings.Contains(strings.ToLower(e.Name()), lower) {
visible = append(visible, e) visible = append(visible, e)
} }
} }
} else { } else {
visible = t.entries visible = p.entries
} }
if len(visible) == 0 { if len(visible) == 0 {
b.WriteString(SubtitleStyle.Render(" (empty)")) content.WriteString(SubtitleStyle.Render(" (empty)"))
} else { } 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() name := entry.Name()
var line string var line string
@@ -344,26 +640,27 @@ func (t *SFTPBrowserTab) View() string {
line = fmt.Sprintf(" %s (%s)", name, size) line = fmt.Sprintf(" %s (%s)", name, size)
} }
if i == t.selectedIdx { if len(line) > maxW {
b.WriteString(SelectedStyle.Render(line)) line = line[:maxW-1] + "…"
}
if i == p.selIdx {
content.WriteString(SelectedStyle.Render(line))
} else if entry.IsDir() {
content.WriteString(InfoStyle.Render(line))
} else { } else {
style := HostDetailStyle content.WriteString(HostDetailStyle.Render(line))
if entry.IsDir() {
style = InfoStyle
} }
b.WriteString(style.Render(line)) content.WriteString("\n")
}
b.WriteString("\n")
} }
} }
if t.searchMode { if p.filterMode {
b.WriteString("\n" + SubtitleStyle.Render("Filter: ") + t.searchInput.View()) content.WriteString("\n" + SubtitleStyle.Render("Filter: "+p.filter+"_"))
} else {
b.WriteString("\n" + SubtitleStyle.Render("/:filter r:refresh Esc:close"))
} }
return b.String() inner := titleBar + "\n" + content.String()
return paneStyle.Width(maxW).Render(inner)
} }
func (t *SFTPBrowserTab) Close() { func (t *SFTPBrowserTab) Close() {
@@ -377,6 +674,10 @@ func (t *SFTPBrowserTab) Close() {
}) })
} }
type sftpRefreshMsg struct {
err error
}
func formatSize(size int64) string { func formatSize(size int64) string {
switch { switch {
case size >= 1<<30: case size >= 1<<30:
@@ -389,12 +690,3 @@ func formatSize(size int64) string {
return fmt.Sprintf("%d B", size) return fmt.Sprintf("%d B", size)
} }
} }
// Message types for SFTP tab
type sftpReadyMsg struct{}
type sftpDoneMsg struct {
err error
}
type sftpRefreshMsg struct{}
+6
View File
@@ -45,3 +45,9 @@ func TabBarWidth(totalWidth int) int {
} }
return totalWidth - 2 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)
)