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
|
- Delete: clear cache + trigger re-render after file removal
|
||||||
- Mkdir: clear cache + trigger re-render after directory creation
|
- 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)
|
## v1.0.0 (2025-01-30)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+107
-2
@@ -38,6 +38,9 @@ type sftpPane struct {
|
|||||||
filterMode bool
|
filterMode bool
|
||||||
filter string
|
filter string
|
||||||
|
|
||||||
|
renameMode bool
|
||||||
|
renameInput string
|
||||||
|
|
||||||
// Directory listing cache: path → entries
|
// Directory listing cache: path → entries
|
||||||
dirCache map[string][]os.FileInfo
|
dirCache map[string][]os.FileInfo
|
||||||
|
|
||||||
@@ -295,7 +298,7 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
|||||||
return t, nil
|
return t, nil
|
||||||
case "tab", "up", "k", "down", "j":
|
case "tab", "up", "k", "down", "j":
|
||||||
// Allow navigation and pane switch in filter mode
|
// Allow navigation and pane switch in filter mode
|
||||||
case "c", "d", "n", "r":
|
case "c", "d", "m", "n", "r":
|
||||||
// Allow commands in filter mode
|
// Allow commands in filter mode
|
||||||
default:
|
default:
|
||||||
if len(msg.Runes) == 1 {
|
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() {
|
switch msg.String() {
|
||||||
case "tab":
|
case "tab":
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
@@ -424,6 +464,16 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
|||||||
go t.mkdirRemote()
|
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":
|
case "esc":
|
||||||
return t, func() tea.Msg { return closeFormMsg{} }
|
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 {
|
func (t *SFTPBrowserTab) View() string {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
@@ -764,7 +861,7 @@ func (t *SFTPBrowserTab) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Footer (wrapped to terminal width)
|
// 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)
|
footerWrapped := wrapFooter(footerText, t.width)
|
||||||
for _, line := range strings.Split(footerWrapped, "\n") {
|
for _, line := range strings.Split(footerWrapped, "\n") {
|
||||||
b.WriteString("\n" + lipgloss.PlaceHorizontal(t.width, lipgloss.Center, SubtitleStyle.Render(line)))
|
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))
|
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()
|
inner := titleBar + "\n" + content.String()
|
||||||
return paneStyle.Width(maxW).Height(maxH).Render(inner)
|
return paneStyle.Width(maxW).Height(maxH).Render(inner)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user