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{}