From c957a97cdb3914100000cbca9e4e3b4585d94a50 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Tue, 23 Jun 2026 15:38:48 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20dual-pane=20SFTP=20browser=20(local=20?= =?UTF-8?q?=E2=86=94=20remote)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/tui/sftp_browser_tab.go | 602 ++++++++++++++++++++++++++---------- pkg/tui/styles.go | 6 + 2 files changed, 453 insertions(+), 155 deletions(-) diff --git a/pkg/tui/sftp_browser_tab.go b/pkg/tui/sftp_browser_tab.go index 37e2e2c..d7b9631 100644 --- a/pkg/tui/sftp_browser_tab.go +++ b/pkg/tui/sftp_browser_tab.go @@ -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{} diff --git a/pkg/tui/styles.go b/pkg/tui/styles.go index 223b3b3..2a21a81 100644 --- a/pkg/tui/styles.go +++ b/pkg/tui/styles.go @@ -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) +)