@morscherlab/mint-sdk 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/dist/__tests__/composables/createFilePickerAdapter.test.d.ts +1 -0
  2. package/dist/__tests__/composables/usePickerNavigation.test.d.ts +1 -0
  3. package/dist/components/index.js +1 -1
  4. package/dist/{components-CxQVQCpP.js → components-CZzBCakZ.js} +38 -3
  5. package/dist/{components-CxQVQCpP.js.map → components-CZzBCakZ.js.map} +1 -1
  6. package/dist/composables/createFilePickerAdapter.d.ts +18 -0
  7. package/dist/composables/index.d.ts +2 -0
  8. package/dist/composables/index.js +2 -2
  9. package/dist/composables/usePlatformFilePickerAdapter.d.ts +3 -7
  10. package/dist/{composables-DAqOU8Aq.js → composables-BCbYb2lA.js} +125 -42
  11. package/dist/composables-BCbYb2lA.js.map +1 -0
  12. package/dist/index.js +3 -3
  13. package/dist/install.js +1 -1
  14. package/dist/types/filePicker.d.ts +2 -0
  15. package/package.json +1 -1
  16. package/src/__tests__/components/FilePicker.state.test.ts +19 -0
  17. package/src/__tests__/composables/createFilePickerAdapter.test.ts +142 -0
  18. package/src/__tests__/composables/useFileBrowser.test.ts +12 -0
  19. package/src/__tests__/composables/usePickerNavigation.test.ts +149 -0
  20. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +18 -0
  21. package/src/composables/createFilePickerAdapter.ts +145 -0
  22. package/src/composables/filePicker/usePickerNavigation.ts +42 -2
  23. package/src/composables/index.ts +2 -0
  24. package/src/composables/useFileBrowser.ts +6 -5
  25. package/src/composables/usePlatformFilePickerAdapter.ts +24 -91
  26. package/src/types/filePicker.ts +2 -0
  27. package/dist/composables-DAqOU8Aq.js.map +0 -1
@@ -3,6 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest'
3
3
  import { ref } from 'vue'
4
4
  import FilePicker from '../../components/FilePicker.vue'
5
5
  import FileUploader from '../../components/FileUploader.vue'
6
+ import { createFilePickerAdapter, encodePlatformPickerPath } from '../../composables/createFilePickerAdapter'
6
7
  import type { PickerAdapter, PickerNode, PickerSelection } from '../../types/filePicker'
7
8
 
8
9
  enableAutoUnmount(afterEach)
@@ -43,6 +44,24 @@ function confirm(wrapper: Awaited<ReturnType<typeof setup>>) {
43
44
  return wrapper.findAll('.mint-picker__actions button').at(-1)!
44
45
  }
45
46
 
