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 // 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: 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) 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 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() 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 } 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.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": 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 var leftView, rightView string if t.width < 50 { // Stack vertically — each pane gets full width leftView = t.renderPane(&t.left, paneLocal, t.width) rightView = t.renderPane(&t.right, paneRemote, t.width) } else { // Side by side — each pane gets half width halfW := t.width / 2 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(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.JoinVertical(lipgloss.Left, leftView, rightView)) } 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:copy 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 int) string { isActive := t.active == pt title := "Local" if pt == paneRemote { title = "Remote" } paneStyle := StylePaneInactive if isActive { paneStyle = StylePaneActive } // Inner content width: border(2) + padding(2) = 4 overhead innerW := maxW - 4 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 := 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 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 { content.WriteString("\n" + SubtitleStyle.Render("Filter: "+p.filter+"_")) } inner := titleBar + "\n" + content.String() return paneStyle.Width(maxW).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) } }