Skip to content

Commit afa2fe1

Browse files
committed
Go to path: Dialog, handler, and wiring
- Extract the shared "jump into a pane" primitives into `lib/file-explorer/navigation/navigate-and-select.ts` (`navigateToDirInPane` / `navigateToFileInPane`, preserving the `navigateToPath` sync-error-string handling verbatim) and repoint `lib/downloads/go-to-latest.ts` at them — behavior-preserving, `go-to-latest.test.ts` still green. - Add the `lib/go-to-path/` feature: `goToPath` handler (switches on the typed `GoToPathResolution`, navigates, records the resolved target into recents, fires a snapshotted-`nav.back`-shortcut ancestor toast), the pure helpers `digitToRecentIndex` + `shouldPrefillClipboard`, the `recent-paths-state` `$state` mirror, `GoToPathDialog.svelte` (clipboard prefill, digit-jump recents, live ancestor warning, `[x]` remove), and `GoToPathAncestorToastContent.svelte`. - Wire `nav.goToPath` (⌘G) into `command-registry.ts` + `command-dispatch.ts` + `+page.svelte` (mount, close, `isModalDialogOpen`, and the `showGoToPathDialog` idempotency guard for the menu double-dispatch). Register the `go-to-path` dialog id. - Vitest: handler per-`kind` + dynamic-shortcut proof, `digitToRecentIndex` / `shouldPrefillClipboard` tables, recents mirror add/dedupe/move-to-top/cap/remove, navigate-and-select primitives, dialog behavior, and toast + dialog a11y. - Docs: `lib/go-to-path/CLAUDE.md`, the `navigation/` row, and the frontend `go-to-path/` row in `architecture.md`.
1 parent 2a87c01 commit afa2fe1

20 files changed

Lines changed: 1714 additions & 85 deletions

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ export const commands: Command[] = [
6868
shortcuts: ['⌘F', '⌥F7'],
6969
},
7070

71+
// ============================================================================
72+
// Main window - Navigation (Go to path)
73+
// ============================================================================
74+
{
75+
id: 'nav.goToPath',
76+
name: 'Go to path…',
77+
scope: 'Main window',
78+
showInPalette: true,
79+
shortcuts: ['⌘G'],
80+
description: 'Jump the focused pane to a typed, pasted, or recent path.',
81+
},
82+
7183
// ============================================================================
7284
// Main window - Downloads
7385
// ============================================================================

apps/desktop/src/lib/downloads/go-to-latest.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { commands } from '$lib/ipc/bindings'
1313
import { addToast } from '$lib/ui/toast'
1414
import { getAppLogger } from '$lib/logging/logger'
15+
import { navigateToFileInPane } from '$lib/file-explorer/navigation/navigate-and-select'
1516
import type { ExplorerAPI } from '../../routes/(main)/explorer-api'
1617

1718
import LatestDownloadEmptyToastContent from './LatestDownloadEmptyToastContent.svelte'
@@ -43,7 +44,7 @@ export async function goToLatestDownload(explorer: ExplorerAPI | undefined): Pro
4344

4445
const result = await commands.goToLatestDownload()
4546
if (result.status === 'ok') {
46-
await navigateToDownloadFile(explorer, result.data.parentDir, result.data.fileName)
47+
await navigateToFileInPane(explorer, explorer.getFocusedPane(), result.data.parentDir, result.data.fileName)
4748
return
4849
}
4950

@@ -82,27 +83,7 @@ export async function goToDownload(
8283
log.debug('goToDownload: no explorer; skipping (HMR or pre-mount)')
8384
return
8485
}
85-
await navigateToDownloadFile(explorer, parentDir, fileName)
86-
}
87-
88-
async function navigateToDownloadFile(explorer: ExplorerAPI, parentDir: string, fileName: string): Promise<void> {
89-
const pane = explorer.getFocusedPane()
90-
// `navigateToPath` returns a sync error string when navigation can't even
91-
// start (snapshot pane on a missing volume, etc.); otherwise it returns a
92-
// Promise that settles when the listing completes. We await the Promise
93-
// but report-and-bail on the sync-error string — without the listing
94-
// settled, `moveCursor` would race against an empty cache.
95-
const navResult = explorer.navigateToPath(pane, parentDir)
96-
if (typeof navResult === 'string') {
97-
log.warn('goToDownload: navigateToPath refused {pane} {parentDir}: {reason}', {
98-
pane,
99-
parentDir,
100-
reason: navResult,
101-
})
102-
return
103-
}
104-
await navResult
105-
await explorer.moveCursor(pane, fileName)
86+
await navigateToFileInPane(explorer, explorer.getFocusedPane(), parentDir, fileName)
10687
}
10788

