Add F2 rename flow and improve modal key hints

This commit is contained in:
vrubelroman 2026-04-24 13:15:04 +03:00
parent 124e7ef972
commit e7b31a8d5c
4 changed files with 192 additions and 12 deletions

View file

@ -270,6 +270,31 @@ func MakeDir(parent string, name string) (string, error) {
return target, nil return target, nil
} }
func RenamePath(sourcePath string, newName string) (string, error) {
newName = filepath.Base(filepath.Clean(newName))
if newName == "." || newName == "" {
return "", fmt.Errorf("invalid target name")
}
targetPath := filepath.Join(filepath.Dir(sourcePath), newName)
if same, err := samePath(sourcePath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
return "", ErrOverwrite(targetPath)
}
if err := os.Rename(sourcePath, targetPath); err != nil {
return "", err
}
return targetPath, nil
}
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error { func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil { if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil { if err := tracker.ctx.Err(); err != nil {

View file

@ -86,3 +86,50 @@ func TestMovePathWithProgressContextCancelledBeforeStartKeepsSource(t *testing.T
t.Fatalf("expected destination file to be absent, stat err=%v", statErr) t.Fatalf("expected destination file to be absent, stat err=%v", statErr)
} }
} }
func TestRenamePath(t *testing.T) {
t.Parallel()
root := t.TempDir()
source := filepath.Join(root, "old.txt")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
target, err := RenamePath(source, "new.txt")
if err != nil {
t.Fatalf("rename: %v", err)
}
if filepath.Base(target) != "new.txt" {
t.Fatalf("unexpected target path: %s", target)
}
if _, statErr := os.Stat(target); statErr != nil {
t.Fatalf("expected renamed file to exist, stat err=%v", statErr)
}
if _, statErr := os.Stat(source); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected source to be absent, stat err=%v", statErr)
}
}
func TestRenamePathRejectsExistingTarget(t *testing.T) {
t.Parallel()
root := t.TempDir()
source := filepath.Join(root, "old.txt")
target := filepath.Join(root, "new.txt")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
_, err := RenamePath(source, "new.txt")
if err == nil {
t.Fatalf("expected overwrite error, got nil")
}
if got, want := err.Error(), ErrOverwrite(target).Error(); got != want {
t.Fatalf("expected overwrite error %q, got %q", want, got)
}
}

View file

@ -6,6 +6,7 @@ type KeyMap struct {
Help key.Binding Help key.Binding
View key.Binding View key.Binding
Edit key.Binding Edit key.Binding
Rename key.Binding
Info key.Binding Info key.Binding
SelectText key.Binding SelectText key.Binding
ToggleHidden key.Binding ToggleHidden key.Binding
@ -36,6 +37,7 @@ type KeyMap struct {
func DefaultKeyMap() KeyMap { func DefaultKeyMap() KeyMap {
return KeyMap{ return KeyMap{
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
Rename: key.NewBinding(key.WithKeys("f2", "r"), key.WithHelp("F2/r", "rename")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")), Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("f9", "i"), key.WithHelp("F9/i", "info")), Info: key.NewBinding(key.WithKeys("f9", "i"), key.WithHelp("F9/i", "info")),
@ -52,7 +54,7 @@ func DefaultKeyMap() KeyMap {
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")),
Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")), Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("C-r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")), DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")), Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")), Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
@ -67,13 +69,13 @@ func DefaultKeyMap() KeyMap {
} }
func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.View, k.Copy, k.Move, k.Delete, k.Info, k.Quit} return []key.Binding{k.Help, k.Rename, k.View, k.Copy, k.Move, k.Delete, k.Info, k.Quit}
} }
func (k KeyMap) FullHelp() [][]key.Binding { func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back}, {k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back},
{k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete}, {k.Rename, k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete},
{k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, {k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
} }
} }

View file

