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
}
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 {
if tracker != nil && tracker.ctx != 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)
}
}
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
View key.Binding
Edit key.Binding
Rename key.Binding
Info key.Binding
SelectText key.Binding
ToggleHidden key.Binding
@ -36,6 +37,7 @@ type KeyMap struct {
func DefaultKeyMap() KeyMap {
return KeyMap{
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")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
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")),
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")),
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")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
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 {
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 {
return [][]key.Binding{
{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},
}
}

View file

@ -26,6 +26,7 @@ type modalKind int
const (
modalNone modalKind = iota
modalMkdir
modalRename
modalConfirm
modalCopyProgress
modalNotice
@ -39,6 +40,7 @@ const (
opMove
opDelete
opMkdir
opRename
opEdit
opView
)
@ -259,6 +261,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.activePane().ClearMarks()
case opMkdir:
m.status = fmt.Sprintf("Created %s", msg.targetPath)
case opRename:
m.status = fmt.Sprintf("Renamed to %s", filepath.Base(msg.targetPath))
case opEdit:
m.status = "Editor closed"
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()
}
activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection)
leftSelection := selectedName(&m.left)
rightSelection := selectedName(&m.right)
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()
case copyPlanMsg:
@ -423,6 +436,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Help):
m.openHelpModal()
return m, nil
case key.Matches(msg, m.keys.Rename):
m.openRenameModal()
return m, nil
case key.Matches(msg, m.keys.Cancel):
if m.infoMode {
m.infoMode = false
@ -575,20 +591,34 @@ func (m Model) View() string {
func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.modal.kind {
case modalMkdir:
case modalMkdir, modalRename:
switch {
case isModalCloseKey(msg, m.keys):
case msg.String() == "esc":
m.modal = modalState{}
m.status = "Cancelled"
return m, nil
case key.Matches(msg, m.keys.Confirm):
value := strings.TrimSpace(m.modal.input.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
}
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
@ -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) {
m.modal = modalState{
kind: modalConfirm,
@ -1176,7 +1228,8 @@ func (m *Model) openHelpModal() {
" Enter / Right open selected entry",
" Backspace/Left go to parent directory",
" Tab / h / l switch active pane",
" r refresh both panes",
" F2 / r rename selected entry",
" Ctrl+r refresh both panes",
"",
"View and Panels",
" 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))
}
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()))
}
if modal.note != "" {
@ -1605,6 +1658,52 @@ func renderModalNoteLine(raw string, width int, palette theme.Palette, fallback
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 ", " (", ","} {
if idx := strings.Index(raw, sep); idx > 0 {
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 {
selected, ok := pane.Selected()
if !ok {