diff --git a/internal/fs/ops.go b/internal/fs/ops.go index 4d45ab0..6260ebf 100644 --- a/internal/fs/ops.go +++ b/internal/fs/ops.go @@ -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 { diff --git a/internal/fs/ops_test.go b/internal/fs/ops_test.go index fee4de4..f91bfdf 100644 --- a/internal/fs/ops_test.go +++ b/internal/fs/ops_test.go @@ -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) + } +} diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 9e4796c..169410b 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -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}, } } diff --git a/internal/ui/model.go b/internal/ui/model.go index 17b3918..518e212 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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 {