@ -26,6 +26,7 @@ type modalKind int
const ( const (
modalNone modalKind = iota modalNone modalKind = iota
modalMkdir modalMkdir
modalRename
modalConfirm modalConfirm
modalCopyProgress modalCopyProgress
modalNotice modalNotice
@ -39,6 +40,7 @@ const (
opMove opMove
opDelete opDelete
opMkdir opMkdir
opRename
opEdit opEdit
opView opView
) )
@ -259,6 +261,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.activePane().ClearMarks() m.activePane().ClearMarks()
case opMkdir: case opMkdir:
m.status = fmt.Sprintf("Created %s", msg.targetPath) m.status = fmt.Sprintf("Created %s", msg.targetPath)
case opRename:
m.status = fmt.Sprintf("Renamed to %s", filepath.Base(msg.targetPath))
case opEdit: case opEdit:
m.status = "Editor closed" m.status = "Editor closed"
return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
@ -267,9 +271,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, enableMouseCmd() return m, enableMouseCmd()
} }
activeSelection := selectedName(m.activePane()) leftSelection := selectedName(&m.left)
_ = m.reloadPane(PaneLeft, activeSelection) rightSelection := selectedName(&m.right)
_ = m.reloadPane(PaneRight, activeSelection) if msg.kind == opRename && msg.targetPath != "" {
renamed := filepath.Base(msg.targetPath)
if m.active == PaneLeft {
leftSelection = renamed
} else {
rightSelection = renamed
}
}
_ = m.reloadPane(PaneLeft, leftSelection)
_ = m.reloadPane(PaneRight, rightSelection)
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case copyPlanMsg: case copyPlanMsg:
@ -423,6 +436,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Help): case key.Matches(msg, m.keys.Help):
m.openHelpModal() m.openHelpModal()
return m, nil return m, nil
case key.Matches(msg, m.keys.Rename):
m.openRenameModal()
return m, nil
case key.Matches(msg, m.keys.Cancel): case key.Matches(msg, m.keys.Cancel):
if m.infoMode { if m.infoMode {
m.infoMode = false m.infoMode = false
@ -575,20 +591,34 @@ func (m Model) View() string {
func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.modal.kind { switch m.modal.kind {
case modalMkdir: case modalMkdir, modalRename:
switch { switch {
case isModalCloseKey(msg, m.keys): case msg.String() == "esc":
m.modal = modalState{} m.modal = modalState{}
m.status = "Cancelled" m.status = "Cancelled"
return m, nil return m, nil
case key.Matches(msg, m.keys.Confirm): case key.Matches(msg, m.keys.Confirm):
value := strings.TrimSpace(m.modal.input.Value()) value := strings.TrimSpace(m.modal.input.Value())
if value == "" { if value == "" {
m.status = "Directory name must not be empty" if m.modal.kind == modalMkdir {
m.status = "Directory name must not be empty"
} else {
m.status = "Name must not be empty"
}
return m, nil return m, nil
} }
m.busy = true m.busy = true
return m, mkdirCmd(m.activePane().Path, value) if m.modal.kind == modalMkdir {
return m, mkdirCmd(m.activePane().Path, value)
}
selected, ok := m.activePane().Selected()
if !ok || selected.IsParent {
m.busy = false
m.modal = modalState{}
m.status = "No entry selected"
return m, nil
}
return m, renameCmd(selected.Path, value)
} }
var cmd tea.Cmd var cmd tea.Cmd
@ -1154,6 +1184,28 @@ func (m *Model) openMkdirModal() {
} }
} }
func (m *Model) openRenameModal() {
selected, ok := m.activePane().Selected()
if !ok || selected.IsParent {
m.status = "Select an entry to rename"
return
}
input := textinput.New()
input.SetValue(selected.Name)
input.Focus()
input.CharLimit = 255
input.Width = 42
m.modal = modalState{
kind: modalRename,
title: "Rename entry",
body: fmt.Sprintf("Path: %s", selected.Path),
note: "Enter to confirm, Esc to cancel",
input: input,
}
}
func (m *Model) openConfirmModal(title, body, note string, pending pendingOperation) { func (m *Model) openConfirmModal(title, body, note string, pending pendingOperation) {
m.modal = modalState{ m.modal = modalState{
kind: modalConfirm, kind: modalConfirm,
@ -1176,7 +1228,8 @@ func (m *Model) openHelpModal() {
" Enter / Right open selected entry", " Enter / Right open selected entry",
" Backspace/Left go to parent directory", " Backspace/Left go to parent directory",
" Tab / h / l switch active pane", " Tab / h / l switch active pane",
" r refresh both panes", " F2 / r rename selected entry",
" Ctrl+r refresh both panes",
"", "",
"View and Panels", "View and Panels",
" F9 / i toggle preview/info pane", " F9 / i toggle preview/info pane",
@ -1544,7 +1597,7 @@ func renderModal(m Model, palette theme.Palette, width int) string {
lines = append(lines, renderModalBodyLine(raw, contentWidth, palette)) lines = append(lines, renderModalBodyLine(raw, contentWidth, palette))
} }
if modal.kind == modalMkdir { if modal.kind == modalMkdir || modal.kind == modalRename {
lines = append(lines, spacer, lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(modal.input.View())) lines = append(lines, spacer, lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(modal.input.View()))
} }
if modal.note != "" { if modal.note != "" {
@ -1605,6 +1658,52 @@ func renderModalNoteLine(raw string, width int, palette theme.Palette, fallback
return fallback.Render("") return fallback.Render("")
} }
if strings.Contains(raw, "Enter") || strings.Contains(raw, "Esc") {
var line strings.Builder
rest := raw
for len(rest) > 0 {
enterIdx := strings.Index(rest, "Enter")
escIdx := strings.Index(rest, "Esc")
nextIdx := -1
nextToken := ""
nextColor := palette.Muted
if enterIdx >= 0 {
nextIdx = enterIdx
nextToken = "Enter"
nextColor = palette.ConfirmButton
}
if escIdx >= 0 && (nextIdx == -1 || escIdx < nextIdx) {
nextIdx = escIdx
nextToken = "Esc"
nextColor = palette.CancelButton
}
if nextIdx == -1 {
line.WriteString(lipgloss.NewStyle().
Background(palette.Panel).
Foreground(palette.Muted).
Render(rest))
break
}
if nextIdx > 0 {
line.WriteString(lipgloss.NewStyle().
Background(palette.Panel).
Foreground(palette.Muted).
Render(rest[:nextIdx]))
}
line.WriteString(lipgloss.NewStyle().
Background(palette.Panel).
Foreground(nextColor).
Bold(true).
Render(nextToken))
rest = rest[nextIdx+len(nextToken):]
}
return lipgloss.NewStyle().
Width(width).
Background(palette.Panel).
Render(line.String())
}
for _, sep := range []string{" to ", " (", ","} { for _, sep := range []string{" to ", " (", ","} {
if idx := strings.Index(raw, sep); idx > 0 { if idx := strings.Index(raw, sep); idx > 0 {
keyLabel := strings.TrimSpace(raw[:idx]) keyLabel := strings.TrimSpace(raw[:idx])
@ -2231,6 +2330,13 @@ func mkdirCmd(parent, name string) tea.Cmd {
} }
} }
func renameCmd(sourcePath, newName string) tea.Cmd {
return func() tea.Msg {
targetPath, err := vfs.RenamePath(sourcePath, newName)
return opMsg{kind: opRename, sourcePath: sourcePath, targetPath: targetPath, err: err}
}
}
func selectedName(pane *BrowserPane) string { func selectedName(pane *BrowserPane) string {
selected, ok := pane.Selected() selected, ok := pane.Selected()
if !ok { if !ok {