Skip to content

Commit d25de48

Browse files
committed
UX: Volume-boundary navigation, SMB native warning
- `resolveValidPath` stops at `volumeRoot` instead of walking above the mount boundary. Prevents trying to list `/Volumes` through SmbVolume (which fails with STATUS_OBJECT_NAME_NOT_FOUND). - When the volume root is unreachable, `navigateToFallback` switches to the root volume (`~`) instead of trying to list `~` on the SMB share. - Transfer dialog and progress dialog show a warning for yellow-state (os_mount) SMB volumes: "This share uses the system connection. Cancel and rollback may be delayed."
1 parent d10d9cc commit d25de48

5 files changed

Lines changed: 83 additions & 19 deletions

File tree

apps/desktop/src/lib/file-explorer/navigation/path-navigation.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,24 @@ export interface ResolveValidPathOptions {
6262
pathExistsFn?: (path: string) => Promise<boolean>
6363
/** Timeout per step in ms. Set to 0 to skip timeout wrapping. Defaults to 1000. */
6464
timeoutMs?: number
65+
/**
66+
* Volume root path (like "/Volumes/naspi"). When set, the walk-up stops at this
67+
* boundary instead of continuing to "/" — prevents crossing into a different volume
68+
* (which would fail for non-local volumes like SmbVolume).
69+
*/
70+
volumeRoot?: string
6571
}
6672

