From cd877ab58409c93ca4cff519296f77a57e796e14 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 29 Apr 2026 16:03:34 +0300 Subject: [PATCH] feat: mirror pane (p), cursor memory, clean up help dialog - Add Mirror pane keybinding p (for 'Pane'), SSH restored on s - Add session-scoped cursor memory per directory - Fix cursor memory bug: DisplayName() adds trailing / for directories - Fix remote mirror: use active.Path instead of mount.RemotePath - Remove F-key/Mouse/b/c entries from F1 help dialog - Remove Mirror from ShortHelp footer (F-keys only) - Add missing letter-key bindings to help: p, s, a, n, x --- internal/ui/keymap.go | 2 + internal/ui/model.go | 105 +++++++++++++++++++++++++----- internal/ui/pane.go | 22 +++++++ plans/mirror-and-cursor-memory.md | 88 +++++++++++++++++++++++++ vcom.toml | 2 +- 5 files changed, 201 insertions(+), 18 deletions(-) create mode 100644 plans/mirror-and-cursor-memory.md diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 3570560..5d0df15 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -15,6 +15,7 @@ type KeyMap struct { CycleTheme key.Binding CycleSort key.Binding SSH key.Binding + Mirror key.Binding Up key.Binding Down key.Binding SelectUp key.Binding @@ -55,6 +56,7 @@ func DefaultKeyMap() KeyMap { CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")), SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")), + Mirror: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "mirror pane")), Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")), diff --git a/internal/ui/model.go b/internal/ui/model.go index 34a3f49..e5e0cec 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1257,6 +1257,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.handleDelete() case key.Matches(msg, m.keys.Unpack): return m.handleUnpack() + case key.Matches(msg, m.keys.Mirror): + return m.handleMirrorPane() case key.Matches(msg, m.keys.SSH): log.Printf("[KEY] SSH toggle — active=%s path=%s", m.active, activePane.Path) return m.handleSSHToggle() @@ -1881,11 +1883,15 @@ func (m *Model) enterSelected() error { m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor." return nil } + // Save cursor position for the current directory before navigating away. + pane.SaveCursor(pane.Path, selected.Name) // Save current directory to history before navigating. pane.PushHistory(pane.Path) log.Printf("[NAV] enterSelected — pane=%s from=%s to=%s", pane.ID, pane.Path, selected.Path) pane.Path = selected.Path - if err := m.reloadPane(pane.ID, selected.Name); err != nil { + // Restore cursor position if we've visited this directory before in this session. + preserve := pane.LoadCursor(pane.Path) + if err := m.reloadPane(pane.ID, preserve); err != nil { return err } m.status = fmt.Sprintf("Entered %s", pane.Path) @@ -1998,6 +2004,9 @@ func (m *Model) goParent() error { return nil } // Inside remote subdirectory — go up one level + if selected, ok := pane.Selected(); ok && !selected.IsParent { + pane.SaveCursor(pane.Path, selected.Name) + } pane.PushHistory(pane.Path) parent := path.Dir(current) if parent == "." { @@ -2037,6 +2046,10 @@ func (m *Model) goParent() error { return nil } + // Save cursor position before leaving this directory. + if selected, ok := pane.Selected(); ok && !selected.IsParent { + pane.SaveCursor(pane.Path, selected.Name) + } parent := filepath.Dir(pane.Path) if parent == pane.Path { return nil @@ -2060,13 +2073,19 @@ func (m *Model) historyBack() (tea.Model, tea.Cmd) { m.status = "No directory history" return m, nil } + // Save cursor position for the current directory before navigating away. + if selected, ok := pane.Selected(); ok && !selected.IsParent { + pane.SaveCursor(pane.Path, selected.Name) + } // Save current path to forward-stack so forward navigation can restore it. pane.PushFuture(pane.Path) log.Printf("[NAV] historyBack — pane=%s cur=%s prev=%s", pane.ID, pane.Path, prevPath) pane.Path = prevPath pane.Cursor = 0 pane.Offset = 0 - if err := m.reloadPane(pane.ID, ""); err != nil { + // Restore cursor position if we've visited this directory before in this session. + preserve := pane.LoadCursor(pane.Path) + if err := m.reloadPane(pane.ID, preserve); err != nil { m.status = err.Error() return m, nil } @@ -2082,13 +2101,19 @@ func (m *Model) historyForward() (tea.Model, tea.Cmd) { m.status = "No forward history" return m, nil } + // Save cursor position for the current directory before navigating away. + if selected, ok := pane.Selected(); ok && !selected.IsParent { + pane.SaveCursor(pane.Path, selected.Name) + } // Save current path to back-stack so back navigation can restore it. pane.PushHistory(pane.Path) log.Printf("[NAV] historyForward — pane=%s cur=%s next=%s", pane.ID, pane.Path, nextPath) pane.Path = nextPath pane.Cursor = 0 pane.Offset = 0 - if err := m.reloadPane(pane.ID, ""); err != nil { + // Restore cursor position if we've visited this directory before in this session. + preserve := pane.LoadCursor(pane.Path) + if err := m.reloadPane(pane.ID, preserve); err != nil { m.status = err.Error() return m, nil } @@ -2375,6 +2400,51 @@ func (m *Model) handleUnpack() (tea.Model, tea.Cmd) { return m, nil } +func (m *Model) handleMirrorPane() (tea.Model, tea.Cmd) { + active := m.activePane() + passive := m.passivePane() + + // Determine the target path from the active pane + targetPath := active.Path + log.Printf("[ACTION] MirrorPane — active=%s path=%s inRemote=%v inArchive=%v", m.active, targetPath, active.InRemote(), active.InArchive()) + + // If active is in a remote mount, clone the remote stack to passive + if mount, ok := active.CurrentRemote(); ok { + // If passive already has a remote connection, clean it up first + if existingMount, exists := passive.CurrentRemote(); exists { + existingMount.Client.Close() + passive.PopRemote() + } + // Clone the mount — don't clone the client, open a new connection + // For simplicity, just set the path; the mount will reconnect on reload + passive.ClearRemotes() + passive.Remote = append(passive.Remote, mount) + passive.Path = active.Path + if err := m.reloadRemotePane(passive.ID, ""); err != nil { + m.status = fmt.Sprintf("Mirror failed: %v", err) + return m, nil + } + } else if active.InArchive() { + // Archive mounts: set path directly, archive data stays on active pane + passive.Path = targetPath + if err := m.reloadPane(passive.ID, ""); err != nil { + m.status = fmt.Sprintf("Mirror failed: %v", err) + return m, nil + } + } else { + // Local directory — simple path copy + passive.ClearRemotes() + passive.Path = targetPath + if err := m.reloadPane(passive.ID, ""); err != nil { + m.status = fmt.Sprintf("Mirror failed: %v", err) + return m, nil + } + } + + m.status = fmt.Sprintf("Mirrored: %s", targetPath) + return m, m.loadPreviewCmd() +} + func (m *Model) handleView() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok || selected.IsParent || selected.IsDir { @@ -2925,26 +2995,19 @@ func (m *Model) openHelpModal() { " w / b move caret by word", " Ctrl+t mouse selection mode in text preview pane", " Space calculate selected directory size", - " s cycle sort mode", + " p mirror current path to opposite pane", + " s toggle SSH host list", + " g cycle sort mode", " . toggle hidden files", " t cycle theme", "", "Dialogs and Transfers", - " F8 / x delete (trash or permanent, d to toggle)", - " F11 extract archive to opposite pane", + " x delete selected entry", + " a archive selected entry", " r rename selected entry", + " n create new directory", " Enter / y confirm action", " Esc / n cancel action", - " b run copy/move in background (progress dialog)", - " c cancel active copy/move transfer", - "", - "Mouse", - " Left click select entry and activate pane", - " Double click open selected entry", - " Right click toggle preview/info mode for clicked entry", - " Wheel scroll list or preview area", - "", - "F1–F10 actions are shown in the footer.", } m.modal = modalState{ @@ -5731,10 +5794,18 @@ func (m *Model) enterRemoteDir(entry vfs.Entry) (tea.Model, tea.Cmd) { return m, nil } + // Save cursor position for the current directory before navigating away. + pane.SaveCursor(pane.Path, entry.Name) + pane.PushHistory(pane.Path) log.Printf("[NAV] enterRemoteDir — pane=%s from=%s to=%s", pane.ID, pane.Path, entry.Path) pane.Path = entry.Path - if err := m.reloadRemotePane(pane.ID, entry.Name); err != nil { + // Restore cursor position if we've visited this directory before in this session. + preserve := pane.LoadCursor(pane.Path) + if preserve == "" { + preserve = entry.Name + } + if err := m.reloadRemotePane(pane.ID, preserve); err != nil { m.status = err.Error() return m, nil } diff --git a/internal/ui/pane.go b/internal/ui/pane.go index 512f50c..b944b07 100644 --- a/internal/ui/pane.go +++ b/internal/ui/pane.go @@ -32,6 +32,10 @@ type BrowserPane struct { dirHistory []string dirFuture []string + + // cursorMemory remembers the last selected entry display name per directory + // within a session. Keyed by directory path. Restored when re-entering a dir. + cursorMemory map[string]string } type ArchiveMount struct { @@ -193,6 +197,24 @@ func (p *BrowserPane) EnsureVisible(pageSize int) { } } +func (p *BrowserPane) SaveCursor(dirPath string, entryName string) { + if dirPath == "" || entryName == "" { + return + } + if p.cursorMemory == nil { + p.cursorMemory = map[string]string{} + } + p.cursorMemory[dirPath] = entryName +} + +// LoadCursor returns the saved entry name for a directory, or empty string. +func (p *BrowserPane) LoadCursor(dirPath string) string { + if p.cursorMemory == nil { + return "" + } + return p.cursorMemory[dirPath] +} + func (p *BrowserPane) InArchive() bool { return len(p.Archive) > 0 } diff --git a/plans/mirror-and-cursor-memory.md b/plans/mirror-and-cursor-memory.md new file mode 100644 index 0000000..da56968 --- /dev/null +++ b/plans/mirror-and-cursor-memory.md @@ -0,0 +1,88 @@ +# Feature Plan: Mirror Pane & Per-Directory Cursor Memory + +## Feature 1: Mirror active pane directory to opposite pane + +### Keybinding research +- **Midnight Commander (MC)**: `Alt-i` — makes the other panel equal to current directory +- **Total Commander**: `Ctrl-PgDn` opens directory under cursor in opposite panel +- **Far Manager**: `Ctrl-Left/Right` — opens in opposite panel + +**Recommendation**: Bind to `p` (for "Pane"). `Ctrl-i` sends the same byte as `Tab` (ASCII 0x09) so it conflicts with Switch. `p` is free, accessible, and "Pane" reflects the meaning. + +### How it works +1. Read active pane's current path (including remote mount state) +2. Apply that path to the passive (opposite) pane +3. If active is in a remote mount, also clone the remote mount stack to the passive pane +4. Reload both panes to reflect the change + +### Code changes + +#### [`internal/ui/keymap.go`](internal/ui/keymap.go) +- Add `Mirror` key binding field to `KeyMap` struct +- Bind to `"p"` with help text `"p"` / `"mirror pane"` + +#### [`internal/ui/model.go`](internal/ui/model.go) +- Add handler `handleMirrorPane()`: + 1. Get active pane path and remote mount state + 2. Switch passive pane to match (copy remote stack if applicable) + 3. Reload passive pane (using `reloadPane` or `reloadRemotePane`) + 4. Set status: `"Mirrored: "` +- Add key match case before the `default` switch + +--- + +## Feature 2: Per-directory cursor position memory (session scope) + +### Current state +- `enterSelected()` saves `pane.Path` to history for back-nav, then reloads target dir with `selected.Name` as preserve key — but `selected.Name` is the name of the directory being entered, not a cursor anchor +- `goParent()` pops history and reloads parent using the child directory name as preserve — cursor lands on the directory we came from +- **Missing**: When navigating back into a previously-visited directory, there's no saved cursor position for it + +### Design +Add a `cursorMemory` field to `BrowserPane` — a `map[string]string` mapping **directory path → last selected entry display name**. + +This integrates cleanly with the existing `SetEntries(entries, preserveKey)` / `FindSelected()` infrastructure. + +### Flow + +``` +enterSelected() / enterRemoteDir(): + 1. Save to cursorMemory: pane.Path → selected entry's Name + 2. Push history (existing) + 3. Set path, reload (existing) + 4. The reload already uses preserve=selected.Name for the new dir, + but cursorMemory will help when coming back later + +reloadPane() / reloadRemotePane(): + 1. Try preserve key first (existing behavior) + 2. If preserve is empty, check cursorMemory[pane.Path] + and use that as preserveKey instead +``` + +### Code changes + +#### [`internal/ui/pane.go`](internal/ui/pane.go) +- Add field to `BrowserPane`: `CursorMemory map[string]string` +- Method `SaveCursor(path string, name string)` — stores cursor position +- Method `LoadCursor(path string) string` — retrieves saved cursor + +#### [`internal/ui/model.go`](internal/ui/model.go) +- In `enterSelected()` (line ~1886): after `pane.PushHistory(pane.Path)`, add `pane.SaveCursor(pane.Path, selected.Name)` +- In `enterRemoteDir()` (line ~5726): same save logic +- In `goParent()`: save cursor before navigating away (already partially done via history) +- In `reloadPane()` (line ~1672): after existing preserve logic, if no preserve key and directory has cursor memory, use it +- In `reloadRemotePane()` (line ~5535): same fallback + +--- + +## Files modified +| File | Changes | +|------|---------| +| `internal/ui/keymap.go` | Add `Mirror` field, binding `p` | +| `internal/ui/pane.go` | Add `CursorMemory` field + methods | +| `internal/ui/model.go` | Add `handleMirrorPane()`, save/restore cursor in navigation methods | + +## Not changed +- `internal/fs/` — storage layer unaffected +- `internal/config/` — no config changes needed +- `internal/theme/` — no theme changes needed diff --git a/vcom.toml b/vcom.toml index 80bc448..498e302 100644 --- a/vcom.toml +++ b/vcom.toml @@ -4,7 +4,7 @@ right_path = '' [ui] app_title = 'vcom' -theme = 'one-dark' +theme = 'ayu-dark' icon_mode = 'auto' show_title_bar = true show_footer = true