Skip to content

Commit 8d7c644

Browse files
committed
Fallback to ~ instead of / everywhere
- Unified two duplicate path-resolution functions (`resolvePathWithFallback` in `app-status-store.ts` and `resolveValidPath` in `path-navigation.ts`) into one - The startup version had an inconsistent fallback order (parents → `/` → `~`), now matches the runtime version (parents → `~` → `/`) - `resolveValidPath` now accepts optional `{ pathExistsFn, timeoutMs }` for startup use (no timeout, injected checker) - Added 5 new tests covering the new options - Updated `navigation/CLAUDE.md`
1 parent 9188864 commit 8d7c644

File tree

4 files changed

+93
-37
lines changed

4 files changed

+93
-37
lines changed

apps/desktop/src/lib/app-status-store.ts

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import type { Store } from '@tauri-apps/plugin-store'
55
import type { SortColumn } from './file-explorer/types'
66
import { defaultSortOrders } from './file-explorer/types'
77
import type { PersistedTab, PersistedPaneTabs } from './file-explorer/tabs/tab-types'
8+
import { resolveValidPath } from './file-explorer/navigation/path-navigation'
89

910
const STORE_NAME = 'app-status.json'
1011
const DEFAULT_PATH = '~'
11-
const ROOT_PATH = '/'
1212
const DEFAULT_VOLUME_ID = 'root'
1313
const DEFAULT_SORT_BY: SortColumn = 'name'
1414

@@ -53,31 +53,12 @@ async function getStore(): Promise<Store> {
5353
}
5454

