@morscherlab/mint-sdk 1.2.0-beta.11 → 1.2.0-beta.13
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/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-BIZKoRTT.js} +19 -12
- package/dist/ExperimentPopover-BIZKoRTT.js.map +1 -0
- package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
- package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
- package/dist/components/FileBrowserModal.vue.d.ts +99 -0
- package/dist/components/FilePicker.vue.d.ts +32 -0
- package/dist/components/fileBrowserPresentation.d.ts +10 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +3 -3
- package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
- package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
- package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
- package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
- package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
- package/dist/{components-CgAUzF8F.js → components-DuQRC810.js} +3727 -1521
- package/dist/components-DuQRC810.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +3 -0
- package/dist/composables/filePicker/usePickerNavigation.d.ts +95 -0
- package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
- package/dist/composables/filePicker/validation.d.ts +5 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/index.js +3 -3
- package/dist/composables/useFileBrowser.d.ts +55 -0
- package/dist/composables/useFilePicker.d.ts +190 -0
- package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
- package/dist/{composables-B_pia3hO.js → composables-hY2Q2xPI.js} +289 -3
- package/dist/composables-hY2Q2xPI.js.map +1 -0
- package/dist/index.js +5 -5
- package/dist/install.js +1 -1
- package/dist/styles.css +2232 -1259
- package/dist/types/fileBrowserTypes.d.ts +77 -0
- package/dist/types/filePicker.d.ts +69 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-nBbSn1TI.js} +46 -2
- package/dist/validation-nBbSn1TI.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
- package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
- package/src/__tests__/components/FilePicker.state.test.ts +160 -0
- package/src/__tests__/components/FilePicker.test.ts +328 -0
- package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
- package/src/components/ExperimentPopover.vue +10 -2
- package/src/components/FileBrowserModal.story.vue +203 -0
- package/src/components/FileBrowserModal.vue +384 -0
- package/src/components/FilePicker.vue +301 -0
- package/src/components/fileBrowserPresentation.ts +39 -0
- package/src/components/index.ts +4 -0
- package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
- package/src/components/internal/FileBrowserListingInternal.vue +197 -0
- package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
- package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
- package/src/components/internal/FilePickerTreeInternal.vue +238 -0
- package/src/composables/filePicker/localFiles.ts +55 -0
- package/src/composables/filePicker/usePickerNavigation.ts +199 -0
- package/src/composables/filePicker/usePickerSelection.ts +242 -0
- package/src/composables/filePicker/validation.ts +40 -0
- package/src/composables/index.ts +7 -0
- package/src/composables/useFileBrowser.ts +314 -0
- package/src/composables/useFilePicker.ts +347 -0
- package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
- package/src/styles/components/file-browser-modal.css +489 -0
- package/src/styles/components/file-picker.css +501 -0
- package/src/styles/index.css +3 -0
- package/src/types/fileBrowserTypes.ts +85 -0
- package/src/types/filePicker.ts +65 -0
- package/src/types/index.ts +14 -0
- package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
- package/dist/components-CgAUzF8F.js.map +0 -1
- package/dist/composables-B_pia3hO.js.map +0 -1
- package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { computed, onScopeDispose, ref, shallowRef } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
PickerAdapter,
|
|
4
|
+
PickerNode,
|
|
5
|
+
PickerRequest,
|
|
6
|
+
PickerTreeRow,
|
|
7
|
+
} from '../../types/filePicker'
|
|
8
|
+
|
|
9
|
+
export function usePickerNavigation(adapter: () => PickerAdapter, request: () => PickerRequest) {
|
|
10
|
+
const path = shallowRef('')
|
|
11
|
+
const trail = ref<{ path: string; label: string }[]>([])
|
|
12
|
+
const entries = shallowRef<PickerNode[]>([])
|
|
13
|
+
const nodes = ref(new Map<string, PickerNode>())
|
|
14
|
+
const children = shallowRef(new Map<string, PickerNode[]>())
|
|
15
|
+
const expanded = ref(new Set<string>())
|
|
16
|
+
const expanding = ref(new Set<string>())
|
|
17
|
+
const expansionErrors = ref(new Map<string, string>())
|
|
18
|
+
const loading = shallowRef(false)
|
|
19
|
+
const error = shallowRef('')
|
|
20
|
+
let epoch = 0
|
|
21
|
+
let navigation = 0
|
|
22
|
+
let controller: AbortController | undefined
|
|
23
|
+
const pending = new Map<string, Promise<PickerNode[]>>()
|
|
24
|
+
const childControllers = new Set<AbortController>()
|
|
25
|
+
|
|
26
|
+
function register(items: PickerNode[], parent?: string) {
|
|
27
|
+
for (const item of items)
|
|
28
|
+
nodes.value.set(item.path, {
|
|
29
|
+
...item,
|
|
30
|
+
parentPath:
|
|
31
|
+
item.parentPath !== undefined
|
|
32
|
+
? item.parentPath
|
|
33
|
+
: parent !== undefined
|
|
34
|
+
? parent || null
|
|
35
|
+
: (nodes.value.get(item.path)?.parentPath ?? null),
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
function invalidate() {
|
|
39
|
+
++epoch
|
|
40
|
+
++navigation
|
|
41
|
+
controller?.abort()
|
|
42
|
+
childControllers.forEach((item) => item.abort())
|
|
43
|
+
childControllers.clear()
|
|
44
|
+
pending.clear()
|
|
45
|
+
children.value = new Map()
|
|
46
|
+
expanding.value.clear()
|
|
47
|
+
expansionErrors.value.clear()
|
|
48
|
+
}
|
|
49
|
+
function clear() {
|
|
50
|
+
invalidate()
|
|
51
|
+
nodes.value.clear()
|
|
52
|
+
expanded.value.clear()
|
|
53
|
+
expanding.value.clear()
|
|
54
|
+
expansionErrors.value.clear()
|
|
55
|
+
entries.value = []
|
|
56
|
+
loading.value = false
|
|
57
|
+
error.value = ''
|
|
58
|
+
}
|
|
59
|
+
async function loadChildren(folder: string, ancestors: string[] = []): Promise<PickerNode[]> {
|
|
60
|
+
if (ancestors.includes(folder)) throw new Error('The adapter returned a circular folder tree.')
|
|
61
|
+
const cached = children.value.get(folder)
|
|
62
|
+
if (cached) return cached
|
|
63
|
+
const existing = pending.get(folder)
|
|
64
|
+
if (existing) return existing
|
|
65
|
+
const token = epoch
|
|
66
|
+
const abort = new AbortController()
|
|
67
|
+
childControllers.add(abort)
|
|
68
|
+
const task = adapter()
|
|
69
|
+
.listChildren(folder, { ...request(), signal: abort.signal })
|
|
70
|
+
.then((items) => {
|
|
71
|
+
if (token !== epoch) throw new DOMException('Cancelled', 'AbortError')
|
|
72
|
+
const retained = new Set(items.map((item) => item.path))
|
|
73
|
+
function remove(path: string, seen = new Set<string>()) {
|
|
74
|
+
if (seen.has(path) || path === folder) return
|
|
75
|
+
seen.add(path)
|
|
76
|
+
for (const node of nodes.value.values())
|
|
77
|
+
if (node.parentPath === path) remove(node.path, seen)
|
|
78
|
+
nodes.value.delete(path)
|
|
79
|
+
children.value.delete(path)
|
|
80
|
+
}
|
|
81
|
+
for (const node of nodes.value.values())
|
|
82
|
+
if (node.parentPath === folder && !retained.has(node.path)) remove(node.path)
|
|
83
|
+
register(items, folder)
|
|
84
|
+
children.value = new Map(children.value).set(folder, items)
|
|
85
|
+
return items
|
|
86
|
+
})
|
|
87
|
+
.finally(() => {
|
|
88
|
+
childControllers.delete(abort)
|
|
89
|
+
if (token === epoch) pending.delete(folder)
|
|
90
|
+
})
|
|
91
|
+
pending.set(folder, task)
|
|
92
|
+
return task
|
|
93
|
+
}
|
|
94
|
+
async function load(query = '', recursive = false) {
|
|
95
|
+
const token = ++navigation
|
|
96
|
+
controller?.abort()
|
|
97
|
+
controller = new AbortController()
|
|
98
|
+
const options = { ...request(), signal: controller.signal }
|
|
99
|
+
loading.value = true
|
|
100
|
+
error.value = ''
|
|
101
|
+
entries.value = []
|
|
102
|
+
try {
|
|
103
|
+
const items = query.trim()
|
|
104
|
+
? await adapter().search(query.trim(), { path: path.value, recursive }, options)
|
|
105
|
+
: path.value
|
|
106
|
+
? await loadChildren(path.value)
|
|
107
|
+
: await adapter().listRoot(options)
|
|
108
|
+
if (token !== navigation) return
|
|
109
|
+
register(items, query.trim() ? undefined : path.value)
|
|
110
|
+
entries.value = items
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
if (token === navigation)
|
|
113
|
+
error.value = cause instanceof Error ? cause.message : 'Could not read this location.'
|
|
114
|
+
} finally {
|
|
115
|
+
if (token === navigation) loading.value = false
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function expand(node: PickerNode) {
|
|
119
|
+
if (node.kind !== 'folder' || node.unavailableReason) return
|
|
120
|
+
if (expanded.value.has(node.path)) {
|
|
121
|
+
expanded.value.delete(node.path)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
expanded.value.add(node.path)
|
|
125
|
+
expanding.value.add(node.path)
|
|
126
|
+
expansionErrors.value.delete(node.path)
|
|
127
|
+
const token = epoch
|
|
128
|
+
try {
|
|
129
|
+
await loadChildren(node.path)
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
if (token === epoch)
|
|
132
|
+
expansionErrors.value.set(
|
|
133
|
+
node.path,
|
|
134
|
+
cause instanceof Error ? cause.message : 'Could not read this folder.',
|
|
135
|
+
)
|
|
136
|
+
} finally {
|
|
137
|
+
if (token === epoch) expanding.value.delete(node.path)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function enter(node: PickerNode) {
|
|
141
|
+
if (node.kind !== 'folder' || node.unavailableReason) return
|
|
142
|
+
const index = trail.value.findIndex((crumb) => crumb.path === node.path)
|
|
143
|
+
trail.value =
|
|
144
|
+
index >= 0
|
|
145
|
+
? trail.value.slice(0, index + 1)
|
|
146
|
+
: [...trail.value, { path: node.path, label: node.name }]
|
|
147
|
+
path.value = node.path
|
|
148
|
+
}
|
|
149
|
+
function up(target?: string) {
|
|
150
|
+
const index =
|
|
151
|
+
target !== undefined
|
|
152
|
+
? trail.value.findIndex((crumb) => crumb.path === target)
|
|
153
|
+
: trail.value.length - 2
|
|
154
|
+
trail.value = index < 0 ? [] : trail.value.slice(0, index + 1)
|
|
155
|
+
path.value = trail.value.at(-1)?.path ?? ''
|
|
156
|
+
}
|
|
157
|
+
const rows = computed(() => {
|
|
158
|
+
const result: PickerTreeRow[] = []
|
|
159
|
+
function visit(items: PickerNode[], depth: number, ancestors: Set<string>) {
|
|
160
|
+
for (const item of items) {
|
|
161
|
+
if (ancestors.has(item.path) || depth > 30) continue
|
|
162
|
+
const node = nodes.value.get(item.path) ?? item
|
|
163
|
+
result.push({
|
|
164
|
+
node,
|
|
165
|
+
depth,
|
|
166
|
+
expanded: expanded.value.has(item.path),
|
|
167
|
+
loading: expanding.value.has(item.path),
|
|
168
|
+
error: expansionErrors.value.get(item.path),
|
|
169
|
+
})
|
|
170
|
+
if (expanded.value.has(item.path))
|
|
171
|
+
visit(children.value.get(item.path) ?? [], depth + 1, new Set([...ancestors, item.path]))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
visit(entries.value, 0, new Set())
|
|
175
|
+
return result
|
|
176
|
+
})
|
|
177
|
+
onScopeDispose(clear)
|
|
178
|
+
return {
|
|
179
|
+
path,
|
|
180
|
+
trail,
|
|
181
|
+
entries,
|
|
182
|
+
nodes,
|
|
183
|
+
children,
|
|
184
|
+
expanded,
|
|
185
|
+
expanding,
|
|
186
|
+
expansionErrors,
|
|
187
|
+
loading,
|
|
188
|
+
error,
|
|
189
|
+
rows,
|
|
190
|
+
clear,
|
|
191
|
+
invalidate,
|
|
192
|
+
register,
|
|
193
|
+
loadChildren,
|
|
194
|
+
load,
|
|
195
|
+
expand,
|
|
196
|
+
enter,
|
|
197
|
+
up,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { computed, ref, shallowRef } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
PickerCapability,
|
|
4
|
+
PickerExcluded,
|
|
5
|
+
PickerFile,
|
|
6
|
+
PickerNode,
|
|
7
|
+
PickerSelection,
|
|
8
|
+
PickerSelectionMode,
|
|
9
|
+
} from '../../types/filePicker'
|
|
10
|
+
import type { usePickerNavigation } from './usePickerNavigation'
|
|
11
|
+
import { pickerExclusion } from './validation'
|
|
12
|
+
|
|
13
|
+
type Navigation = ReturnType<typeof usePickerNavigation>
|
|
14
|
+
export function usePickerSelection(
|
|
15
|
+
nav: Navigation,
|
|
16
|
+
mode: () => PickerSelectionMode,
|
|
17
|
+
capability: () => PickerCapability | undefined,
|
|
18
|
+
skipBlanks: () => boolean,
|
|
19
|
+
) {
|
|
20
|
+
const keys = ref(new Set<string>())
|
|
21
|
+
const dropped = ref(new Set<string>())
|
|
22
|
+
const focusPath = shallowRef('')
|
|
23
|
+
const resolving = shallowRef(false)
|
|
24
|
+
const resolutionError = shallowRef('')
|
|
25
|
+
let anchor = ''
|
|
26
|
+
let generation = 0
|
|
27
|
+
|
|
28
|
+
function ancestorSelected(node: PickerNode, candidates = keys.value): boolean {
|
|
29
|
+
const seen = new Set<string>()
|
|
30
|
+
let parent = node.parentPath
|
|
31
|
+
while (parent && !seen.has(parent)) {
|
|
32
|
+
if (candidates.has(parent)) return true
|
|
33
|
+
seen.add(parent)
|
|
34
|
+
parent = nav.nodes.value.get(parent)?.parentPath
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
function exclusion(node: PickerNode) {
|
|
39
|
+
const own = pickerExclusion(node, capability(), skipBlanks())
|
|
40
|
+
if (own) return own
|
|
41
|
+
const seen = new Set<string>()
|
|
42
|
+
let parent = node.parentPath
|
|
43
|
+
while (parent && !seen.has(parent)) {
|
|
44
|
+
seen.add(parent)
|
|
45
|
+
const folder = nav.nodes.value.get(parent)
|
|
46
|
+
if (!folder) break
|
|
47
|
+
const inherited = pickerExclusion(folder, capability(), skipBlanks())
|
|
48
|
+
if (inherited) return { ...inherited, path: node.path, name: node.name }
|
|
49
|
+
parent = folder.parentPath
|
|
50
|
+
}
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
function selected(node: PickerNode): boolean {
|
|
54
|
+
return (
|
|
55
|
+
!exclusion(node) &&
|
|
56
|
+
!dropped.value.has(node.path) &&
|
|
57
|
+
!ancestorSelected(node, dropped.value) &&
|
|
58
|
+
(keys.value.has(node.path) || (mode() === 'folder+files' && ancestorSelected(node)))
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
function selectable(node: PickerNode): boolean {
|
|
62
|
+
if (exclusion(node)) return false
|
|
63
|
+
return node.kind === 'folder'
|
|
64
|
+
? mode() === 'folder' || mode() === 'folder+files'
|
|
65
|
+
: mode() !== 'folder'
|
|
66
|
+
}
|
|
67
|
+
function select(
|
|
68
|
+
node: PickerNode,
|
|
69
|
+
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; toggle?: boolean } = {},
|
|
70
|
+
) {
|
|
71
|
+
if (exclusion(node)) return
|
|
72
|
+
focusPath.value = node.path
|
|
73
|
+
if (!selectable(node)) return
|
|
74
|
+
const multi = mode() === 'multi-file' || mode() === 'folder+files'
|
|
75
|
+
const additive = multi && (modifiers.metaKey || modifiers.ctrlKey)
|
|
76
|
+
if (multi && modifiers.shiftKey && anchor) {
|
|
77
|
+
const rows = nav.rows.value.map((row) => row.node)
|
|
78
|
+
const from = rows.findIndex((item) => item.path === anchor)
|
|
79
|
+
const to = rows.findIndex((item) => item.path === node.path)
|
|
80
|
+
if (from >= 0 && to >= 0) {
|
|
81
|
+
keys.value = new Set([
|
|
82
|
+
...(additive ? keys.value : []),
|
|
83
|
+
...rows
|
|
84
|
+
.slice(Math.min(from, to), Math.max(from, to) + 1)
|
|
85
|
+
.filter(selectable)
|
|
86
|
+
.map((item) => item.path),
|
|
87
|
+
])
|
|
88
|
+
dropped.value.clear()
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (additive || (modifiers.toggle && (selected(node) || dropped.value.has(node.path)))) {
|
|
93
|
+
if (mode() === 'folder+files' && ancestorSelected(node)) {
|
|
94
|
+
if (dropped.value.has(node.path)) dropped.value.delete(node.path)
|
|
95
|
+
else dropped.value.add(node.path)
|
|
96
|
+
} else if (keys.value.has(node.path)) keys.value.delete(node.path)
|
|
97
|
+
else {
|
|
98
|
+
keys.value.add(node.path)
|
|
99
|
+
dropped.value.delete(node.path)
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
keys.value = new Set([node.path])
|
|
103
|
+
dropped.value.clear()
|
|
104
|
+
}
|
|
105
|
+
anchor = node.path
|
|
106
|
+
}
|
|
107
|
+
function reset(selection?: PickerSelection | null) {
|
|
108
|
+
++generation
|
|
109
|
+
keys.value.clear()
|
|
110
|
+
dropped.value.clear()
|
|
111
|
+
resolving.value = false
|
|
112
|
+
resolutionError.value = ''
|
|
113
|
+
focusPath.value = ''
|
|
114
|
+
if (selection?.source === 'server') {
|
|
115
|
+
const files =
|
|
116
|
+
mode() === 'folder'
|
|
117
|
+
? []
|
|
118
|
+
: mode() === 'single-file'
|
|
119
|
+
? selection.files.slice(0, 1)
|
|
120
|
+
: selection.files
|
|
121
|
+
const folders =
|
|
122
|
+
mode() === 'folder'
|
|
123
|
+
? selection.folders.slice(0, 1)
|
|
124
|
+
: mode() === 'folder+files'
|
|
125
|
+
? selection.folders
|
|
126
|
+
: []
|
|
127
|
+
nav.register(files)
|
|
128
|
+
folders.forEach((path) => {
|
|
129
|
+
if (!nav.nodes.value.has(path))
|
|
130
|
+
nav.register([
|
|
131
|
+
{ kind: 'folder', path, name: 'Selected folder', parentName: 'Selected location' },
|
|
132
|
+
])
|
|
133
|
+
keys.value.add(path)
|
|
134
|
+
})
|
|
135
|
+
files.forEach((file) => keys.value.add(file.path))
|
|
136
|
+
selection.excluded
|
|
137
|
+
.filter((item) => item.reason === 'deselected')
|
|
138
|
+
.forEach((item) => dropped.value.add(item.path))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const roots = computed(() =>
|
|
142
|
+
[...keys.value]
|
|
143
|
+
.map((key) => nav.nodes.value.get(key))
|
|
144
|
+
.filter((node): node is PickerNode => !!node && !ancestorSelected(node)),
|
|
145
|
+
)
|
|
146
|
+
const chosen = computed(() => {
|
|
147
|
+
const files: PickerFile[] = []
|
|
148
|
+
const excluded: PickerExcluded[] = []
|
|
149
|
+
for (const node of nav.nodes.value.values()) {
|
|
150
|
+
if (!keys.value.has(node.path) && !ancestorSelected(node)) continue
|
|
151
|
+
const reason =
|
|
152
|
+
exclusion(node) ??
|
|
153
|
+
(dropped.value.has(node.path)
|
|
154
|
+
? { path: node.path, name: node.name, reason: 'deselected' as const }
|
|
155
|
+
: null)
|
|
156
|
+
// A dropped folder excludes its descendants as well.
|
|
157
|
+
const droppedByParent = ancestorSelected(node, dropped.value)
|
|
158
|
+
if (reason || droppedByParent) {
|
|
159
|
+
if (node.kind === 'file' || reason?.reason === 'unreadable')
|
|
160
|
+
excluded.push(reason ?? { path: node.path, name: node.name, reason: 'deselected' })
|
|
161
|
+
} else if (node.kind === 'file') files.push(node)
|
|
162
|
+
}
|
|
163
|
+
return { files, excluded, bytes: files.reduce((total, node) => total + (node.bytes ?? 0), 0) }
|
|
164
|
+
})
|
|
165
|
+
async function resolve() {
|
|
166
|
+
const token = ++generation
|
|
167
|
+
resolving.value = true
|
|
168
|
+
resolutionError.value = ''
|
|
169
|
+
let count = 0
|
|
170
|
+
const seen = new Set<string>()
|
|
171
|
+
async function walk(node: PickerNode, ancestors: string[] = []): Promise<PickerFile[]> {
|
|
172
|
+
if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
|
|
173
|
+
if (node.kind === 'file') return [node]
|
|
174
|
+
if (node.unavailableReason) return []
|
|
175
|
+
if (ancestors.includes(node.path))
|
|
176
|
+
throw new Error('The adapter returned a circular folder tree.')
|
|
177
|
+
if (seen.has(node.path)) return []
|
|
178
|
+
seen.add(node.path)
|
|
179
|
+
if (++count > 500 || ancestors.length > 30)
|
|
180
|
+
throw new Error('This selection is too large to validate. Choose a smaller folder.')
|
|
181
|
+
const children = await nav.loadChildren(node.path, ancestors)
|
|
182
|
+
const files: PickerFile[] = []
|
|
183
|
+
for (const child of children) files.push(...(await walk(child, [...ancestors, node.path])))
|
|
184
|
+
if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
|
|
185
|
+
const formats: Record<string, number> = {}
|
|
186
|
+
files.forEach((file) => {
|
|
187
|
+
formats[file.format] = (formats[file.format] ?? 0) + 1
|
|
188
|
+
})
|
|
189
|
+
nav.register([
|
|
190
|
+
{
|
|
191
|
+
...node,
|
|
192
|
+
fileCount: files.length,
|
|
193
|
+
bytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0),
|
|
194
|
+
formats,
|
|
195
|
+
},
|
|
196
|
+
])
|
|
197
|
+
return files
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
for (const node of roots.value) await walk(node)
|
|
201
|
+
if (token === generation) {
|
|
202
|
+
// Resolved descendants inherit their folder, including restored selections.
|
|
203
|
+
for (const key of [...keys.value]) {
|
|
204
|
+
const node = nav.nodes.value.get(key)
|
|
205
|
+
if (node && ancestorSelected(node)) keys.value.delete(key)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch (cause) {
|
|
209
|
+
if (token === generation)
|
|
210
|
+
resolutionError.value =
|
|
211
|
+
cause instanceof Error ? cause.message : 'Could not validate the selection.'
|
|
212
|
+
} finally {
|
|
213
|
+
if (token === generation) resolving.value = false
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const focused = computed(() => nav.nodes.value.get(focusPath.value) ?? roots.value.at(-1) ?? null)
|
|
217
|
+
function folderProgress(node: PickerNode): string {
|
|
218
|
+
if (node.kind !== 'folder' || node.fileCount == null) return ''
|
|
219
|
+
const allowed = chosen.value.files.filter((file) =>
|
|
220
|
+
ancestorSelected(file, new Set([node.path])),
|
|
221
|
+
).length
|
|
222
|
+
return `${selected(node) ? allowed : 0} / ${node.fileCount}`
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
keys,
|
|
226
|
+
dropped,
|
|
227
|
+
focusPath,
|
|
228
|
+
resolving,
|
|
229
|
+
resolutionError,
|
|
230
|
+
roots,
|
|
231
|
+
chosen,
|
|
232
|
+
focused,
|
|
233
|
+
select,
|
|
234
|
+
selected,
|
|
235
|
+
selectable,
|
|
236
|
+
exclusion,
|
|
237
|
+
reset,
|
|
238
|
+
resolve,
|
|
239
|
+
folderProgress,
|
|
240
|
+
ancestorSelected,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { PickerCapability, PickerExcluded, PickerNode } from '../../types/filePicker'
|
|
2
|
+
|
|
3
|
+
export function normalizePickerFormat(format: string): string {
|
|
4
|
+
return format.trim().replace(/^\./, '').toLowerCase()
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function pickerExclusion(
|
|
8
|
+
node: PickerNode,
|
|
9
|
+
capability?: PickerCapability,
|
|
10
|
+
skipBlanks = false,
|
|
11
|
+
): PickerExcluded | null {
|
|
12
|
+
const base = { path: node.path, name: node.name }
|
|
13
|
+
if (node.unavailableReason) return { ...base, reason: 'unreadable' }
|
|
14
|
+
if (skipBlanks && capability?.isBlank?.(node)) return { ...base, reason: 'blank' }
|
|
15
|
+
if (node.kind === 'folder') return null
|
|
16
|
+
const formats = capability?.formats.map(normalizePickerFormat) ?? []
|
|
17
|
+
if (formats.length && !formats.some((format) => node.name.toLowerCase().endsWith(`.${format}`))) {
|
|
18
|
+
return { ...base, reason: 'unsupported' }
|
|
19
|
+
}
|
|
20
|
+
if (capability?.maxBytes != null && node.bytes != null && node.bytes > capability.maxBytes) {
|
|
21
|
+
return { ...base, reason: 'tooLarge' }
|
|
22
|
+
}
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function pickerReason(excluded: PickerExcluded | null, node?: PickerNode): string {
|
|
27
|
+
if (!excluded) return ''
|
|
28
|
+
return {
|
|
29
|
+
unsupported: 'This format is not supported by this source.',
|
|
30
|
+
tooLarge: 'This file exceeds the size limit for this source.',
|
|
31
|
+
blank: 'Skipped by the blank-file rule.',
|
|
32
|
+
unreadable: node?.unavailableReason || 'This item cannot be read.',
|
|
33
|
+
deselected: 'Removed from the selection.',
|
|
34
|
+
}[excluded.reason]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pickerFormat(name: string): string {
|
|
38
|
+
const compound = /\.(mzml|mzxml)\.gz$/i.exec(name)
|
|
39
|
+
return compound ? `${compound[1]}.gz` : name.includes('.') ? name.split('.').pop()! : ''
|
|
40
|
+
}
|
package/src/composables/index.ts
CHANGED
|
@@ -174,6 +174,11 @@ export {
|
|
|
174
174
|
type UseAnalysisArtifactsOptions,
|
|
175
175
|
type UseAnalysisArtifactsReturn,
|
|
176
176
|
} from './useAnalysisArtifacts'
|
|
177
|
+
export {
|
|
178
|
+
useFileBrowser,
|
|
179
|
+
type UseFileBrowserOptions,
|
|
180
|
+
type UseFileBrowserReturn,
|
|
181
|
+
} from './useFileBrowser'
|
|
177
182
|
export {
|
|
178
183
|
buildAnalysisArtifactTargetQuery,
|
|
179
184
|
parseAnalysisArtifactTarget,
|
|
@@ -564,3 +569,5 @@ export {
|
|
|
564
569
|
type UseMenuKeyboardOptions,
|
|
565
570
|
type UseMenuKeyboardReturn,
|
|
566
571
|
} from './useMenuKeyboard'
|
|
572
|
+
|
|
573
|
+
export { usePlatformFilePickerAdapter, encodePlatformPickerPath, decodePlatformPickerPath } from './usePlatformFilePickerAdapter'
|