Skip to content

Commit a3e15f4

Browse files
committed
Feature: copy path between panes with ⌘← / ⌘→
- Two new commands ("Copy path from left to right pane" / "right to left"), customizable in Settings → Shortcuts. - When fired from the source pane with the cursor on a folder, opens that folder on the target. Otherwise opens the source pane's current dir on the target. - Symlinks-to-directories are followed; symlinks-to-files are treated as files. - Works across volumes; target switches volume automatically. - Network browser is mirrored: cursor-on-server opens that server's share list on the target, cursor-on-share auto-mounts the share on the target (full credential flow if needed). - Focus stays on the source pane after the action — user keeps working where they were. - `⌘←` / `⌘→` pass through to text inputs (rename editor, palette search, etc.) so macOS line-start/end still works there. - Plain `←` / `→` keep their existing cursor behavior; only ⌘+arrows are reserved for the new commands. Bugfix: drop stale `onPathChange` events that arrive on a pane after it was switched to the virtual `network` volume. Without the gate, a slow listing-complete from the prior SMB folder corrupted `panePath` and `lastUsedPathForVolume('network')` (visible as "Network ▸ /Volumes/<share>/<subdir>" in the breadcrumb). Pre-existing, unmasked by the new feature's volume-switching flow. `ShareBrowser` auto-mount gate now fires once per distinct prop value (was once per component instance) so the new cursor-on-share path can mount a different share on the same host without forcing a remount.
1 parent 7ad142b commit a3e15f4

12 files changed

Lines changed: 322 additions & 11 deletions

File tree

apps/desktop/src/lib/commands/command-registry.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,24 @@ export const commands: Command[] = [
152152
showInPalette: true,
153153
shortcuts: ['⌥F2'],
154154
},
155+
{
156+
id: 'pane.copyPathLeftToRight',
157+
name: 'Copy path from left to right pane',
158+
scope: 'Main window',
159+
showInPalette: true,
160+
shortcuts: ['⌘→'],
161+
description:
162+
'Open the left pane’s location on the right. When the left pane is focused and the cursor is on a folder, that folder opens on the right instead.',
163+
},
164+
{
165+
id: 'pane.copyPathRightToLeft',
166+
name: 'Copy path from right to left pane',
167+
scope: 'Main window',
168+
showInPalette: true,
169+
shortcuts: ['⌘←'],
170+
description:
171+
'Open the right pane’s location on the left. When the right pane is focused and the cursor is on a folder, that folder opens on the left instead.',
172+
},
155173

156174
// ============================================================================
157175
// Main window - Tab commands

