@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.
- package/dist/__tests__/composables/createFilePickerAdapter.test.d.ts +1 -0
- package/dist/__tests__/composables/usePickerNavigation.test.d.ts +1 -0
- package/dist/components/index.js +1 -1
- package/dist/{components-CxQVQCpP.js → components-CZzBCakZ.js} +38 -3
- package/dist/{components-CxQVQCpP.js.map → components-CZzBCakZ.js.map} +1 -1
- package/dist/composables/createFilePickerAdapter.d.ts +18 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/index.js +2 -2
- package/dist/composables/usePlatformFilePickerAdapter.d.ts +3 -7
- package/dist/{composables-DAqOU8Aq.js → composables-BCbYb2lA.js} +125 -42
- package/dist/composables-BCbYb2lA.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/types/filePicker.d.ts +2 -0
- package/package.json +1 -1
- package/src/__tests__/components/FilePicker.state.test.ts +19 -0
- package/src/__tests__/composables/createFilePickerAdapter.test.ts +142 -0
- package/src/__tests__/composables/useFileBrowser.test.ts +12 -0
- package/src/__tests__/composables/usePickerNavigation.test.ts +149 -0
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +18 -0
- package/src/composables/createFilePickerAdapter.ts +145 -0
- package/src/composables/filePicker/usePickerNavigation.ts +42 -2
- package/src/composables/index.ts +2 -0
- package/src/composables/useFileBrowser.ts +6 -5
- package/src/composables/usePlatformFilePickerAdapter.ts +24 -91
- package/src/types/filePicker.ts +2 -0
- 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(
|
package/src/composables/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
278
|
+
if (mountId.value) void navigate({ mount_id: mountId.value, path: path.value })
|
|
278
279
|
},
|
|
279
280
|
{ deep: true },
|
|
280
281
|
)
|