5555
/**
56-
* Resolves a path with fallback logic.
57-
* If the path doesn't exist, tries parent directories up to root.
58-
* Falls back to home (~) if nothing exists.
56+
* Resolves a persisted path, falling back to ~ if nothing exists.
57+
* Uses resolveValidPath with no timeout (startup paths are local, no hung-mount risk at load time)
58+
* and the caller's pathExistsFn (which may be mocked in tests).
5959
*/
60-
async function resolvePathWithFallback(path: string, pathExists: (p: string) => Promise<boolean>): Promise<string> {
61-
// Start with the saved path
62-
let currentPath = path
63-
64-
// Try the path and its parents
65-
while (currentPath && currentPath !== ROOT_PATH) {
66-
if (await pathExists(currentPath)) {
67-
return currentPath
68-
}
69-
// Try parent directory
70-
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || ROOT_PATH
71-
currentPath = parentPath === currentPath ? ROOT_PATH : parentPath
72-
}
73-
74-
// Check if root exists
75-
if (await pathExists(ROOT_PATH)) {
76-
return ROOT_PATH
77-
}
78-
79-
// Ultimate fallback to home
80-
return DEFAULT_PATH
60+
async function resolvePersistedPath(path: string, pathExistsFn: (p: string) => Promise<boolean>): Promise<string> {
61+
return (await resolveValidPath(path, { pathExistsFn, timeoutMs: 0 })) ?? DEFAULT_PATH
8162
}
8263

8364
function parseViewMode(raw: unknown): ViewMode {
@@ -116,9 +97,9 @@ export async function loadAppStatus(pathExists: (p: string) => Promise<boolean>)
11697

11798
// Resolve paths with fallback - skip for virtual 'network' volume
11899
const resolvedLeftPath =
119-
leftVolumeId === 'network' ? leftPath : await resolvePathWithFallback(leftPath, pathExists)
100+
leftVolumeId === 'network' ? leftPath : await resolvePersistedPath(leftPath, pathExists)
120101
const resolvedRightPath =
121-
rightVolumeId === 'network' ? rightPath : await resolvePathWithFallback(rightPath, pathExists)
102+
rightVolumeId === 'network' ? rightPath : await resolvePersistedPath(rightPath, pathExists)
122103

123104
return {
124105
leftPath: resolvedLeftPath,
@@ -351,7 +332,7 @@ export async function loadPaneTabs(
351332
const validatedTabs = await Promise.all(
352333
raw.tabs.map(async (tab) => {
353334
if (tab.volumeId === 'network') return tab
354-
const resolvedPath = await resolvePathWithFallback(tab.path, pathExistsFn)
335+
const resolvedPath = await resolvePersistedPath(tab.path, pathExistsFn)
355336
return { ...tab, path: resolvedPath }
356337
}),
357338
)
@@ -364,7 +345,7 @@ export async function loadPaneTabs(
364345
const volumeId = ((await store.get(`${side}VolumeId`)) as string) || DEFAULT_VOLUME_ID
365346
const sortBy = parseSortColumn(await store.get(`${side}SortBy`))
366347
const viewMode = parseViewMode(await store.get(`${side}ViewMode`))
367-
const resolvedPath = volumeId === 'network' ? path : await resolvePathWithFallback(path, pathExistsFn)
348+
const resolvedPath = volumeId === 'network' ? path : await resolvePersistedPath(path, pathExistsFn)
368349

369350
const tab: PersistedTab = {
370351
id: crypto.randomUUID(),

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ Runs checks **in parallel** with 500ms frontend timeouts per check. Priority:
4444
3. Stored `lastUsedPath` for this volume
4545
4. Default: `~` for `DEFAULT_VOLUME_ID`, else volume root
4646

47-
`resolveValidPath(targetPath)` — walks parent tree until an existing directory is found. Each step has a **1-second
48-
frontend timeout**. Fallback chain: parent dirs → `~``/``null` (volume unmounted).
47+
`resolveValidPath(targetPath, options?)` — walks parent tree until an existing directory is found. Accepts optional
48+
`{ pathExistsFn, timeoutMs }` — defaults to Tauri `pathExists` with 1s timeout per step. Used both at runtime (with
49+
timeouts) and at startup via `app-status-store.ts`'s `resolvePersistedPath` wrapper (no timeout, injected
50+
`pathExistsFn`). Fallback chain: parent dirs → `~``/``null` (volume unmounted).
4951

5052
`withTimeout(promise, ms, fallback)` — imported from `$lib/utils/timing` and re-exported. Races a promise against a
5153
timeout, returning the fallback on expiry. Used by both functions above, and also by `VolumeBreadcrumb.svelte` (wraps

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,66 @@ describe('resolveValidPath', () => {
272272
expect(result).toBe('/Users/test')
273273
expect(callCount).toBe(2)
274274
})
275+
276+
it('uses custom pathExistsFn when provided', async () => {
277+
const customPathExists = vi.fn((p: string) => Promise.resolve(p === '/custom/path'))
278+
279+
const result = await resolveValidPath('/custom/path/child', { pathExistsFn: customPathExists })
280+
281+
expect(result).toBe('/custom/path')
282+
expect(customPathExists).toHaveBeenCalledWith('/custom/path/child')
283+
expect(customPathExists).toHaveBeenCalledWith('/custom/path')
284+
// The global mock should NOT have been called
285+
expect(mockPathExists).not.toHaveBeenCalled()
286+
})
287+
288+
it('uses custom timeoutMs when provided', async () => {
289+
// First call hangs, second call resolves
290+
let callCount = 0
291+
mockPathExists.mockImplementation((): Promise<boolean> => {
292+
callCount++
293+
if (callCount === 1) return new Promise<boolean>(() => {}) // hangs
294+
return Promise.resolve(true)
295+
})
296+
297+
const resultPromise = resolveValidPath('/Users/test/slow', { timeoutMs: 2000 })
298+
// At 1000ms the default timeout would have fired, but our custom 2000ms shouldn't yet
299+
await vi.advanceTimersByTimeAsync(1000)
300+
expect(callCount).toBe(1) // still waiting on first call
301+
// At 2000ms it should time out and try parent
302+
await vi.advanceTimersByTimeAsync(1000)
303+
await vi.advanceTimersByTimeAsync(100) // let microtasks settle
304+
const result = await resultPromise
305+
306+
expect(result).toBe('/Users/test')
307+
expect(callCount).toBe(2)
308+
})
309+
310+
it('skips timeout when timeoutMs is 0 (no timeout wrapping)', async () => {
311+
const customPathExists = vi.fn((p: string) => Promise.resolve(p === '/exists'))
312+
313+
const result = await resolveValidPath('/exists/child', { pathExistsFn: customPathExists, timeoutMs: 0 })
314+
315+
expect(result).toBe('/exists')
316+
expect(customPathExists).toHaveBeenCalledWith('/exists/child')
317+
expect(customPathExists).toHaveBeenCalledWith('/exists')
318+
})
319+
320+
it('with custom pathExistsFn still falls back to ~ then / then null', async () => {
321+
const customPathExists = vi.fn(() => Promise.resolve(false))
322+
323+
const result = await resolveValidPath('/gone/path', { pathExistsFn: customPathExists, timeoutMs: 0 })
324+
325+
expect(result).toBeNull()
326+
expect(customPathExists).toHaveBeenCalledWith('~')
327+
expect(customPathExists).toHaveBeenCalledWith('/')
328+
})
329+
330+
it('with custom pathExistsFn falls back to ~ when parents are gone', async () => {
331+
const customPathExists = vi.fn((p: string) => Promise.resolve(p === '~'))
332+
333+
const result = await resolveValidPath('/gone/deep/path', { pathExistsFn: customPathExists, timeoutMs: 0 })
334+
335+
expect(result).toBe('~')
336+
})
275337
})

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,30 +57,41 @@ export async function determineNavigationPath(
5757
return volumeId === DEFAULT_VOLUME_ID ? '~' : volumePath
5858
}
5959

60+
export interface ResolveValidPathOptions {
61+
/** Custom path-existence checker. Defaults to the Tauri `pathExists` command. */
62+
pathExistsFn?: (path: string) => Promise<boolean>
63+
/** Timeout per step in ms. Set to 0 to skip timeout wrapping. Defaults to 1000. */
64+
timeoutMs?: number
65+
}
66+
6067
/**
6168
* Resolves a path to a valid existing path by walking up the parent tree.
62-
* Each step has a 1-second timeout to prevent hanging on dead mounts.
69+
* Each step has a timeout to prevent hanging on dead mounts (default 1s).
6370
* Fallback chain: parent tree → user home (~) → filesystem root (/).
6471
* Returns null if even the root doesn't exist (volume unmounted).
6572
*/
66-
export async function resolveValidPath(targetPath: string): Promise<string | null> {
67-
const stepTimeoutMs = 1000
73+
export async function resolveValidPath(targetPath: string, options?: ResolveValidPathOptions): Promise<string | null> {
74+
const checkFn = options?.pathExistsFn ?? pathExists
75+
const timeoutMs = options?.timeoutMs ?? 1000
76+
77+
const check = (p: string): Promise<boolean> =>
78+
timeoutMs > 0 ? withTimeout(checkFn(p), timeoutMs, false) : checkFn(p)
6879

6980
let path = targetPath
7081
while (path !== '/' && path !== '') {
71-
if (await withTimeout(pathExists(path), stepTimeoutMs, false)) {
82+
if (await check(path)) {
7283
return path
7384
}
7485
// Go to parent
7586
const lastSlash = path.lastIndexOf('/')
7687
path = lastSlash > 0 ? path.substring(0, lastSlash) : '/'
7788
}
7889
// Try user home before falling back to root (~ is expanded by the backend)
79-
if (await withTimeout(pathExists('~'), stepTimeoutMs, false)) {
90+
if (await check('~')) {
8091
return '~'
8192
}
8293
// Check root
83-
if (await withTimeout(pathExists('/'), stepTimeoutMs, false)) {
94+
if (await check('/')) {
8495
return '/'
8596
}
8697
return null

0 commit comments

Comments
 (0)