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
This commit is contained in:
swanadiva
2026-06-24 16:27:39 +07:00
parent eb663c2634
commit a1cd3d5dc0
2 changed files with 115 additions and 2 deletions
+8
View File
@@ -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
+107 -2
View File
@@ -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)
}