Add F2 rename flow and improve modal key hints
This commit is contained in:
parent
124e7ef972
commit
e7b31a8d5c
4 changed files with 192 additions and 12 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,21 +591,35 @@ 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 == "" {
|
||||
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
|
||||
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
|
||||
m.modal.input, cmd = m.modal.input.Update(msg)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue