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:
parent
c8d6976030
commit
cd877ab584
5 changed files with 201 additions and 18 deletions
|
|
@ -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")),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
"",
|
|
||||||
"F1–F10 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
88
plans/mirror-and-cursor-memory.md
Normal file
88
plans/mirror-and-cursor-memory.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue