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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user