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
This commit is contained in:
vrubelroman 2026-04-29 16:03:34 +03:00
parent c8d6976030
commit cd877ab584
5 changed files with 201 additions and 18 deletions

View file

@ -15,6 +15,7 @@ type KeyMap struct {
CycleTheme key.Binding CycleTheme key.Binding
CycleSort key.Binding CycleSort key.Binding
SSH key.Binding SSH key.Binding
Mirror key.Binding
Up key.Binding Up key.Binding
Down key.Binding Down key.Binding
SelectUp key.Binding SelectUp key.Binding
@ -55,6 +56,7 @@ func DefaultKeyMap() KeyMap {
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")), CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")),
SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")), 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")), Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), 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")), SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")),

View file

@ -1257,6 +1257,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleDelete() return m.handleDelete()
case key.Matches(msg, m.keys.Unpack): case key.Matches(msg, m.keys.Unpack):
return m.handleUnpack() return m.handleUnpack()
case key.Matches(msg, m.keys.Mirror):
return m.handleMirrorPane()
case key.Matches(msg, m.keys.SSH): case key.Matches(msg, m.keys.SSH):
log.Printf("[KEY] SSH toggle — active=%s path=%s", m.active, activePane.Path) log.Printf("[KEY] SSH toggle — active=%s path=%s", m.active, activePane.Path)
return m.handleSSHToggle() 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." m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor."
return nil 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. // Save current directory to history before navigating.
pane.PushHistory(pane.Path) pane.PushHistory(pane.Path)
log.Printf("[NAV] enterSelected — pane=%s from=%s to=%s", pane.ID, pane.Path, selected.Path) log.Printf("[NAV] enterSelected — pane=%s from=%s to=%s", pane.ID, pane.Path, selected.Path)
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 return err
} }
m.status = fmt.Sprintf("Entered %s", pane.Path) m.status = fmt.Sprintf("Entered %s", pane.Path)
@ -1998,6 +2004,9 @@ func (m *Model) goParent() error {
return nil return nil
} }
// Inside remote subdirectory — go up one level // 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) pane.PushHistory(pane.Path)
parent := path.Dir(current) parent := path.Dir(current)
if parent == "." { if parent == "." {
@ -2037,6 +2046,10 @@ func (m *Model) goParent() error {
return nil 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) parent := filepath.Dir(pane.Path)
if parent == pane.Path { if parent == pane.Path {
return nil return nil
@ -2060,13 +2073,19 @@ func (m *Model) historyBack() (tea.Model, tea.Cmd) {
m.status = "No directory history" m.status = "No directory history"
return m, nil 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. // Save current path to forward-stack so forward navigation can restore it.
pane.PushFuture(pane.Path) pane.PushFuture(pane.Path)
log.Printf("[NAV] historyBack — pane=%s cur=%s prev=%s", pane.ID, pane.Path, prevPath) log.Printf("[NAV] historyBack — pane=%s cur=%s prev=%s", pane.ID, pane.Path, prevPath)
pane.Path = prevPath pane.Path = prevPath
pane.Cursor = 0 pane.Cursor = 0
pane.Offset = 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() m.status = err.Error()
return m, nil return m, nil
} }
@ -2082,13 +2101,19 @@ func (m *Model) historyForward() (tea.Model, tea.Cmd) {
m.status = "No forward history" m.status = "No forward history"
return m, nil 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. // Save current path to back-stack so back navigation can restore it.
pane.PushHistory(pane.Path) pane.PushHistory(pane.Path)
log.Printf("[NAV] historyForward — pane=%s cur=%s next=%s", pane.ID, pane.Path, nextPath) log.Printf("[NAV] historyForward — pane=%s cur=%s next=%s", pane.ID, pane.Path, nextPath)
pane.Path = nextPath pane.Path = nextPath
pane.Cursor = 0 pane.Cursor = 0
pane.Offset = 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() m.status = err.Error()
return m, nil return m, nil
} }
@ -2375,6 +2400,51 @@ func (m *Model) handleUnpack() (tea.Model, tea.Cmd) {
return m, nil 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) { func (m *Model) handleView() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected() selected, ok := m.activePane().Selected()
if !ok || selected.IsParent || selected.IsDir { if !ok || selected.IsParent || selected.IsDir {
@ -2925,26 +2995,19 @@ func (m *Model) openHelpModal() {
" w / b move caret by word", " w / b move caret by word",
" Ctrl+t mouse selection mode in text preview pane", " Ctrl+t mouse selection mode in text preview pane",
" Space calculate selected directory size", " 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", " . toggle hidden files",
" t cycle theme", " t cycle theme",
"", "",
"Dialogs and Transfers", "Dialogs and Transfers",
" F8 / x delete (trash or permanent, d to toggle)", " x delete selected entry",
" F11 extract archive to opposite pane", " a archive selected entry",
" r rename selected entry", " r rename selected entry",
" n create new directory",
" Enter / y confirm action", " Enter / y confirm action",
" Esc / n cancel 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",
"",
"F1F10 actions are shown in the footer.",
} }
m.modal = modalState{ m.modal = modalState{
@ -5731,10 +5794,18 @@ func (m *Model) enterRemoteDir(entry vfs.Entry) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
// Save cursor position for the current directory before navigating away.
pane.SaveCursor(pane.Path, entry.Name)
pane.PushHistory(pane.Path) pane.PushHistory(pane.Path)
log.Printf("[NAV] enterRemoteDir — pane=%s from=%s to=%s", pane.ID, pane.Path, entry.Path) log.Printf("[NAV] enterRemoteDir — pane=%s from=%s to=%s", pane.ID, pane.Path, entry.Path)
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() m.status = err.Error()
return m, nil return m, nil
} }

View file

@ -32,6 +32,10 @@ type BrowserPane struct {
dirHistory []string dirHistory []string
dirFuture []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 { 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 { func (p *BrowserPane) InArchive() bool {
return len(p.Archive) > 0 return len(p.Archive) > 0
} }

View file

@ -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: <path>"`
- 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

View file

@ -4,7 +4,7 @@ right_path = ''
[ui] [ui]
app_title = 'vcom' app_title = 'vcom'
theme = 'one-dark' theme = 'ayu-dark'
icon_mode = 'auto' icon_mode = 'auto'
show_title_bar = true show_title_bar = true
show_footer = true show_footer = true