47
+ it('re-reads the current directory when reopening a cached SDK adapter', async () => {
48
+ let name = 'old.raw'
49
+ const browse = vi.fn(async () => ({
50
+ mount_id: 'raw', path: '',
51
+ entries: [{ kind: 'file' as const, path: name, name, size_bytes: 7 }],
52
+ }))
53
+ const data = createFilePickerAdapter({ listMounts: async () => [], browse })
54
+ const wrapper = await setup(data, { initialPath: encodePlatformPickerPath('raw') })
55
+ expect(wrapper.text()).toContain('old.raw')
56
+ await wrapper.setProps({ open: false })
57
+ name = 'new.raw'
58
+ await wrapper.setProps({ open: true })
59
+ await flushPromises()
60
+ expect(browse).toHaveBeenCalledTimes(2)
61
+ expect(wrapper.text()).toContain('new.raw')
62
+ expect(wrapper.text()).not.toContain('old.raw')
63
+ })
64
+
46
65
  it('root breadcrumb returns directly to mounts and navigation keeps a tabbable row', async () => {
47
66
  const wrapper = await setup()
48
67
  await row(wrapper, folder.path).trigger('click')
@@ -0,0 +1,142 @@
1
+ import { expect, it, vi } from 'vitest'
2
+ import {
3
+ createFilePickerAdapter,
4
+ encodePlatformPickerPath,
5
+ type FilePickerTransport,
6
+ } from '../../composables/createFilePickerAdapter'
7
+ import type { FileDirectoryListing } from '../../types/fileBrowserTypes'
8
+
9
+ const identity = (path = '') => encodePlatformPickerPath('raw', path)
10
+ function setup() {
11
+ const transport: FilePickerTransport = {
12
+ listMounts: vi.fn(async () => [{ id: 'raw', label: 'Raw files' }]),
13
+ browse: vi.fn(async ({ mount_id, path }) => ({
14
+ mount_id, path,
15
+ entries: path ? [{ kind: 'file' as const, name: 'run.mzML.gz', path: `${path}/run.mzML.gz`, size_bytes: 7 }]
16
+ : [{ kind: 'directory' as const, name: 'study', path: 'study' }],
17
+ })),
18
+ }
19
+ return { transport, adapter: createFilePickerAdapter(transport, { rootLocation: { mount_id: 'raw', path: '' } }) }
20
+ }
21
+
22
+ it('shows a configured root directly, preserving nested ancestry and file formats', async () => {
23
+ const { adapter, transport } = setup()
24
+ const roots = await adapter.listRoot()
25
+ expect(transport.listMounts).not.toHaveBeenCalled()
26
+ expect(roots).toMatchObject([{ path: identity('study'), parentPath: null, parentName: 'raw' }])
27
+ expect(await adapter.listChildren(roots[0]!.path)).toMatchObject([
28
+ { path: identity('study/run.mzML.gz'), parentPath: identity('study'), format: 'mzML.gz', bytes: 7 },
29
+ ])
30
+ })
31
+
32
+ it('reuses directory listings across navigation and repeated searches within one instance', async () => {
33
+ const { adapter, transport } = setup()
34
+ await adapter.listRoot()
35
+ await adapter.listChildren(identity('study'))
36
+ expect(await adapter.search('run', { path: '', recursive: true })).toHaveLength(1)
37
+ expect(await adapter.search('run', { path: identity('study'), recursive: false })).toHaveLength(1)
38
+ expect(transport.browse).toHaveBeenCalledTimes(2)
39
+ await createFilePickerAdapter(transport).listChildren(identity('study'))
40
+ expect(transport.browse).toHaveBeenCalledTimes(3)
41
+ })
42
+
43
+ it('refreshes root and descendants, forwarding refresh and cancellation to the transport', async () => {
44
+ const { adapter, transport } = setup()
45
+ await adapter.search('run', { path: '', recursive: true })
46
+ const signal = new AbortController().signal
47
+ await adapter.listRoot({ refresh: true, signal })
48
+ expect(transport.browse).toHaveBeenLastCalledWith({ mount_id: 'raw', path: '' }, { refresh: true, signal })
49
+ await adapter.listChildren(identity('study'))
50
+ expect(transport.browse).toHaveBeenCalledTimes(4)
51
+ await adapter.search('run', { path: '', recursive: true }, { refresh: true, signal })
52
+ expect(transport.browse).toHaveBeenCalledTimes(6)
53
+ expect(vi.mocked(transport.browse).mock.calls.at(-1)![1]).toEqual({ signal })
54
+ })
55
+
56
+ it('does not reuse an old pending request during refresh or cache its late response', async () => {
57
+ const { adapter, transport } = setup()
58
+ let finish!: (value: FileDirectoryListing) => void
59
+ vi.mocked(transport.browse).mockReturnValueOnce(new Promise((resolve) => { finish = resolve }))
60
+ const old = adapter.listChildren(identity('study'))
61
+ const rejected = expect(old).rejects.toMatchObject({ name: 'AbortError' })
62
+ const current = await adapter.listChildren(identity('study'), { refresh: true })
63
+ finish({ mount_id: 'raw', path: 'study', entries: [] })
64
+ await rejected
65
+ expect(await adapter.listChildren(identity('study'))).toEqual(current)
66
+ expect(transport.browse).toHaveBeenCalledTimes(2)
67
+ })
68
+
69
+ it('rejects late aborted responses even when the transport ignores the signal', async () => {
70
+ const { adapter, transport } = setup()
71
+ let finish!: (value: FileDirectoryListing) => void
72
+ vi.mocked(transport.browse).mockReturnValueOnce(new Promise((resolve) => { finish = resolve }))
73
+ const controller = new AbortController()
74
+ const old = adapter.listRoot({ signal: controller.signal })
75
+ const rejected = expect(old).rejects.toMatchObject({ name: 'AbortError' })
76
+ controller.abort()
77
+ finish({ mount_id: 'raw', path: '', entries: [] })
78
+ await rejected
79
+ expect(await adapter.listRoot()).toHaveLength(1)
80
+ expect(transport.browse).toHaveBeenCalledTimes(2)
81
+ })
82
+
83
+ it('rejects already cancelled requests without serving cached listings', async () => {
84
+ const { adapter, transport } = setup()
85
+ await adapter.listRoot()
86
+ const signal = AbortSignal.abort()
87
+ await expect(adapter.listRoot({ signal })).rejects.toMatchObject({ name: 'AbortError' })
88
+ await expect(adapter.listChildren(identity(), { signal })).rejects.toMatchObject({ name: 'AbortError' })
89
+ expect(transport.browse).toHaveBeenCalledTimes(1)
90
+ })
91
+
92
+ it('confines navigation, search and returned nodes to the configured root', async () => {
93
+ const { transport } = setup()
94
+ const adapter = createFilePickerAdapter(transport, { rootLocation: { mount_id: 'raw', path: 'study' } })
95
+ for (const path of [identity(), identity('study-other'), identity('study/../outside'), encodePlatformPickerPath('other', 'study')]) {
96
+ await expect(adapter.listChildren(path)).rejects.toThrow('location')
97
+ await expect(adapter.search('run', { path, recursive: true })).rejects.toThrow('location')
98
+ }
99
+ expect(transport.browse).not.toHaveBeenCalled()
100
+ expect(await adapter.listRoot()).toMatchObject([{ parentPath: null }])
101
+ vi.mocked(transport.browse).mockResolvedValue({ mount_id: 'raw', path: 'study', entries: [
102
+ { kind: 'directory', name: 'outside', path: 'outside' },
103
+ ] })
104
+ await expect(adapter.listRoot({ refresh: true })).rejects.toThrow('entry')
105
+ })
106
+
107
+ it('bounds recursive traversal and rejects truncated listings', async () => {
108
+ const { adapter, transport } = setup()
109
+ vi.mocked(transport.browse).mockImplementation(async ({ mount_id, path }) => ({
110
+ mount_id, path,
111
+ entries: [{ kind: 'directory', name: 'next', path: path ? `${path}/next` : 'next' }],
112
+ }))
113
+ await expect(adapter.search('missing', { path: '', recursive: true })).rejects.toThrow('folder limit')
114
+ expect(transport.browse).toHaveBeenCalledTimes(500)
115
+ vi.mocked(transport.browse).mockResolvedValue({ mount_id: 'raw', path: '', entries: [], truncated: true })
116
+ await expect(adapter.listRoot({ refresh: true })).rejects.toThrow('listing limit')
117
+ })
118
+
119
+ it('invalidating a session prevents old responses from repopulating its cache', async () => {
120
+ const { adapter, transport } = setup()
121
+ let finish!: (value: FileDirectoryListing) => void
122
+ vi.mocked(transport.browse).mockReturnValueOnce(new Promise((resolve) => { finish = resolve }))
123
+ const old = adapter.listRoot()
124
+ const rejected = expect(old).rejects.toMatchObject({ name: 'AbortError' })
125
+ adapter.invalidate!()
126
+ const current = await adapter.listRoot()
127
+ finish({ mount_id: 'raw', path: '', entries: [] })
128
+ await rejected
129
+ expect(await adapter.listRoot()).toEqual(current)
130
+ expect(transport.browse).toHaveBeenCalledTimes(2)
131
+ })
132
+
133
+ it('keeps folders expandable when filtered child counts are zero', async () => {
134
+ const { adapter, transport } = setup()
135
+ vi.mocked(transport.browse).mockResolvedValueOnce({
136
+ mount_id: 'raw', path: '',
137
+ entries: [{ kind: 'directory', name: 'study', path: 'study', child_count: 0 }],
138
+ })
139
+ const [folder] = await adapter.listRoot()
140
+ expect(folder?.kind).toBe('folder')
141
+ expect(folder).not.toHaveProperty('hasChildren', false)
142
+ })
@@ -210,6 +210,7 @@ describe('useFileBrowser review fixes', () => {
210
210
  await new Promise(resolve => setTimeout(resolve, 0))
211
211
 
212
212
  expect(mockGet.mock.calls.length).toBeGreaterThan(before)
213
+ expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=')
213
214
  })
214
215
 
215
216
  it('should refetch when the search changes', async () => {
@@ -221,6 +222,7 @@ describe('useFileBrowser review fixes', () => {
221
222
  await new Promise(resolve => setTimeout(resolve, 400))
222
223
 
223
224
  expect(mockGet.mock.calls.length).toBeGreaterThan(before)
225
+ expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=')
224
226
  })
225
227
 
226
228
  it('should expose how many rows the listing carries', async () => {
@@ -242,6 +244,15 @@ describe('useFileBrowser review fixes', () => {
242
244
 
243
245
 
244
246
  describe('useFileBrowser selection and request lifecycle', () => {
247
+ it('bypasses the server directory cache only for an explicit refresh', async () => {
248
+ mockGet.mockResolvedValue(LISTING)
249
+ const browser = useFileBrowser()
250
+ await browser.navigate({ mount_id: 'qe', path: 'raw' })
251
+ expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=')
252
+ await browser.refresh()
253
+ expect(mockGet.mock.calls.at(-1)?.[0]).toContain('&refresh=true')
254
+ })
255
+
245
256
  it('never selects an uninitialized or failed directory', async () => {
246
257
  const browser = useFileBrowser()
247
258
  browser.selectCurrentFolder()
@@ -281,6 +292,7 @@ describe('useFileBrowser selection and request lifecycle', () => {
281
292
  typeRules.value = ['.mzML']
282
293
  await nextTick()
283
294
  expect(mockGet.mock.calls.at(-1)?.[0]).toContain('type_rules=.mzML')
295
+ expect(mockGet.mock.calls.at(-1)?.[0]).not.toContain('refresh=')
284
296
  scope.stop()
285
297
  })
286
298
 
@@ -0,0 +1,149 @@
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+ import { effectScope, type EffectScope } from 'vue'
3
+ import { usePickerNavigation } from '../../composables/filePicker/usePickerNavigation'
4
+ import type { PickerAdapter, PickerNode } from '../../types/filePicker'
5
+
6
+ const scopes: EffectScope[] = []
7
+ afterEach(() => {
8
+ scopes.splice(0).forEach((scope) => scope.stop())
9
+ vi.useRealTimers()
10
+ })
11
+ const folder = (path: string): PickerNode => ({ kind: 'folder', path, name: path })
12
+ function setup(roots: PickerNode[], allowed = (_node: PickerNode) => true) {
13
+ vi.useFakeTimers()
14
+ const adapter: PickerAdapter = {
15
+ listRoot: vi.fn(async () => roots),
16
+ listChildren: vi.fn(async (path) => [folder(`${path}/child`)]),
17
+ search: vi.fn(async () => roots),
18
+ }
19
+ const scope = effectScope()
20
+ scopes.push(scope)
21
+ const nav = scope.run(() => usePickerNavigation(() => adapter, () => ({}), allowed))!
22
+ return { nav, adapter, scope }
23
+ }
24
+
25
+ it('warms only the next level and reuses an in-flight prefetch when entering', async () => {
26
+ const { nav, adapter } = setup([folder('a')])
27
+ let finish!: (nodes: PickerNode[]) => void
28
+ vi.mocked(adapter.listChildren).mockReturnValueOnce(new Promise((resolve) => { finish = resolve }))
29
+ await nav.load()
30
+ expect(nav.loading.value).toBe(false)
31
+ expect(adapter.listChildren).not.toHaveBeenCalled()
32
+ await vi.advanceTimersByTimeAsync(100)
33
+ expect(adapter.listChildren).toHaveBeenCalledTimes(1)
34
+ nav.enter(folder('a'))
35
+ const opened = nav.load()
36
+ finish([folder('a/child')])
37
+ await opened
38
+ expect(adapter.listChildren).toHaveBeenCalledTimes(1)
39
+ expect(nav.entries.value).toEqual([folder('a/child')])
40
+ await vi.advanceTimersByTimeAsync(100)
41
+ expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['a', 'a/child'])
42
+ await vi.advanceTimersByTimeAsync(1000)
43
+ expect(adapter.listChildren).toHaveBeenCalledTimes(2)
44
+ await nav.loadChildren('a')
45
+ expect(adapter.listChildren).toHaveBeenCalledTimes(2)
46
+ })
47
+
48
+ it('bounds background work to two concurrent requests and twenty folders per view', async () => {
49
+ const { nav, adapter } = setup(Array.from({ length: 30 }, (_, i) => folder(`${i}`)))
50
+ const finish: (() => void)[] = []
51
+ vi.mocked(adapter.listChildren).mockImplementation(() => new Promise((resolve) => {
52
+ finish.push(() => resolve([]))
53
+ }))
54
+ await nav.load()
55
+ await vi.advanceTimersByTimeAsync(100)
56
+ expect(adapter.listChildren).toHaveBeenCalledTimes(2)
57
+ vi.mocked(adapter.listChildren).mockResolvedValue([])
58
+ finish.forEach((resolve) => resolve())
59
+ await vi.advanceTimersByTimeAsync(100)
60
+ expect(adapter.listChildren).toHaveBeenCalledTimes(20)
61
+ })
62
+
63
+ it('does not prefetch forbidden, offline, empty folders or search results', async () => {
64
+ const { nav, adapter } = setup([
65
+ folder('denied'), { ...folder('offline'), unavailableReason: 'Offline' },
66
+ { kind: 'folder', path: 'empty', name: 'empty', hasChildren: false }, folder('ok'),
67
+ ], (node) => node.path !== 'denied')
68
+ await nav.load()
69
+ await vi.advanceTimersByTimeAsync(100)
70
+ expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['ok'])
71
+ nav.invalidate()
72
+ await nav.load('query')
73
+ await vi.advanceTimersByTimeAsync(100)
74
+ expect(adapter.listChildren).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ it('cancels queued prefetches and discards late responses when closed', async () => {
78
+ const { nav, adapter, scope } = setup([folder('a'), folder('b'), folder('c')])
79
+ let finish!: (nodes: PickerNode[]) => void
80
+ vi.mocked(adapter.listChildren).mockImplementation(() => new Promise((resolve) => { finish = resolve }))
81
+ await nav.load()
82
+ await vi.advanceTimersByTimeAsync(100)
83
+ scope.stop()
84
+ expect(vi.mocked(adapter.listChildren).mock.calls[0]![1]!.signal!.aborted).toBe(true)
85
+ finish([folder('late')])
86
+ await vi.advanceTimersByTimeAsync(1000)
87
+ expect(nav.nodes.value.size).toBe(0)
88
+ expect(nav.children.value.size).toBe(0)
89
+ expect(adapter.listChildren).toHaveBeenCalledTimes(2)
90
+ })
91
+
92
+ it('retries a failed prefetch on demand and refresh bypasses warmed results', async () => {
93
+ const { nav, adapter } = setup([folder('a')])
94
+ vi.mocked(adapter.listChildren).mockRejectedValueOnce(new Error('Offline'))
95
+ await nav.load()
96
+ await vi.advanceTimersByTimeAsync(100)
97
+ expect(nav.error.value).toBe('')
98
+ nav.enter(folder('a'))
99
+ await nav.load()
100
+ expect(nav.entries.value).toEqual([folder('a/child')])
101
+ nav.invalidate()
102
+ await nav.load('', false, true)
103
+ expect(adapter.listChildren).toHaveBeenCalledTimes(3)
104
+ expect(vi.mocked(adapter.listChildren).mock.calls.at(-1)![1]).toMatchObject({ refresh: true })
105
+ })
106
+
107
+ it('warms expanded children and rechecks their registered ancestors before prefetching', async () => {
108
+ let denied = ''
109
+ const { nav, adapter } = setup([folder('a'), folder('b')], (node) => node.path !== denied)
110
+ await nav.load()
111
+ await nav.expand(folder('a'))
112
+ await vi.advanceTimersByTimeAsync(100)
113
+ expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['a', 'a/child'])
114
+ await vi.advanceTimersByTimeAsync(1000)
115
+ expect(adapter.listChildren).toHaveBeenCalledTimes(2)
116
+
117
+ await nav.expand(folder('b'))
118
+ denied = 'b'
119
+ await vi.advanceTimersByTimeAsync(100)
120
+ expect(nav.isAllowed(nav.nodes.value.get('b/child')!)).toBe(false)
121
+ expect(vi.mocked(adapter.listChildren).mock.calls.map(([path]) => path)).toEqual(['a', 'a/child', 'b'])
122
+ })
123
+
124
+ it('restarts prefetch after reopening even if old requests ignore cancellation', async () => {
125
+ const { nav, adapter } = setup([folder('a'), folder('b'), folder('c')])
126
+ const finish: (() => void)[] = []
127
+ vi.mocked(adapter.listChildren).mockImplementation(() => new Promise((resolve) => {
128
+ finish.push(() => resolve([]))
129
+ }))
130
+ await nav.load()
131
+ await vi.advanceTimersByTimeAsync(100)
132
+ expect(adapter.listChildren).toHaveBeenCalledTimes(2)
133
+ nav.clear()
134
+ await nav.load()
135
+ await vi.advanceTimersByTimeAsync(100)
136
+ expect(adapter.listChildren).toHaveBeenCalledTimes(4)
137
+
138
+ finish[0]!()
139
+ finish[1]!()
140
+ await vi.advanceTimersByTimeAsync(100)
141
+ expect(adapter.listChildren).toHaveBeenCalledTimes(4)
142
+ expect(nav.children.value.size).toBe(0)
143
+ finish[2]!()
144
+ await vi.advanceTimersByTimeAsync(100)
145
+ expect(adapter.listChildren).toHaveBeenCalledTimes(5)
146
+ finish[3]!()
147
+ finish[4]!()
148
+ await vi.advanceTimersByTimeAsync(100)
149
+ })
@@ -65,3 +65,21 @@ it('scopes shallow search to one level and recursive search skips offline mounts
65
65
  expect(await adapter.search('run', { path: '', recursive: true })).toHaveLength(1)
66
66
  expect(get.mock.calls.filter((call) => call[0].includes('/browse'))).toHaveLength(1)
67
67
  })
68
+ it('forwards explicit refresh to mount browsing and supports a scoped root', async () => {
69
+ get.mockResolvedValue({ entries: [] })
70
+ const adapter = usePlatformFilePickerAdapter({
71
+ apiBaseUrl: '/api', rootLocation: { mount_id: 'raw', path: 'study' },
72
+ })
73
+ const signal = new AbortController().signal
74
+ await adapter.listRoot({ refresh: true, signal })
75
+ expect(get).toHaveBeenCalledExactlyOnceWith(
76
+ '/filesystem/browse?mount_id=raw&path=study&refresh=true', { signal },
77
+ )
78
+ })
79
+ it('forwards a mount-list refresh so server directory snapshots are invalidated too', async () => {
80
+ get.mockResolvedValue({ mounts: [] })
81
+ const adapter = usePlatformFilePickerAdapter()
82
+ const signal = new AbortController().signal
83
+ await adapter.listRoot({ refresh: true, signal })
84
+ expect(get).toHaveBeenCalledExactlyOnceWith('/filesystem/mounts?refresh=true', { signal })
85
+ })
@@ -0,0 +1,145 @@
1
+ import type { PickerAdapter, PickerNode, PickerRequest } from '../types/filePicker'
2
+ import type { FileBrowserLocation, FileDirectoryListing, ServerMount } from '../types/fileBrowserTypes'
3
+ import { pickerFormat } from './filePicker/validation'
4
+
5
+ export interface FilePickerTransport {
6
+ listMounts(request?: PickerRequest): Promise<ServerMount[]>
7
+ browse(location: FileBrowserLocation, request?: PickerRequest): Promise<FileDirectoryListing>
8
+ }
9
+ export interface FilePickerAdapterOptions {
10
+ /** Show this directory's contents directly and prevent navigation outside it. */
11
+ rootLocation?: FileBrowserLocation
12
+ }
13
+
14
+ /** Opaque picker identity; decode before passing a selection to a mount-scoped API. */
15
+ export function encodePlatformPickerPath(mountId: string, path = ''): string {
16
+ return JSON.stringify([mountId, path])
17
+ }
18
+ export function decodePlatformPickerPath(path: string): { mountId: string; path: string } {
19
+ const decoded: unknown = JSON.parse(path)
20
+ if (
21
+ !Array.isArray(decoded) || decoded.length !== 2 ||
22
+ !decoded.every((item) => typeof item === 'string') || !decoded[0]
23
+ ) throw new Error('Invalid platform picker path.')
24
+ return { mountId: decoded[0], path: decoded[1] }
25
+ }
26
+
27
+ function isRelativePath(path: string): boolean {
28
+ return path === '' || (!/^[a-z]:/i.test(path) && !/[\\\x00-\x1f\x7f]/.test(path) &&
29
+ path.split('/').every((part) => part !== '' && part !== '.' && part !== '..'))
30
+ }
31
+
32
+ /** Mount browsing and search shared by platform and generated plugin clients. */
33
+ export function createFilePickerAdapter(
34
+ transport: FilePickerTransport,
35
+ options: FilePickerAdapterOptions = {},
36
+ ): PickerAdapter {
37
+ const root = options.rootLocation && { ...options.rootLocation }
38
+ const mountLabels = new Map<string, string>()
39
+ // Per picker instance: never share permission-sensitive listings across clients.
40
+ const listings = new Map<string, PickerNode[]>()
41
+ let generation = 0
42
+
43
+ function invalidate(): void {
44
+ generation++
45
+ listings.clear()
46
+ mountLabels.clear()
47
+ }
48
+
49
+ function validateLocation(location: FileBrowserLocation) {
50
+ if (!location.mount_id || !isRelativePath(location.path) || (root && (
51
+ location.mount_id !== root.mount_id ||
52
+ (root.path !== '' && location.path !== root.path && !location.path.startsWith(`${root.path}/`))
53
+ ))) throw new Error('Invalid file picker location.')
54
+ }
55
+ if (root) validateLocation(root)
56
+ function begin(request?: PickerRequest): number {
57
+ request?.signal?.throwIfAborted()
58
+ if (request?.refresh) invalidate()
59
+ return generation
60
+ }
61
+ function check(request: PickerRequest | undefined, token: number) {
62
+ request?.signal?.throwIfAborted()
63
+ if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
64
+ }
65
+ async function read(path: string, request: PickerRequest | undefined, token: number): Promise<PickerNode[]> {
66
+ check(request, token)
67
+ const decoded = path ? decodePlatformPickerPath(path) : undefined
68
+ const location = decoded ? { mount_id: decoded.mountId, path: decoded.path } : root
69
+ if (location) validateLocation(location)
70
+ const key = location ? encodePlatformPickerPath(location.mount_id, location.path) : ''
71
+ const cached = listings.get(key)
72
+ if (cached) return cached
73
+ let nodes: PickerNode[]
74
+ if (!location) {
75
+ const mounts = await transport.listMounts(request)
76
+ check(request, token)
77
+ nodes = mounts.map((mount) => {
78
+ mountLabels.set(mount.id, mount.label)
79
+ return {
80
+ kind: 'folder', path: encodePlatformPickerPath(mount.id), name: mount.label,
81
+ parentPath: null, parentName: 'Server mounts',
82
+ unavailableReason: mount.available === false ? 'This mount is offline.' : undefined,
83
+ }
84
+ })
85
+ } else {
86
+ const listing = await transport.browse(location, request)
87
+ check(request, token)
88
+ if (listing.truncated)
89
+ throw new Error('This directory exceeds the server listing limit. Choose a smaller data directory.')
90
+ const atRoot = root && location.mount_id === root.mount_id && location.path === root.path
91
+ nodes = (listing.entries ?? []).map((entry): PickerNode => {
92
+ if (!isRelativePath(entry.path) || !entry.name ||
93
+ entry.path !== `${location.path ? `${location.path}/` : ''}${entry.name}` ||
94
+ entry.name.includes('/')) throw new Error('Invalid file picker directory entry.')
95
+ const common = {
96
+ path: encodePlatformPickerPath(location.mount_id, entry.path),
97
+ parentPath: atRoot ? null : key,
98
+ parentName: `${mountLabels.get(location.mount_id) ?? location.mount_id}${location.path ? ` / ${location.path}` : ''}`,
99
+ name: entry.name, bytes: entry.size_bytes,
100
+ }
101
+ return entry.kind === 'directory'
102
+ // Counts may include only matching formats, not nested directories.
103
+ ? { ...common, kind: 'folder' }
104
+ : { ...common, kind: 'file', format: pickerFormat(entry.name) }
105
+ })
106
+ }
107
+ listings.set(key, nodes)
108
+ return nodes
109
+ }
110
+ return {
111
+ invalidate,
112
+ listRoot: async (request) => read('', request, begin(request)),
113
+ listChildren: async (path, request) => read(path, request, begin(request)),
114
+ async search(query, scope, request) {
115
+ const token = begin(request)
116
+ const matches: PickerNode[] = []
117
+ const queue = [scope.path]
118
+ const visited = new Set<string>()
119
+ const needle = query.toLowerCase()
120
+ let currentRequest = request
121
+ while (queue.length) {
122
+ check(request, token)
123
+ const path = queue.shift()!
124
+ if (visited.has(path)) continue
125
+ visited.add(path)
126
+ if (visited.size > 500)
127
+ throw new Error('Search reached its folder limit. Search a smaller location or turn off Subfolders.')
128
+ const nodes = await read(path, currentRequest, token)
129
+ check(request, token)
130
+ if (currentRequest?.refresh) {
131
+ currentRequest = { ...currentRequest }
132
+ delete currentRequest.refresh
133
+ }
134
+ for (const node of nodes) {
135
+ if (node.name.toLowerCase().includes(needle)) matches.push(node)
136
+ if (scope.recursive && node.kind === 'folder' && !node.unavailableReason)
137
+ queue.push(node.path)
138
+ }
139
+ if (matches.length > 10000)
140
+ throw new Error('Too many search results. Use a more specific search.')
141
+ }
142
+ return matches
143
+ },
144
+ }
145
+ }
@@ -26,10 +26,43 @@ export function usePickerNavigation(
26
26
  let controller: AbortController | undefined
27
27
  const pending = new Map<string, Promise<PickerNode[]>>()
28
28
  const childControllers = new Set<AbortController>()
29
+ let prefetchTimer: ReturnType<typeof setTimeout> | undefined
30
+ let prefetchQueue: PickerNode[] = []
31
+ let prefetchActive = 0
32
+
33
+ function stopPrefetch() {
34
+ clearTimeout(prefetchTimer)
35
+ prefetchQueue = []
36
+ }
37
+ function drainPrefetch() {
38
+ while (prefetchActive < 2 && prefetchQueue.length) {
39
+ const node = prefetchQueue.shift()!
40
+ if (!isAllowed(node) || children.value.has(node.path) || pending.has(node.path)) continue
41
+ prefetchActive++
42
+ const token = epoch
43
+ void loadChildren(node.path).catch(() => {
44
+ // Speculative failures are retried and reported only when the folder is opened.
45
+ }).finally(() => {
46
+ if (token !== epoch) return
47
+ prefetchActive--
48
+ drainPrefetch()
49
+ })
50
+ }
51
+ }
52
+ function prefetch(items: PickerNode[]) {
53
+ stopPrefetch()
54
+ // ponytail: warm at most 20 immediate folders, two at a time; use a server index for full-tree search.
55
+ const candidates = items.filter((node) => node.kind === 'folder' &&
56
+ !node.unavailableReason && node.hasChildren !== false && isAllowed(node)).slice(0, 20)
57
+ prefetchTimer = setTimeout(() => {
58
+ prefetchQueue = candidates
59
+ drainPrefetch()
60
+ }, 100)
61
+ }
29
62
 
30
63
  function isAllowed(node: PickerNode): boolean {
31
64
  const seen = new Set<string>()
32
- let current: PickerNode | undefined = node
65
+ let current: PickerNode | undefined = nodes.value.get(node.path) ?? node
33
66
  while (current && !seen.has(current.path)) {
34
67
  if (!systemFilter(current)) return false
35
68
  seen.add(current.path)
@@ -51,6 +84,9 @@ export function usePickerNavigation(
51
84
  })
52
85
  }
53
86
  function invalidate() {
87
+ adapter().invalidate?.()
88
+ stopPrefetch()
89
+ prefetchActive = 0
54
90
  ++epoch
55
91
  ++navigation
56
92
  controller?.abort()
@@ -111,6 +147,7 @@ export function usePickerNavigation(
111
147
  return task
112
148
  }
113
149
  async function load(query = '', recursive = false, refresh = false) {
150
+ stopPrefetch()
114
151
  const token = ++navigation
115
152
  controller?.abort()
116
153
  controller = new AbortController()
@@ -127,6 +164,7 @@ export function usePickerNavigation(
127
164
  if (token !== navigation) return
128
165
  register(items, query.trim() ? undefined : path.value)
129
166
  entries.value = items
167
+ if (!query.trim()) prefetch(items)
130
168
  } catch (cause) {
131
169
  if (token === navigation)
132
170
  error.value = cause instanceof Error ? cause.message : 'Could not read this location.'
@@ -144,8 +182,10 @@ export function usePickerNavigation(
144
182
  expanding.value.add(node.path)
145
183
  expansionErrors.value.delete(node.path)
146
184
  const token = epoch
185
+ const view = navigation
147
186
  try {
148
- await loadChildren(node.path)
187
+ const items = await loadChildren(node.path)
188
+ if (token === epoch && view === navigation && expanded.value.has(node.path)) prefetch(items)
149
189
  } catch (cause) {
150
190
  if (token === epoch)
151
191
  expansionErrors.value.set(
@@ -581,3 +581,5 @@ export {
581
581
  } from './useMenuKeyboard'
582
582
 
583
583
  export { usePlatformFilePickerAdapter, encodePlatformPickerPath, decodePlatformPickerPath } from './usePlatformFilePickerAdapter'
584
+ export { createFilePickerAdapter } from './createFilePickerAdapter'
585
+ export type { FilePickerTransport, FilePickerAdapterOptions } from './createFilePickerAdapter'
@@ -166,7 +166,7 @@ export function useFileBrowser(options: UseFileBrowserOptions = {}): UseFileBrow
166
166
  }
167
167
  }
168
168
 
169
- async function navigate(location: { mount_id: string; path: string }): Promise<void> {
169
+ async function navigate(location: { mount_id: string; path: string }, refresh = false): Promise<void> {
170
170
  if (disposed || !location.mount_id) return
171
171
  clearTimeout(searchTimer)
172
172
  const token = ++listToken
@@ -182,6 +182,7 @@ export function useFileBrowser(options: UseFileBrowserOptions = {}): UseFileBrow
182
182
  `mount_id=${encodeURIComponent(location.mount_id)}` +
183
183
  `&path=${encodeURIComponent(location.path)}` +
184
184
  `&sort=${sort.value}` +
185
+ (refresh ? '&refresh=true' : '') +
185
186
  (search.value ? `&search=${encodeURIComponent(search.value)}` : '') +
186
187
  typeRuleParams()
187
188
  const listing = await api.get<FileDirectoryListing>(`/filesystem/browse?${query}`, {
@@ -230,7 +231,7 @@ export function useFileBrowser(options: UseFileBrowserOptions = {}): UseFileBrow
230
231
 
231
232
  async function refresh(): Promise<void> {
232
233
  if (disposed) return
233
- if (mountId.value) await navigate({ mount_id: mountId.value, path: path.value })
234
+ if (mountId.value) await navigate({ mount_id: mountId.value, path: path.value }, true)
234
235
  else await init()
235
236
  }
236
237
 
@@ -263,18 +264,18 @@ export function useFileBrowser(options: UseFileBrowserOptions = {}): UseFileBrow
263
264
  clearTimeout(searchTimer)
264
265
  // Debounced: search changes on every keystroke.
265
266
  searchTimer = setTimeout(() => {
266
- void refresh()
267
+ void navigate({ mount_id: mountId.value, path: path.value })
267
268
  }, 250)
268
269
  })
269
270
 
270
271
  watch(sort, () => {
271
- if (mountId.value) void refresh()
272
+ if (mountId.value) void navigate({ mount_id: mountId.value, path: path.value })
272
273
  })
273
274
 
274
275
  watch(
275
276
  () => toValue(options.typeRules),
276
277
  () => {
277
- if (mountId.value) void refresh()
278
+ if (mountId.value) void navigate({ mount_id: mountId.value, path: path.value })
278
279
  },
279
280
  { deep: true },
280
281
  )