apps/desktop/src/lib/file-explorer/CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,15 @@ finished importing during the rebuild. This is a SvelteKit bug (sveltejs/kit#152
357357
crash and forces a clean page reload. The recovery listener is imported from `+layout.ts` (a stable module that survives
358358
layout component re-evaluation). If sveltejs/kit#15287 gets fixed, the workaround can be removed.
359359

360+
**Stale `onPathChange` from a slow listing can poison a pane after a volume switch.** `FilePane.onPathChange` fires on
361+
`listing-complete` for whatever path the pane was loading. If the user (or a command like "Copy path between panes")
362+
flips the pane to a different volume — especially the virtual `network` volume — between `listing-start` and
363+
`listing-complete`, the stale callback lands on a pane whose `volumeId` no longer matches the path it carries.
364+
`applyPathChange` in `DualPaneExplorer` guards against the `network` case (drops paths that don't start with `smb://`)
365+
because `pushPath` inherits the current `volumeId` and would otherwise write a malformed `{network, /Volumes/...}`
366+
history entry plus a corrupted `lastUsedPathForVolume('network')`. If you introduce another virtual-volume namespace
367+
with its own non-filesystem prefix, extend the guard.
368+
360369
## Views (`views/`)
361370

362371
`BriefList` and `FullList` virtual-scrolling components. Brief-mode column widths come from the

apps/desktop/src/lib/file-explorer/network/CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ row. Clicking it calls `forgetCredentials` and clears `authenticatedCredentials`
106106

107107
Shares displayed sorted case-insensitively. Escape/Backspace go back to host list.
108108

109+
The `autoMountShare` prop fires once per distinct value (tracked via `lastAutoMountAttempt`), not once per
110+
`ShareBrowser` instance. This lets the "Copy path between panes" command auto-mount a different share without forcing a
111+
remount when the source pane's cursor moves to another share on the same host.
112+
109113
## `NetworkLoginForm.svelte`
110114

111115
Props: `host`, `shareName?`, `authMode`, `errorMessage?`, `isConnecting?`, `onConnect`, `onCancel`.

apps/desktop/src/lib/file-explorer/network/NetworkBrowser.svelte

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,18 @@
205205
return hosts.findIndex((h) => h.name.toLowerCase() === name.toLowerCase())
206206
}
207207
208+
/**
209+
* Returns the host under the cursor, or `null` when the cursor sits on the
210+
* "Connect to server…" pseudo-row or the list is empty. Consumed by the
211+
* "Copy path between panes" command so cursor-on-server mirrors that server.
212+
*/
213+
// noinspection JSUnusedGlobalSymbols -- used dynamically by NetworkMountView
214+
export function getHostUnderCursor(): NetworkHost | null {
215+
if (isCursorOnConnectRow) return null
216+
if (cursorIndex < 0 || cursorIndex >= hosts.length) return null
217+
return hosts[cursorIndex]
218+
}
219+
208220
/** Opens the host (or "Connect to server…" row) under the cursor — same action Enter triggers. */
209221
// noinspection JSUnusedGlobalSymbols -- used dynamically by NetworkMountView / MCP
210222
export function openCursorItem(): void {
@@ -289,6 +301,11 @@
289301
return true
290302
}
291303
304+
// ⌘← / ⌘→ are reserved for "Copy path between panes" (document-level
305+
// dispatch), so let them bubble instead of jumping the cursor.
306+
if (e.metaKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
307+
return false
308+
}
292309
if (handleArrowAndEnter(e.key)) {
293310
e.preventDefault()
294311
return true

apps/desktop/src/lib/file-explorer/network/ShareBrowser.svelte

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,10 @@
7878
// Track authenticated credentials for mounting
7979
let authenticatedCredentials = $state<{ username: string; password: string } | null>(null)
8080
81-
// Auto-mount tracking (for smb://host/share URLs)
82-
let autoMountAttempted = $state(false)
81+
// Auto-mount tracking: track the last share we tried so the same prop value
82+
// doesn't re-fire, but a new value (for example via "Copy path between panes"
83+
// with cursor on a different share) does.
84+
let lastAutoMountAttempt = $state<string | undefined>(undefined)
8385
8486
// Container tracking for PageUp/PageDown
8587
let listContainer: HTMLDivElement | undefined = $state()
@@ -98,11 +100,12 @@
98100
void syncPaneStateToMcp()
99101
})
100102
101-
// Auto-mount a share if requested (from smb://host/share URL)
103+
// Auto-mount a share if requested (from smb://host/share URL, or "Copy path
104+
// between panes" with cursor on a share). Fires once per distinct prop value.
102105
$effect(() => {
103106
const shareName = autoMountShare
104-
if (autoMountAttempted || !shareName || loading || sortedShares.length === 0) return
105-
autoMountAttempted = true
107+
if (!shareName || shareName === lastAutoMountAttempt || loading || sortedShares.length === 0) return
108+
lastAutoMountAttempt = shareName
106109
107110
const match = sortedShares.find(
108111
(s) => s.name.localeCompare(shareName, undefined, { sensitivity: 'base' }) === 0,
@@ -317,6 +320,19 @@
317320
return sortedShares.findIndex((s) => s.name.toLowerCase() === name.toLowerCase())
318321
}
319322
323+
/**
324+
* Returns the share under the cursor, or `null` when nothing valid is highlighted
325+
* (login form, empty list, out-of-range index). Consumed by the
326+
* "Copy path between panes" command so cursor-on-share mounts that share on the
327+
* target pane.
328+
*/
329+
// noinspection JSUnusedGlobalSymbols -- used dynamically by NetworkMountView
330+
export function getShareUnderCursor(): ShareInfo | null {
331+
if (showLoginForm) return null
332+
if (cursorIndex < 0 || cursorIndex >= sortedShares.length) return null
333+
return sortedShares[cursorIndex]
334+
}
335+
320336
/** Opens the share under the cursor — same action Enter triggers. */
321337
// noinspection JSUnusedGlobalSymbols -- used dynamically by NetworkMountView / MCP
322338
export function openCursorItem(): void {
@@ -368,6 +384,11 @@
368384
return true
369385
}
370386
387+
/** `⌘←` / `⌘→` belong to "Copy path between panes" (document-level dispatch). */
388+
function isCopyPathBetweenPanesShortcut(e: KeyboardEvent): boolean {
389+
return e.metaKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')
390+
}
391+
371392
export function handleKeyDown(e: KeyboardEvent): boolean {
372393
if (showLoginForm) {
373394
// Login form handles its own keyboard events
@@ -394,7 +415,7 @@
394415
return true
395416
}
396417
397-
// Handle arrow keys
418+
if (isCopyPathBetweenPanesShortcut(e)) return false
398419
if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
399420
e.preventDefault()
400421
return handleArrowKey(e.key)

apps/desktop/src/lib/file-explorer/pane/DualPaneExplorer.svelte

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,17 @@
464464
465465
/** Applies a path change to the active tab in-place (the normal non-pinned flow). */
466466
function applyPathChange(pane: 'left' | 'right', path: string) {
467+
// Drop stale `onPathChange` events that arrive after the pane was
468+
// switched to the virtual `network` volume. Without this gate, a
469+
// listing-complete from the previous SMB folder lands after the user
470+
// (or a command like "Copy path between panes") flips to Network, and
471+
// `pushPath` inherits `volumeId='network'` while writing a real path
472+
// — corrupting both `pane.path` and `lastUsedPathForVolume('network')`.
473+
const currentVolumeId = getPaneVolumeId(pane)
474+
if (currentVolumeId === 'network' && !path.startsWith('smb://')) {
475+
log.debug('Dropping stale onPathChange on network pane: {path}', { path })
476+
return
477+
}
467478
setPanePath(pane, path)
468479
setPaneHistory(pane, pushPath(getPaneHistory(pane), path))
469480
saveAppStatus({ [paneKey(pane, 'path')]: path })
@@ -2377,6 +2388,103 @@
23772388
return false
23782389
}
23792390
2391+
/**
2392+
* "Copy path from <source> to <target> pane" command. Mirrors the source
2393+
* pane's location (volume + path + network state) into the target pane,
2394+
* without shifting keyboard focus. When the source pane is focused, the
2395+
* cursor refines the destination: cursor-on-folder uses the folder's path;
2396+
* cursor-on-server (network browser) sets the target's selected host;
2397+
* cursor-on-share (share browser) queues auto-mount on the target.
2398+
*/
2399+
export function copyPathBetweenPanes(source: 'left' | 'right', target: 'left' | 'right'): void {
2400+
if (source === target) return
2401+
const sourcePaneRef = getPaneRef(source)
2402+
if (!sourcePaneRef) return
2403+
2404+
const sourceVolumeId = getPaneVolumeId(source)
2405+
const sourcePath = getPanePath(source)
2406+
const sourceHistoryEntry = getCurrentEntry(getPaneHistory(source))
2407+
const sourceHost = sourceHistoryEntry.networkHost ?? null
2408+
const sourceFocused = focusedPane === source
2409+
2410+
// Normal listing on the source: cursor-on-folder refines the path.
2411+
if (sourceVolumeId !== 'network') {
2412+
let destPath = sourcePath
2413+
if (sourceFocused) {
2414+
const entry = sourcePaneRef.getCursorEntry()
2415+
if (entry && entry.isDirectory && entry.name !== '..') {
2416+
destPath = entry.path
2417+
}
2418+
}
2419+
mirrorLocalStateToPane(target, sourceVolumeId, destPath)
2420+
return
2421+
}
2422+
2423+
// Source is on the network volume (host list or share list).
2424+
let destHost: NetworkHost | null = sourceHost
2425+
let destAutoMountShare: string | undefined
2426+
if (sourceFocused) {
2427+
const cursor = sourcePaneRef.getNetworkCursorEntry()
2428+
if (cursor?.kind === 'host') {
2429+
destHost = cursor.host
2430+
} else if (cursor?.kind === 'share' && sourceHost) {
2431+
destAutoMountShare = cursor.share.name
2432+
}
2433+
}
2434+
mirrorNetworkStateToPane(target, destHost, destAutoMountShare)
2435+
}
2436+
2437+
/** Helper: mirror a {volumeId, path} state to a target pane without shifting focus. */
2438+
function mirrorLocalStateToPane(target: 'left' | 'right', volumeId: string, path: string): void {
2439+
const originalFocused = focusedPane
2440+
const targetVolumeId = getPaneVolumeId(target)
2441+
if (targetVolumeId !== volumeId) {
2442+
const volumePath = volumes.find((v) => v.id === volumeId)?.path ?? path
2443+
handleVolumeChange(target, volumeId, volumePath, path)
2444+
} else if (getPanePath(target) === path) {
2445+
// Already on the same volume and path; nothing to do.
2446+
} else {
2447+
setPanePath(target, path)
2448+
setPaneHistory(target, pushPath(getPaneHistory(target), path))
2449+
saveAppStatus({ [paneKey(target, 'path')]: path })
2450+
saveTabsForPaneSide(target)
2451+
void getPaneRef(target)?.navigateToPath(path)
2452+
}
2453+
restoreFocus(originalFocused)
2454+
}
2455+
2456+
/** Helper: mirror a network state ({host, autoMountShare}) to a target pane without shifting focus. */
2457+
function mirrorNetworkStateToPane(
2458+
target: 'left' | 'right',
2459+
host: NetworkHost | null,
2460+
autoMountShare: string | undefined,
2461+
): void {
2462+
const originalFocused = focusedPane
2463+
const targetPaneRef = getPaneRef(target)
2464+
if (getPaneVolumeId(target) !== 'network') {
2465+
handleVolumeChange(target, 'network', 'smb://', 'smb://')
2466+
}
2467+
targetPaneRef?.setNetworkHost(host)
2468+
setPaneHistory(
2469+
target,
2470+
push(getPaneHistory(target), {
2471+
volumeId: 'network',
2472+
path: 'smb://',
2473+
networkHost: host ?? undefined,
2474+
}),
2475+
)
2476+
targetPaneRef?.setNetworkAutoMount(autoMountShare)
2477+
restoreFocus(originalFocused)
2478+
}
2479+
2480+
/** Restore focus to a pane after a target-pane state change so the user keeps working where they were. */
2481+
function restoreFocus(pane: 'left' | 'right'): void {
2482+
if (focusedPane !== pane) {
2483+
focusedPane = pane
2484+
saveAppStatus({ focusedPane: pane })
2485+
}
2486+
}
2487+
23802488
/**
23812489
* Refresh the focused pane.
23822490
* Used by MCP refresh tool.

apps/desktop/src/lib/file-explorer/pane/FilePane.svelte

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@
239239
// Disk space info for the current volume (fetched on mount, volume change, and after file ops)
240240
let volumeSpace: VolumeSpaceInfo | null = $state(null)
241241
242-
import type { ListViewAPI, VolumeBreadcrumbAPI, NetworkMountViewAPI } from './types'
242+
import type { ListViewAPI, VolumeBreadcrumbAPI, NetworkMountViewAPI, NetworkCursorEntry } from './types'
243243
244244
// Component refs for keyboard navigation
245245
let fullListRef: ListViewAPI | undefined = $state()
@@ -436,6 +436,9 @@
436436
437437
// Network browsing state - tracked here for history navigation integration
438438
let currentNetworkHost = $state<NetworkHost | null>(null)
439+
// Pending share to auto-mount on the network host. Set by "Copy path between
440+
// panes" when the source pane has the cursor on a share. Cleared on volume leave.
441+
let pendingAutoMountShare = $state<string | undefined>(undefined)
439442
440443
// Clear the selected network host whenever the pane leaves the network
441444
// volume so that re-entering Network always lands on the host list, not on
@@ -453,6 +456,9 @@
453456
if (!isNetworkView && currentNetworkHost !== null) {
454457
currentNetworkHost = null
455458
}
459+
if (!isNetworkView && pendingAutoMountShare !== undefined) {
460+
pendingAutoMountShare = undefined
461+
}
456462
})
457463
458464
// noinspection JSUnusedGlobalSymbols -- Used dynamically
@@ -504,6 +510,28 @@
504510
return entryUnderCursor?.path
505511
}
506512
513+
/**
514+
* The full `FileEntry` under the cursor (or `null`). Used by the
515+
* "Copy path between panes" command to detect whether the cursor sits on
516+
* a directory (incl. symlinks-to-directories) vs. a file or `..`.
517+
* `..` is reported as-is (as a synthetic parent entry); callers should
518+
* filter on `name === '..'` if needed.
519+
*/
520+
// noinspection JSUnusedGlobalSymbols -- used by DualPaneExplorer.copyPathBetweenPanes
521+
export function getCursorEntry(): FileEntry | null {
522+
return entryUnderCursor
523+
}
524+
525+
/**
526+
* The network browser's cursor target (host or share), or `null` when
527+
* this pane is not in the network view or nothing valid is under the cursor.
528+
*/
529+
// noinspection JSUnusedGlobalSymbols -- used by DualPaneExplorer.copyPathBetweenPanes
530+
export function getNetworkCursorEntry(): NetworkCursorEntry | null {
531+
if (!isNetworkView) return null
532+
return networkMountViewRef?.getNetworkCursorEntry() ?? null
533+
}
534+
507535
/** Also scrolls to make the cursor visible and syncs state to MCP. */
508536
export async function setCursorIndex(index: number): Promise<void> {
509537
if (isNetworkView) {
@@ -833,6 +861,17 @@
833861
networkMountViewRef?.setNetworkHost(host)
834862
}
835863
864+
/**
865+
* Queues a share to auto-mount once `NetworkMountView`'s `ShareBrowser` is ready.
866+
* Survives a not-yet-mounted view because the value is held here and re-passed
867+
* via the `initialAutoMountShare` prop. Cleared automatically when the pane
868+
* leaves the network volume.
869+
*/
870+
// noinspection JSUnusedGlobalSymbols -- used by DualPaneExplorer.copyPathBetweenPanes
871+
export function setNetworkAutoMount(shareName: string | undefined): void {
872+
pendingAutoMountShare = shareName
873+
}
874+
836875
/** Navigates up and selects the folder we came from. Returns false if already at root. */
837876
export async function navigateToParent(): Promise<boolean> {
838877
if (currentPath === '/' || currentPath === volumePath) {
@@ -1557,8 +1596,19 @@
15571596
// fetchEntryUnderCursor is handled by the $effect tracking cursorIndex
15581597
}
15591598
1599+
/**
1600+
* `⌘←` / `⌘→` belong to "Copy path between panes" (document-level dispatch).
1601+
* Bail so the local pane handlers don't also move the cursor when those
1602+
* shortcuts fire. Other modifier + arrow combos keep their existing behavior.
1603+
*/
1604+
function isShortcutModifierArrow(e: KeyboardEvent): boolean {
1605+
if (!e.metaKey) return false
1606+
return e.key === 'ArrowLeft' || e.key === 'ArrowRight'
1607+
}
1608+
15601609
// Helper: Handle brief mode key navigation
15611610
function handleBriefModeKeys(e: KeyboardEvent): boolean {
1611+
if (isShortcutModifierArrow(e)) return false
15621612
const result = briefListRef?.handleKeyNavigation?.(e.key, e)
15631613
if (result !== undefined) {
15641614
e.preventDefault()
@@ -1570,6 +1620,7 @@
15701620
15711621
// Helper: Handle full mode key navigation
15721622
function handleFullModeKeys(e: KeyboardEvent): boolean {
1623+
if (isShortcutModifierArrow(e)) return false
15731624
const visibleItems: number = fullListRef?.getVisibleItemsCount?.() ?? 20
15741625
const shortcutResult = handleNavigationShortcut(e, {
15751626
currentIndex: cursorIndex,
@@ -2355,6 +2406,7 @@
23552406
{paneId}
23562407
{isFocused}
23572408
initialNetworkHost={currentNetworkHost}
2409+
initialAutoMountShare={pendingAutoMountShare}
23582410
{onVolumeChange}
23592411
onNetworkHostChange={handleNetworkHostChange}
23602412
/>

0 commit comments

Comments
 (0)