6773
/**
6874
* Resolves a path to a valid existing path by walking up the parent tree.
6975
* Each step has a timeout to prevent hanging on dead mounts (default 1s).
70-
* Fallback chain: parent tree → user home (~) → filesystem root (/).
76+
* Fallback chain: parent tree (up to volumeRoot) → user home (~) → filesystem root (/).
7177
* Returns null if even the root doesn't exist (volume unmounted).
7278
*/
7379
export async function resolveValidPath(targetPath: string, options?: ResolveValidPathOptions): Promise<string | null> {
7480
const checkFn = options?.pathExistsFn ?? pathExists
7581
const timeoutMs = options?.timeoutMs ?? 1000
82+
const volumeRoot = options?.volumeRoot
7683

7784
const check = (p: string): Promise<boolean> =>
7885
timeoutMs > 0 ? withTimeout(checkFn(p), timeoutMs, false) : checkFn(p)
@@ -82,6 +89,10 @@ export async function resolveValidPath(targetPath: string, options?: ResolveVali
8289
if (await check(path)) {
8390
return path
8491
}
92+
// Don't walk above the volume root — that crosses into a different volume
93+
if (volumeRoot && path === volumeRoot) {
94+
break
95+
}
8596
// Go to parent
8697
const lastSlash = path.lastIndexOf('/')
8798
path = lastSlash > 0 ? path.substring(0, lastSlash) : '/'

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,8 +619,15 @@
619619
620620
// Edge case: tab opened directly at this path, no history. Walk up to nearest valid parent.
621621
const parentPath = entry.path.substring(0, Math.max(1, entry.path.lastIndexOf('/')))
622-
void resolveValidPath(parentPath).then((validPath) => {
622+
const volumeRoot = volumes.find((v) => v.id === entry.volumeId)?.path
623+
void resolveValidPath(parentPath, { volumeRoot }).then((validPath) => {
623624
const target = validPath ?? '~'
625+
const isOutsideVolume = entry.volumeId !== 'root' && (target === '~' || target === '/')
626+
if (isOutsideVolume) {
627+
// Volume root unreachable — switch to root volume
628+
setPaneVolumeId(pane, 'root')
629+
saveAppStatus({ [paneKey(pane, 'volumeId')]: 'root' })
630+
}
624631
setPanePath(pane, target)
625632
saveAppStatus({ [paneKey(pane, 'path')]: target })
626633
saveTabsForPaneSide(pane)

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

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,24 @@
708708
pendingLoadReject = null
709709
}
710710
711+
/**
712+
* Navigates to a fallback path after the current path became invalid.
713+
* If the resolved path is outside the current volume (~ or /), switches
714+
* to the root volume instead of trying to list it on a non-root volume.
715+
*/
716+
function navigateToFallback(validPath: string | null) {
717+
const target = validPath ?? '~'
718+
const isOutsideVolume = volumeId !== 'root' && (target === '~' || target === '/')
719+
if (isOutsideVolume && onVolumeChange) {
720+
// The volume root was unreachable — switch to the root volume
721+
log.info('Volume root unreachable, switching to root volume with path: {target}', { target })
722+
onVolumeChange('root', '/', target)
723+
} else {
724+
currentPath = target
725+
void loadDirectory(target)
726+
}
727+
}
728+
711729
async function loadDirectory(path: string, selectName?: string) {
712730
// Cancel any active rename when navigating
713731
rename.cancel()
@@ -826,10 +844,8 @@
826844
log.info('Listing error for deleted path, navigating to valid parent: {path}', {
827845
path: loadPath,
828846
})
829-
void resolveValidPath(loadPath).then((validPath) => {
830-
const target = validPath ?? volumePath
831-
currentPath = target
832-
void loadDirectory(target)
847+
void resolveValidPath(loadPath, { volumeRoot: volumePath }).then((validPath) => {
848+
navigateToFallback(validPath)
833849
})
834850
} else {
835851
// Path exists but has another error (permission denied, etc.)
@@ -1496,11 +1512,8 @@
14961512
path: event.payload.path,
14971513
})
14981514
1499-
void resolveValidPath(currentPath).then((validPath) => {
1500-
const target = validPath ?? volumePath
1501-
currentPath = target
1502-
// loadDirectory handles onPathChange via handleListingComplete
1503-
void loadDirectory(target)
1515+
void resolveValidPath(currentPath, { volumeRoot: volumePath }).then((validPath) => {
1516+
navigateToFallback(validPath)
15041517
})
15051518
})
15061519
@@ -1635,20 +1648,16 @@
16351648
'Directory {dir} no longer exists, navigating to nearest valid parent under {volume}',
16361649
{ dir: currentPath, volume: volumePath },
16371650
)
1638-
void resolveValidPath(currentPath).then((validPath) => {
1639-
const target = validPath ?? volumePath
1640-
currentPath = target
1641-
void loadDirectory(target)
1651+
void resolveValidPath(currentPath, { volumeRoot: volumePath }).then((validPath) => {
1652+
navigateToFallback(validPath)
16421653
})
16431654
})
16441655
} else {
16451656
log.info('Directory {dir} no longer exists, navigating to nearest valid parent', {
16461657
dir: currentPath,
16471658
})
1648-
void resolveValidPath(currentPath).then((validPath) => {
1649-
const target = validPath ?? volumePath
1650-
currentPath = target
1651-
void loadDirectory(target)
1659+
void resolveValidPath(currentPath, { volumeRoot: volumePath }).then((validPath) => {
1660+
navigateToFallback(validPath)
16521661
})
16531662
}
16541663
})

apps/desktop/src/lib/file-operations/transfer/TransferDialog.svelte

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,13 @@
419419
{/if}
420420
</div>
421421

422+
{#if selectedVolume?.smbConnectionState === 'os_mount'}
423+
<p class="smb-native-note">
424+
This share uses the system connection. Cancellation may be delayed.
425+
Use "Connect directly" in the volume picker for faster transfers and reliable cancel.
426+
</p>
427+
{/if}
428+
422429
<!-- Path input -->
423430
<div class="path-input-group">
424431
<input
@@ -567,6 +574,15 @@
567574
color: var(--color-error);
568575
}
569576
577+
.smb-native-note {
578+
margin: 0;
579+
padding: var(--spacing-xs) var(--spacing-sm);
580+
font-size: var(--font-size-xs);
581+
color: var(--color-warning);
582+
background: var(--color-warning-bg);
583+
border-radius: var(--radius-sm);
584+
}
585+
570586
.button-row {
571587
display: flex;
572588
gap: var(--spacing-md);

apps/desktop/src/lib/file-operations/transfer/TransferProgressDialog.svelte

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
SortOrder,
3838
ConflictResolution,
3939
} from '$lib/file-explorer/types'
40+
import { getVolumes } from '$lib/stores/volume-store.svelte'
4041
import { formatDate } from '$lib/file-explorer/selection/selection-info-utils'
4142
import { formatFileSize } from '$lib/settings/reactive-settings.svelte'
4243
import { getSetting } from '$lib/settings'
@@ -123,6 +124,10 @@
123124
const isDeleteOrTrash = $derived(operationType === 'delete' || operationType === 'trash')
124125
const isCopy = $derived(operationType === 'copy')
125126
const isMove = $derived(operationType === 'move')
127+
const volumes = $derived(getVolumes())
128+
const destUsesNativeSmb = $derived(
129+
volumes.find((v) => v.id === destVolumeId)?.smbConnectionState === 'os_mount',
130+
)
126131
127132
/** Whether this move involves a non-local volume (MTP, etc.) — backend handles all strategy. */
128133
const isVolumeMove = $derived(
@@ -947,6 +952,12 @@
947952
</div>
948953
{/if}
949954

955+
{#if destUsesNativeSmb}
956+
<p class="smb-native-note">
957+
This share uses the system connection. Cancel and rollback may be delayed.
958+
</p>
959+
{/if}
960+
950961
<!-- Action buttons -->
951962
<div class="button-row">
952963
<Button variant="secondary" onclick={() => handleCancel(false)} disabled={isCancelling}>Cancel</Button>
@@ -1123,6 +1134,16 @@
11231134
}
11241135
11251136
/* Buttons */
1137+
.smb-native-note {
1138+
margin: 0 var(--spacing-xl);
1139+
padding: var(--spacing-xs) var(--spacing-sm);
1140+
font-size: var(--font-size-xs);
1141+
color: var(--color-warning);
1142+
background: var(--color-warning-bg);
1143+
border-radius: var(--radius-sm);
1144+
text-align: center;
1145+
}
1146+
11261147
.button-row {
11271148
display: flex;
11281149
gap: var(--spacing-md);

0 commit comments

Comments
 (0)