10889
async function showEmptyToast(explorer: ExplorerAPI): Promise<void> {

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

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,23 @@ Browser-style back/forward history, path resolution, paged keyboard shortcuts, a
44

55
## Key files
66

7-
| File | Purpose |
8-
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
9-
| `navigation-history.ts` | Purely functional immutable history stack |
10-
| `path-navigation.ts` | Picks initial path when switching volumes |
11-
| `path-resolution.ts` | Walk-up `resolveValidPath` (split out to break cycle) |
12-
| `path-segments.ts` | Splits the breadcrumb display path into segments and flags any inside a `.git/...` portal (consumer: `FilePane.svelte` paints them with `--color-git-portal-text`) |
13-
| `keyboard-shortcuts.ts` | Home/End/PageUp/PageDown handling for file lists |
14-
| `VolumeBreadcrumb.svelte` | Clickable volume label + grouped dropdown |
15-
| `volume-grouping.ts` | Pure logic: group volumes by category, get volume icons |
16-
| `volume-space-manager.svelte.ts` | Reactive state machine for disk space fetch/retry/timeout |
17-
| `volume-breadcrumb-handlers.svelte.ts` | Submenu/breadcrumb-popup controllers, keyboard-mode tracker, and pure key-dispatch helpers for `VolumeBreadcrumb.svelte` |
18-
| `eject-predicate.ts` | Pure `isVolumeEjectable(volume)` used by the eject button gate. Returns true when NSURL says ejectable OR the volume has any SMB connection state |
19-
| `navigation-history.test.ts` | Full unit test coverage of history functions |
20-
| `path-navigation.test.ts` | Unit tests for path resolution and timeouts |
21-
| `keyboard-shortcuts.test.ts` | Unit tests for shortcut calculations |
22-
| `path-segments.test.ts` | Unit tests for git-portal segment detection |
7+
| File | Purpose |
8+
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9+
| `navigation-history.ts` | Purely functional immutable history stack |
10+
| `path-navigation.ts` | Picks initial path when switching volumes |
11+
| `navigate-and-select.ts` | Shared "jump into a pane" primitives (`navigateToDirInPane` / `navigateToFileInPane`) used by Go-to-latest-download and Go-to-path; handles `navigateToPath`'s sync-error string |
12+
| `path-resolution.ts` | Walk-up `resolveValidPath` (split out to break cycle) |
13+
| `path-segments.ts` | Splits the breadcrumb display path into segments and flags any inside a `.git/...` portal (consumer: `FilePane.svelte` paints them with `--color-git-portal-text`) |
14+
| `keyboard-shortcuts.ts` | Home/End/PageUp/PageDown handling for file lists |
15+
| `VolumeBreadcrumb.svelte` | Clickable volume label + grouped dropdown |
16+
| `volume-grouping.ts` | Pure logic: group volumes by category, get volume icons |
17+
| `volume-space-manager.svelte.ts` | Reactive state machine for disk space fetch/retry/timeout |
18+
| `volume-breadcrumb-handlers.svelte.ts` | Submenu/breadcrumb-popup controllers, keyboard-mode tracker, and pure key-dispatch helpers for `VolumeBreadcrumb.svelte` |
19+
| `eject-predicate.ts` | Pure `isVolumeEjectable(volume)` used by the eject button gate. Returns true when NSURL says ejectable OR the volume has any SMB connection state |
20+
| `navigation-history.test.ts` | Full unit test coverage of history functions |
21+
| `path-navigation.test.ts` | Unit tests for path resolution and timeouts |
22+
| `keyboard-shortcuts.test.ts` | Unit tests for shortcut calculations |
23+
| `path-segments.test.ts` | Unit tests for git-portal segment detection |
2324

2425
## `navigation-history.ts`
2526

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { navigateToDirInPane, navigateToFileInPane } from './navigate-and-select'
3+
import type { ExplorerAPI } from '../../../routes/(main)/explorer-api'
4+
5+
function makeExplorer(navResult: string | Promise<void>) {
6+
const navigateToPath = vi.fn(() => navResult)
7+
const moveCursor = vi.fn(() => Promise.resolve())
8+
const explorer = { navigateToPath, moveCursor } as unknown as ExplorerAPI
9+
return { explorer, navigateToPath, moveCursor }
10+
}
11+
12+
describe('navigateToDirInPane', () => {
13+
it('navigates to the dir and never moves the cursor', async () => {
14+
const { explorer, navigateToPath, moveCursor } = makeExplorer(Promise.resolve())
15+
await navigateToDirInPane(explorer, 'left', '/tmp/here')
16+
expect(navigateToPath).toHaveBeenCalledWith('left', '/tmp/here')
17+
expect(moveCursor).not.toHaveBeenCalled()
18+
})
19+
20+
it('bails on the sync-error string without throwing', async () => {
21+
const { explorer, moveCursor } = makeExplorer('snapshot pane on a missing volume')
22+
await expect(navigateToDirInPane(explorer, 'right', '/tmp')).resolves.toBeUndefined()
23+
expect(moveCursor).not.toHaveBeenCalled()
24+
})
25+
})
26+
27+
describe('navigateToFileInPane', () => {
28+
let moveCursorCalls: unknown[][]
29+
30+
beforeEach(() => {
31+
moveCursorCalls = []
32+
})
33+
34+
it('navigates to the parent, then moves the cursor onto the file', async () => {
35+
const { explorer, navigateToPath, moveCursor } = makeExplorer(Promise.resolve())
36+
await navigateToFileInPane(explorer, 'left', '/tmp', 'a.txt')
37+
expect(navigateToPath).toHaveBeenCalledWith('left', '/tmp')
38+
expect(moveCursor).toHaveBeenCalledWith('left', 'a.txt')
39+
})
40+
41+
it('awaits the listing before moving the cursor', async () => {
42+
let resolveListing!: () => void
43+
const listing = new Promise<void>((resolve) => {
44+
resolveListing = resolve
45+
})
46+
const navigateToPath = vi.fn(() => listing)
47+
const moveCursor = vi.fn(() => {
48+
moveCursorCalls.push(['called'])
49+
return Promise.resolve()
50+
})
51+
const explorer = { navigateToPath, moveCursor } as unknown as ExplorerAPI
52+
53+
const promise = navigateToFileInPane(explorer, 'left', '/tmp', 'a.txt')
54+
// Cursor must NOT move before the listing settles.
55+
expect(moveCursorCalls).toHaveLength(0)
56+
resolveListing()
57+
await promise
58+
expect(moveCursorCalls).toHaveLength(1)
59+
})
60+
61+
it('bails on the sync-error string and never moves the cursor', async () => {
62+
const { explorer, moveCursor } = makeExplorer('cannot start')
63+
await navigateToFileInPane(explorer, 'left', '/tmp', 'a.txt')
64+
expect(moveCursor).not.toHaveBeenCalled()
65+
})
66+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Shared navigation primitives for the "jump somewhere in the focused pane"
3+
* features: "Go to latest download" (⌘J) and "Go to path" (⌘G). Both want to
4+
* point a pane at a directory and (for files) land the cursor on a specific
5+
* entry, with the same careful handling of `navigateToPath`'s
6+
* `string | Promise<void>` return.
7+
*
8+
* `navigateToPath` returns a sync error string when navigation can't even
9+
* start (a snapshot pane on a missing volume, etc.); otherwise it returns a
10+
* Promise that settles when the listing completes. We await the Promise but
11+
* report-and-bail on the sync-error string — without the listing settled,
12+
* `moveCursor` would race against an empty cache.
13+
*/
14+
15+
import { getAppLogger } from '$lib/logging/logger'
16+
import type { ExplorerAPI } from '../../../routes/(main)/explorer-api'
17+
18+
const log = getAppLogger('navigation')
19+
20+
type Pane = 'left' | 'right'
21+
22+
/**
23+
* Navigate `pane` to a directory. No cursor move — the directory's own normal
24+
* navigation lands the cursor on the 0th row (`..`).
25+
*/
26+
export async function navigateToDirInPane(explorer: ExplorerAPI, pane: Pane, dir: string): Promise<void> {
27+
const navResult = explorer.navigateToPath(pane, dir)
28+
if (typeof navResult === 'string') {
29+
log.warn('navigateToDirInPane: navigateToPath refused {pane} {dir}: {reason}', {
30+
pane,
31+
dir,
32+
reason: navResult,
33+
})
34+
return
35+
}
36+
await navResult
37+
}
38+
39+
/**
40+
* Navigate `pane` to `parentDir`, then move the cursor onto `fileName` so the
41+
* file is revealed/selected (we do NOT open it).
42+
*/
43+
export async function navigateToFileInPane(
44+
explorer: ExplorerAPI,
45+
pane: Pane,
46+
parentDir: string,
47+
fileName: string,
48+
): Promise<void> {
49+
const navResult = explorer.navigateToPath(pane, parentDir)
50+
if (typeof navResult === 'string') {
51+
log.warn('navigateToFileInPane: navigateToPath refused {pane} {parentDir}: {reason}', {
52+
pane,
53+
parentDir,
54+
reason: navResult,
55+
})
56+
return
57+
}
58+
await navResult
59+
await explorer.moveCursor(pane, fileName)
60+
}

0 commit comments

Comments
 (0)