From a1cd3d5dc0a575605bb5acca557a72d8eb2dc744 Mon Sep 17 00:00:00 2001 From: swanadiva Date: Wed, 24 Jun 2026 16:27:39 +0700 Subject: [PATCH] feat: SFTP rename files and directories - M key enters rename mode with current name pre-filled - Type new name, Enter to confirm, Esc to cancel - renameItem: client.Rename() for remote, os.Rename() for local - Clear cache + refresh after rename - Footer updated: M:rename - Filter mode allows M to pass through --- CHANGELOG.md | 8 +++ pkg/tui/sftp_browser_tab.go | 109 +++++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd4a658..99b55bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,14 @@ - Delete: clear cache + trigger re-render after file removal - Mkdir: clear cache + trigger re-render after directory creation +### SFTP Rename Feature +- Added `M` key to rename files and directories +- Rename mode: shows input with current name, type new name, Enter to confirm +- Works on both Local and Remote panes +- Clears cache and refreshes after rename +- Footer updated: `M:rename` added +- Filter mode allows `M` to pass through + ## v1.0.0 (2025-01-30) ### Added diff --git a/pkg/tui/sftp_browser_tab.go b/pkg/tui/sftp_browser_tab.go index 86e7ff1..4faf7ff 100644 --- a/pkg/tui/sftp_browser_tab.go +++ b/pkg/tui/sftp_browser_tab.go @@ -38,6 +38,9 @@ type sftpPane struct { filterMode bool filter string + renameMode bool + renameInput string + // Directory listing cache: path → entries dirCache map[string][]os.FileInfo @@ -295,7 +298,7 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) { return t, nil case "tab", "up", "k", "down", "j": // Allow navigation and pane switch in filter mode - case "c", "d", "n", "r": + case "c", "d", "m", "n", "r": // Allow commands in filter mode default: if len(msg.Runes) == 1 { @@ -305,6 +308,43 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) { } } + if p.renameMode { + switch msg.String() { + case "esc": + p.renameMode = false + p.renameInput = "" + return t, nil + case "enter": + if p.renameInput != "" { + t.mu.Lock() + entries := p.entries + idx := p.selIdx + cwd := p.cwd + isRemote := t.active == paneRemote + t.mu.Unlock() + + if idx >= 0 && idx < len(entries) { + oldPath := path.Join(cwd, entries[idx].Name()) + newPath := path.Join(cwd, p.renameInput) + go t.renameItem(oldPath, newPath, isRemote) + } + } + p.renameMode = false + p.renameInput = "" + return t, nil + case "backspace": + if len(p.renameInput) > 0 { + p.renameInput = p.renameInput[:len(p.renameInput)-1] + } + return t, nil + default: + if len(msg.Runes) == 1 { + p.renameInput += string(msg.Runes[0]) + } + return t, nil + } + } + switch msg.String() { case "tab": t.mu.Lock() @@ -424,6 +464,16 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) { go t.mkdirRemote() } + case "m": + t.mu.Lock() + entries := p.entries + idx := p.selIdx + t.mu.Unlock() + if idx >= 0 && idx < len(entries) { + p.renameMode = true + p.renameInput = entries[idx].Name() + } + case "esc": return t, func() tea.Msg { return closeFormMsg{} } @@ -669,6 +719,53 @@ func (t *SFTPBrowserTab) mkdirRemote() { } } +func (t *SFTPBrowserTab) renameItem(oldPath, newPath string, isRemote bool) { + t.mu.Lock() + t.transferring = true + t.transferMsg = fmt.Sprintf("Renaming %s → %s...", filepath.Base(oldPath), filepath.Base(newPath)) + t.mu.Unlock() + + var err error + if isRemote { + t.mu.Lock() + client := t.right.sftpClient + t.mu.Unlock() + if client != nil { + err = client.Rename(oldPath, newPath) + } + } else { + err = os.Rename(oldPath, newPath) + } + + t.mu.Lock() + if err != nil { + t.err = fmt.Errorf("rename: %w", err) + } + t.transferring = false + t.transferMsg = "" + t.mu.Unlock() + + // Clear cache and refresh + t.mu.Lock() + if isRemote { + delete(t.right.dirCache, t.right.cwd) + } else { + delete(t.left.dirCache, t.left.cwd) + } + t.mu.Unlock() + + if isRemote { + t.refreshRemote() + } else { + t.refreshLocal() + } + + // Trigger re-render + if t.program != nil { + t.program.Send(sftpRefreshMsg{}) + } +} + func (t *SFTPBrowserTab) View() string { t.mu.Lock() defer t.mu.Unlock() @@ -764,7 +861,7 @@ func (t *SFTPBrowserTab) View() string { } // Footer (wrapped to terminal width) - footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav Enter/→:open ←/Backspace:up /:filter C:transfer D:delete N:mkdir R:refresh Esc:close" + footerText := "Ctrl+Tab:switch Ctrl+Q:close Tab:pane ↑↓:nav Enter/→:open ←/Backspace:up /:filter C:transfer D:delete M:rename 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))) @@ -882,6 +979,14 @@ func (t *SFTPBrowserTab) renderPane(p *sftpPane, pt paneType, maxW, maxH int) st content.WriteString("\n" + SubtitleStyle.Render(filterLine)) } + if p.renameMode { + renameLine := "Rename: " + p.renameInput + "_" + if lipgloss.Width(renameLine) > innerW { + renameLine = truncateStr(renameLine, innerW) + } + content.WriteString("\n" + SubtitleStyle.Render(renameLine)) + } + inner := titleBar + "\n" + content.String() return paneStyle.Width(maxW).Height(maxH).Render(inner) }