94ec00c303
- SFTPBrowserTab with remote directory browsing - Open via Ctrl+F from host list - Navigate dirs, filter with /, refresh with r - Dir/icon display with file sizes - Sort dirs first, then by name
401 lines
7.6 KiB
Go
401 lines
7.6 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/pkg/sftp"
|
|
|
|
"git.tukangketik.id/swanadiva/hostkeeper/internal/models"
|
|
sshclient "git.tukangketik.id/swanadiva/hostkeeper/pkg/ssh"
|
|
)
|
|
|
|
type connectionState int
|
|
|
|
const (
|
|
stateConnecting connectionState = iota
|
|
stateConnected
|
|
stateError
|
|
stateDone
|
|
)
|
|
|
|
// SFTPBrowserTab provides a remote file browser via SFTP
|
|
type SFTPBrowserTab struct {
|
|
host *models.Host
|
|
dataDir string
|
|
|
|
sshClient *sshclient.Client
|
|
sftp *sftp.Client
|
|
cwd string
|
|
|
|
entries []os.FileInfo
|
|
selectedIdx int
|
|
state connectionState
|
|
err error
|
|
width int
|
|
height int
|
|
|
|
mu sync.Mutex
|
|
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 {
|
|
si := textinput.New()
|
|
si.Placeholder = "Filter files..."
|
|
|
|
return &SFTPBrowserTab{
|
|
host: host,
|
|
dataDir: dataDir,
|
|
cwd: "/",
|
|
state: stateConnecting,
|
|
searchInput: si,
|
|
}
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) Name() string {
|
|
return "SFTP: " + t.host.Name
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) Init() tea.Cmd {
|
|
go t.connect()
|
|
return t.poll
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) poll() tea.Msg {
|
|
t.mu.Lock()
|
|
state := t.state
|
|
err := t.err
|
|
t.mu.Unlock()
|
|
|
|
if state == stateError {
|
|
return sftpDoneMsg{err: err}
|
|
}
|
|
if state == stateConnected {
|
|
return sftpReadyMsg{}
|
|
}
|
|
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.state = stateError
|
|
t.err = fmt.Errorf("SSH connect failed: %w", err)
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
sftpClient, err := sftp.NewClient(cl.GetClient())
|
|
if err != nil {
|
|
cl.Close()
|
|
t.mu.Lock()
|
|
t.state = stateError
|
|
t.err = fmt.Errorf("SFTP init failed: %w", err)
|
|
t.mu.Unlock()
|
|
return
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.sshClient = cl
|
|
t.sftp = sftpClient
|
|
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()
|
|
return
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.state = stateConnected
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) refresh() error {
|
|
t.mu.Lock()
|
|
sftpClient := t.sftp
|
|
cwd := t.cwd
|
|
t.mu.Unlock()
|
|
|
|
if sftpClient == nil {
|
|
return fmt.Errorf("not connected")
|
|
}
|
|
|
|
entries, err := sftpClient.ReadDir(cwd)
|
|
if err != nil {
|
|
return fmt.Errorf("read dir %s: %w", cwd, err)
|
|
}
|
|
|
|
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()
|
|
t.entries = entries
|
|
if t.selectedIdx >= len(entries) {
|
|
t.selectedIdx = 0
|
|
}
|
|
t.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
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()
|
|
|
|
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 {
|
|
t.mu.Lock()
|
|
t.err = err
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
case tea.KeyMsg:
|
|
if t.searchMode {
|
|
switch msg.String() {
|
|
case "esc", "enter":
|
|
t.searchMode = false
|
|
t.filterText = ""
|
|
return t, nil
|
|
case "backspace":
|
|
t.searchInput.Update(msg)
|
|
t.filterText = t.searchInput.Value()
|
|
return t, nil
|
|
default:
|
|
t.searchInput.Update(msg)
|
|
t.filterText = t.searchInput.Value()
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "/":
|
|
t.searchMode = true
|
|
t.searchInput.Reset()
|
|
t.filterText = ""
|
|
t.searchInput.Focus()
|
|
return t, nil
|
|
|
|
case "esc":
|
|
return t, func() tea.Msg { return closeFormMsg{} }
|
|
|
|
case "up", "k":
|
|
t.mu.Lock()
|
|
if t.selectedIdx > 0 {
|
|
t.selectedIdx--
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
case "down", "j":
|
|
t.mu.Lock()
|
|
if t.selectedIdx < len(t.entries)-1 {
|
|
t.selectedIdx++
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
case "enter":
|
|
t.mu.Lock()
|
|
entries := t.entries
|
|
idx := t.selectedIdx
|
|
t.mu.Unlock()
|
|
|
|
if idx < 0 || idx >= len(entries) {
|
|
return t, nil
|
|
}
|
|
entry := entries[idx]
|
|
|
|
if entry.IsDir() {
|
|
t.mu.Lock()
|
|
t.cwd = path.Join(t.cwd, entry.Name())
|
|
t.selectedIdx = 0
|
|
t.mu.Unlock()
|
|
if err := t.refresh(); err != nil {
|
|
t.mu.Lock()
|
|
t.err = err
|
|
t.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
case "backspace":
|
|
t.mu.Lock()
|
|
parent := path.Dir(t.cwd)
|
|
if parent == "." {
|
|
parent = "/"
|
|
}
|
|
t.cwd = parent
|
|
t.selectedIdx = 0
|
|
t.mu.Unlock()
|
|
if err := t.refresh(); err != nil {
|
|
t.mu.Lock()
|
|
t.err = err
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
case "r":
|
|
if err := t.refresh(); err != nil {
|
|
t.mu.Lock()
|
|
t.err = err
|
|
t.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
return t, t.poll
|
|
}
|
|
|
|
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:
|
|
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"))
|
|
}
|
|
|
|
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")
|
|
|
|
if t.err != nil {
|
|
b.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", t.err)))
|
|
b.WriteString("\n\n")
|
|
}
|
|
|
|
if t.sftp == nil {
|
|
b.WriteString(SubtitleStyle.Render("Not connected."))
|
|
return b.String()
|
|
}
|
|
|
|
var visible []os.FileInfo
|
|
if t.searchMode || t.filterText != "" {
|
|
lower := strings.ToLower(t.filterText)
|
|
for _, e := range t.entries {
|
|
if strings.Contains(strings.ToLower(e.Name()), lower) {
|
|
visible = append(visible, e)
|
|
}
|
|
}
|
|
} else {
|
|
visible = t.entries
|
|
}
|
|
|
|
if len(visible) == 0 {
|
|
b.WriteString(SubtitleStyle.Render(" (empty)"))
|
|
} else {
|
|
for i, entry := range visible {
|
|
name := entry.Name()
|
|
var line string
|
|
|
|
if entry.IsDir() {
|
|
line = fmt.Sprintf(" %s/", name)
|
|
} else {
|
|
size := formatSize(entry.Size())
|
|
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))
|
|
}
|
|
b.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"))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (t *SFTPBrowserTab) Close() {
|
|
t.closeOnce.Do(func() {
|
|
if t.sftp != nil {
|
|
t.sftp.Close()
|
|
}
|
|
if t.sshClient != nil {
|
|
t.sshClient.Close()
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Message types for SFTP tab
|
|
type sftpReadyMsg struct{}
|
|
|
|
type sftpDoneMsg struct {
|
|
err error
|
|
}
|
|
|
|
type sftpRefreshMsg struct{}
|