@morscherlab/mint-sdk 1.2.0-beta.9 → 1.2.0
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/README.md +30 -0
- package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
- package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DCXmwKNS.js} +5 -5
- package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
- package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
- package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
- package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
- package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
- package/dist/__tests__/apiErrors.test.d.ts +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__/components/PlotlyChart.test.d.ts +1 -0
- package/dist/__tests__/components/iconButtonIconSizing.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/apiErrors.d.ts +26 -0
- package/dist/components/BaseInput.vue.d.ts +1 -1
- package/dist/components/BaseSelect.vue.d.ts +1 -1
- package/dist/components/BaseTextarea.vue.d.ts +1 -1
- package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
- package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
- package/dist/components/ConcentrationInput.vue.d.ts +1 -1
- package/dist/components/DatePicker.vue.d.ts +1 -1
- package/dist/components/DateTimePicker.vue.d.ts +1 -1
- package/dist/components/FileBrowserModal.vue.d.ts +99 -0
- package/dist/components/FilePicker.vue.d.ts +32 -0
- package/dist/components/FileUploader.vue.d.ts +5 -2
- package/dist/components/FormulaInput.vue.d.ts +1 -1
- package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
- package/dist/components/MobileSupportGate.vue.d.ts +1 -1
- package/dist/components/MoleculeInput.vue.d.ts +1 -1
- package/dist/components/NumberInput.vue.d.ts +1 -1
- package/dist/components/PlotlyChart.vue.d.ts +59 -0
- package/dist/components/ResourceCard.vue.d.ts +1 -1
- package/dist/components/SecretInput.vue.d.ts +1 -1
- package/dist/components/SequenceInput.vue.d.ts +1 -1
- package/dist/components/StatusIndicator.vue.d.ts +1 -1
- package/dist/components/TagsInput.vue.d.ts +1 -1
- package/dist/components/TimePicker.vue.d.ts +1 -1
- package/dist/components/TimeRangeInput.vue.d.ts +1 -1
- package/dist/components/UnitInput.vue.d.ts +1 -1
- package/dist/components/WellPlate.vue.d.ts +1 -1
- package/dist/components/fileBrowserPresentation.d.ts +10 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +8 -8
- 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-DIum5hkx.js} +4286 -1866
- package/dist/components-DIum5hkx.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +3 -0
- package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -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 +10 -10
- package/dist/composables/useApi.d.ts +14 -7
- package/dist/composables/useFileBrowser.d.ts +55 -0
- package/dist/composables/useFilePicker.d.ts +189 -0
- package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
- package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
- package/dist/composables-mFXsTA4g.js.map +1 -0
- package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
- package/dist/errors-BLuqMee5.js.map +1 -0
- package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
- package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
- package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -15
- package/dist/install.js +2 -2
- package/dist/stores/index.js +2 -2
- package/dist/styles.css +2369 -1377
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
- package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
- package/dist/types/fileBrowserTypes.d.ts +77 -0
- package/dist/types/filePicker.d.ts +77 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
- package/dist/useControlSchema-DldpxNOP.js.map +1 -0
- package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
- package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
- package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
- package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
- package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
- package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
- package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
- package/dist/validation-uwoJGEb6.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/apiErrors.test.ts +81 -0
- 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 +287 -0
- package/src/__tests__/components/FilePicker.test.ts +487 -0
- package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
- package/src/__tests__/components/PlotlyChart.test.ts +305 -0
- package/src/__tests__/components/SegmentedControl.test.ts +19 -0
- package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
- package/src/__tests__/composables/useApi.test.ts +92 -9
- package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
- package/src/__tests__/composables/usePluginClient.test.ts +3 -0
- package/src/apiErrors.ts +85 -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.story.vue +44 -0
- package/src/components/FilePicker.vue +284 -0
- package/src/components/FileUploader.vue +32 -14
- package/src/components/GeneratedPlotlyResult.vue +34 -69
- package/src/components/GeneratedResultRenderer.vue +0 -5
- package/src/components/PlotlyChart.story.vue +61 -0
- package/src/components/PlotlyChart.vue +233 -0
- package/src/components/SegmentedControl.vue +2 -2
- package/src/components/fileBrowserPresentation.ts +39 -0
- package/src/components/index.ts +5 -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 +58 -0
- package/src/composables/filePicker/usePickerNavigation.ts +220 -0
- package/src/composables/filePicker/usePickerSelection.ts +259 -0
- package/src/composables/filePicker/validation.ts +41 -0
- package/src/composables/index.ts +7 -0
- package/src/composables/useApi.ts +57 -13
- package/src/composables/useFileBrowser.ts +314 -0
- package/src/composables/useFilePicker.ts +366 -0
- package/src/composables/useGeneratedAnalysis.ts +10 -17
- package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
- package/src/composables/usePluginClient.ts +3 -0
- package/src/index.ts +3 -0
- package/src/styles/components/file-browser-modal.css +489 -0
- package/src/styles/components/file-picker.css +502 -0
- package/src/styles/components/icon-button.css +9 -0
- package/src/styles/index.css +3 -0
- package/src/types/fileBrowserTypes.ts +85 -0
- package/src/types/filePicker.ts +72 -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/errors-_W0-LbA6.js.map +0 -1
- package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
- package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
|
@@ -0,0 +1,220 @@
|
|
|
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(
|
|
10
|
+
adapter: () => PickerAdapter,
|
|
11
|
+
request: () => PickerRequest,
|
|
12
|
+
systemFilter: (node: PickerNode) => boolean = () => true,
|
|
13
|
+
) {
|
|
14
|
+
const path = shallowRef('')
|
|
15
|
+
const trail = ref<{ path: string; label: string }[]>([])
|
|
16
|
+
const entries = shallowRef<PickerNode[]>([])
|
|
17
|
+
const nodes = ref(new Map<string, PickerNode>())
|
|
18
|
+
const children = shallowRef(new Map<string, PickerNode[]>())
|
|
19
|
+
const expanded = ref(new Set<string>())
|
|
20
|
+
const expanding = ref(new Set<string>())
|
|
21
|
+
const expansionErrors = ref(new Map<string, string>())
|
|
22
|
+
const loading = shallowRef(false)
|
|
23
|
+
const error = shallowRef('')
|
|
24
|
+
let epoch = 0
|
|
25
|
+
let navigation = 0
|
|
26
|
+
let controller: AbortController | undefined
|
|
27
|
+
const pending = new Map<string, Promise<PickerNode[]>>()
|
|
28
|
+
const childControllers = new Set<AbortController>()
|
|
29
|
+
|
|
30
|
+
function isAllowed(node: PickerNode): boolean {
|
|
31
|
+
const seen = new Set<string>()
|
|
32
|
+
let current: PickerNode | undefined = node
|
|
33
|
+
while (current && !seen.has(current.path)) {
|
|
34
|
+
if (!systemFilter(current)) return false
|
|
35
|
+
seen.add(current.path)
|
|
36
|
+
current = current.parentPath ? nodes.value.get(current.parentPath) : undefined
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function register(items: PickerNode[], parent?: string) {
|
|
42
|
+
for (const item of items)
|
|
43
|
+
nodes.value.set(item.path, {
|
|
44
|
+
...item,
|
|
45
|
+
parentPath:
|
|
46
|
+
item.parentPath !== undefined
|
|
47
|
+
? item.parentPath
|
|
48
|
+
: parent !== undefined
|
|
49
|
+
? parent || null
|
|
50
|
+
: (nodes.value.get(item.path)?.parentPath ?? null),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
function invalidate() {
|
|
54
|
+
++epoch
|
|
55
|
+
++navigation
|
|
56
|
+
controller?.abort()
|
|
57
|
+
childControllers.forEach((item) => item.abort())
|
|
58
|
+
childControllers.clear()
|
|
59
|
+
pending.clear()
|
|
60
|
+
children.value = new Map()
|
|
61
|
+
expanding.value.clear()
|
|
62
|
+
expansionErrors.value.clear()
|
|
63
|
+
}
|
|
64
|
+
function clear() {
|
|
65
|
+
invalidate()
|
|
66
|
+
nodes.value.clear()
|
|
67
|
+
expanded.value.clear()
|
|
68
|
+
expanding.value.clear()
|
|
69
|
+
expansionErrors.value.clear()
|
|
70
|
+
entries.value = []
|
|
71
|
+
loading.value = false
|
|
72
|
+
error.value = ''
|
|
73
|
+
}
|
|
74
|
+
async function loadChildren(
|
|
75
|
+
folder: string,
|
|
76
|
+
ancestors: string[] = [],
|
|
77
|
+
refresh = false,
|
|
78
|
+
): Promise<PickerNode[]> {
|
|
79
|
+
if (ancestors.includes(folder)) throw new Error('The adapter returned a circular folder tree.')
|
|
80
|
+
const cached = children.value.get(folder)
|
|
81
|
+
if (cached) return cached
|
|
82
|
+
const existing = pending.get(folder)
|
|
83
|
+
if (existing) return existing
|
|
84
|
+
const token = epoch
|
|
85
|
+
const abort = new AbortController()
|
|
86
|
+
childControllers.add(abort)
|
|
87
|
+
const task = adapter()
|
|
88
|
+
.listChildren(folder, { ...request(), ...(refresh && { refresh }), signal: abort.signal })
|
|
89
|
+
.then((items) => {
|
|
90
|
+
if (token !== epoch) throw new DOMException('Cancelled', 'AbortError')
|
|
91
|
+
const retained = new Set(items.map((item) => item.path))
|
|
92
|
+
function remove(path: string, seen = new Set<string>()) {
|
|
93
|
+
if (seen.has(path) || path === folder) return
|
|
94
|
+
seen.add(path)
|
|
95
|
+
for (const node of nodes.value.values())
|
|
96
|
+
if (node.parentPath === path) remove(node.path, seen)
|
|
97
|
+
nodes.value.delete(path)
|
|
98
|
+
children.value.delete(path)
|
|
99
|
+
}
|
|
100
|
+
for (const node of nodes.value.values())
|
|
101
|
+
if (node.parentPath === folder && !retained.has(node.path)) remove(node.path)
|
|
102
|
+
register(items, folder)
|
|
103
|
+
children.value = new Map(children.value).set(folder, items)
|
|
104
|
+
return items
|
|
105
|
+
})
|
|
106
|
+
.finally(() => {
|
|
107
|
+
childControllers.delete(abort)
|
|
108
|
+
if (token === epoch) pending.delete(folder)
|
|
109
|
+
})
|
|
110
|
+
pending.set(folder, task)
|
|
111
|
+
return task
|
|
112
|
+
}
|
|
113
|
+
async function load(query = '', recursive = false, refresh = false) {
|
|
114
|
+
const token = ++navigation
|
|
115
|
+
controller?.abort()
|
|
116
|
+
controller = new AbortController()
|
|
117
|
+
const options = { ...request(), ...(refresh && { refresh }), signal: controller.signal }
|
|
118
|
+
loading.value = true
|
|
119
|
+
error.value = ''
|
|
120
|
+
entries.value = []
|
|
121
|
+
try {
|
|
122
|
+
const items = query.trim()
|
|
123
|
+
? await adapter().search(query.trim(), { path: path.value, recursive }, options)
|
|
124
|
+
: path.value
|
|
125
|
+
? await loadChildren(path.value, [], refresh)
|
|
126
|
+
: await adapter().listRoot(options)
|
|
127
|
+
if (token !== navigation) return
|
|
128
|
+
register(items, query.trim() ? undefined : path.value)
|
|
129
|
+
entries.value = items
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
if (token === navigation)
|
|
132
|
+
error.value = cause instanceof Error ? cause.message : 'Could not read this location.'
|
|
133
|
+
} finally {
|
|
134
|
+
if (token === navigation) loading.value = false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function expand(node: PickerNode) {
|
|
138
|
+
if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
|
|
139
|
+
if (expanded.value.has(node.path)) {
|
|
140
|
+
expanded.value.delete(node.path)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
expanded.value.add(node.path)
|
|
144
|
+
expanding.value.add(node.path)
|
|
145
|
+
expansionErrors.value.delete(node.path)
|
|
146
|
+
const token = epoch
|
|
147
|
+
try {
|
|
148
|
+
await loadChildren(node.path)
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
if (token === epoch)
|
|
151
|
+
expansionErrors.value.set(
|
|
152
|
+
node.path,
|
|
153
|
+
cause instanceof Error ? cause.message : 'Could not read this folder.',
|
|
154
|
+
)
|
|
155
|
+
} finally {
|
|
156
|
+
if (token === epoch) expanding.value.delete(node.path)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function enter(node: PickerNode) {
|
|
160
|
+
if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
|
|
161
|
+
const index = trail.value.findIndex((crumb) => crumb.path === node.path)
|
|
162
|
+
trail.value =
|
|
163
|
+
index >= 0
|
|
164
|
+
? trail.value.slice(0, index + 1)
|
|
165
|
+
: [...trail.value, { path: node.path, label: node.name }]
|
|
166
|
+
path.value = node.path
|
|
167
|
+
}
|
|
168
|
+
function up(target?: string) {
|
|
169
|
+
const index =
|
|
170
|
+
target !== undefined
|
|
171
|
+
? trail.value.findIndex((crumb) => crumb.path === target)
|
|
172
|
+
: trail.value.length - 2
|
|
173
|
+
trail.value = index < 0 ? [] : trail.value.slice(0, index + 1)
|
|
174
|
+
path.value = trail.value[trail.value.length - 1]?.path ?? ''
|
|
175
|
+
}
|
|
176
|
+
const rows = computed(() => {
|
|
177
|
+
const result: PickerTreeRow[] = []
|
|
178
|
+
function visit(items: PickerNode[], depth: number, ancestors: Set<string>) {
|
|
179
|
+
for (const item of items) {
|
|
180
|
+
if (ancestors.has(item.path) || depth > 30) continue
|
|
181
|
+
const node = nodes.value.get(item.path) ?? item
|
|
182
|
+
if (!isAllowed(node)) continue
|
|
183
|
+
result.push({
|
|
184
|
+
node,
|
|
185
|
+
depth,
|
|
186
|
+
expanded: expanded.value.has(item.path),
|
|
187
|
+
loading: expanding.value.has(item.path),
|
|
188
|
+
error: expansionErrors.value.get(item.path),
|
|
189
|
+
})
|
|
190
|
+
if (expanded.value.has(item.path))
|
|
191
|
+
visit(children.value.get(item.path) ?? [], depth + 1, new Set([...ancestors, item.path]))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
visit(entries.value, 0, new Set())
|
|
195
|
+
return result
|
|
196
|
+
})
|
|
197
|
+
onScopeDispose(clear)
|
|
198
|
+
return {
|
|
199
|
+
path,
|
|
200
|
+
trail,
|
|
201
|
+
entries,
|
|
202
|
+
nodes,
|
|
203
|
+
children,
|
|
204
|
+
expanded,
|
|
205
|
+
expanding,
|
|
206
|
+
expansionErrors,
|
|
207
|
+
loading,
|
|
208
|
+
error,
|
|
209
|
+
rows,
|
|
210
|
+
isAllowed,
|
|
211
|
+
clear,
|
|
212
|
+
invalidate,
|
|
213
|
+
register,
|
|
214
|
+
loadChildren,
|
|
215
|
+
load,
|
|
216
|
+
expand,
|
|
217
|
+
enter,
|
|
218
|
+
up,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { computed, ref, shallowRef, watch } 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
|
+
if (!nav.isAllowed(node))
|
|
40
|
+
return { path: node.path, name: node.name, reason: 'forbidden' as const }
|
|
41
|
+
const own = pickerExclusion(node, capability(), skipBlanks())
|
|
42
|
+
if (own) return own
|
|
43
|
+
const seen = new Set<string>()
|
|
44
|
+
let parent = node.parentPath
|
|
45
|
+
while (parent && !seen.has(parent)) {
|
|
46
|
+
seen.add(parent)
|
|
47
|
+
const folder = nav.nodes.value.get(parent)
|
|
48
|
+
if (!folder) break
|
|
49
|
+
const inherited = pickerExclusion(folder, capability(), skipBlanks())
|
|
50
|
+
if (inherited) return { ...inherited, path: node.path, name: node.name }
|
|
51
|
+
parent = folder.parentPath
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
function selected(node: PickerNode): boolean {
|
|
56
|
+
return (
|
|
57
|
+
!exclusion(node) &&
|
|
58
|
+
!dropped.value.has(node.path) &&
|
|
59
|
+
!ancestorSelected(node, dropped.value) &&
|
|
60
|
+
(keys.value.has(node.path) || (mode() === 'folder+files' && ancestorSelected(node)))
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
function selectable(node: PickerNode): boolean {
|
|
64
|
+
if (exclusion(node)) return false
|
|
65
|
+
return node.kind === 'folder'
|
|
66
|
+
? mode() === 'folder' || mode() === 'folder+files'
|
|
67
|
+
: mode() !== 'folder'
|
|
68
|
+
}
|
|
69
|
+
function select(
|
|
70
|
+
node: PickerNode,
|
|
71
|
+
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; toggle?: boolean } = {},
|
|
72
|
+
) {
|
|
73
|
+
if (exclusion(node)) return
|
|
74
|
+
focusPath.value = node.path
|
|
75
|
+
if (!selectable(node)) return
|
|
76
|
+
const multi = mode() === 'multi-file' || mode() === 'folder+files'
|
|
77
|
+
// Multi modes behave like a checklist: a plain click (or Space) adds or
|
|
78
|
+
// removes one row, Shift extends a range, Ctrl/Cmd is accepted for habit.
|
|
79
|
+
const additive = multi && (modifiers.metaKey || modifiers.ctrlKey || modifiers.toggle)
|
|
80
|
+
if (multi && modifiers.shiftKey && anchor) {
|
|
81
|
+
const rows = nav.rows.value.map((row) => row.node)
|
|
82
|
+
const from = rows.findIndex((item) => item.path === anchor)
|
|
83
|
+
const to = rows.findIndex((item) => item.path === node.path)
|
|
84
|
+
if (from >= 0 && to >= 0) {
|
|
85
|
+
keys.value = new Set([
|
|
86
|
+
...(additive ? keys.value : []),
|
|
87
|
+
...rows
|
|
88
|
+
.slice(Math.min(from, to), Math.max(from, to) + 1)
|
|
89
|
+
.filter(selectable)
|
|
90
|
+
.map((item) => item.path),
|
|
91
|
+
])
|
|
92
|
+
dropped.value.clear()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (additive || (modifiers.toggle && (selected(node) || dropped.value.has(node.path)))) {
|
|
97
|
+
if (mode() === 'folder+files' && ancestorSelected(node)) {
|
|
98
|
+
if (dropped.value.has(node.path)) dropped.value.delete(node.path)
|
|
99
|
+
else dropped.value.add(node.path)
|
|
100
|
+
} else if (keys.value.has(node.path)) keys.value.delete(node.path)
|
|
101
|
+
else {
|
|
102
|
+
keys.value.add(node.path)
|
|
103
|
+
dropped.value.delete(node.path)
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
keys.value = new Set([node.path])
|
|
107
|
+
dropped.value.clear()
|
|
108
|
+
}
|
|
109
|
+
anchor = node.path
|
|
110
|
+
}
|
|
111
|
+
function reset(selection?: PickerSelection | null) {
|
|
112
|
+
++generation
|
|
113
|
+
keys.value.clear()
|
|
114
|
+
dropped.value.clear()
|
|
115
|
+
resolving.value = false
|
|
116
|
+
resolutionError.value = ''
|
|
117
|
+
focusPath.value = ''
|
|
118
|
+
if (selection?.source === 'server') {
|
|
119
|
+
const files =
|
|
120
|
+
mode() === 'folder'
|
|
121
|
+
? []
|
|
122
|
+
: mode() === 'single-file'
|
|
123
|
+
? selection.files.slice(0, 1)
|
|
124
|
+
: selection.files
|
|
125
|
+
const folders =
|
|
126
|
+
mode() === 'folder'
|
|
127
|
+
? selection.folders.slice(0, 1)
|
|
128
|
+
: mode() === 'folder+files'
|
|
129
|
+
? selection.folders
|
|
130
|
+
: []
|
|
131
|
+
nav.register(files)
|
|
132
|
+
folders.forEach((path) => {
|
|
133
|
+
if (!nav.nodes.value.has(path))
|
|
134
|
+
nav.register([
|
|
135
|
+
{ kind: 'folder', path, name: 'Selected folder', parentName: 'Selected location' },
|
|
136
|
+
])
|
|
137
|
+
keys.value.add(path)
|
|
138
|
+
})
|
|
139
|
+
files.forEach((file) => keys.value.add(file.path))
|
|
140
|
+
selection.excluded
|
|
141
|
+
.filter((item) => item.reason === 'deselected')
|
|
142
|
+
.forEach((item) => dropped.value.add(item.path))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const roots = computed(() =>
|
|
146
|
+
[...keys.value]
|
|
147
|
+
.map((key) => nav.nodes.value.get(key))
|
|
148
|
+
.filter((node): node is PickerNode => !!node && !ancestorSelected(node) && nav.isAllowed(node)),
|
|
149
|
+
)
|
|
150
|
+
const chosen = computed(() => {
|
|
151
|
+
const files: PickerFile[] = []
|
|
152
|
+
const excluded: PickerExcluded[] = []
|
|
153
|
+
for (const node of nav.nodes.value.values()) {
|
|
154
|
+
if (!nav.isAllowed(node)) continue
|
|
155
|
+
if (!keys.value.has(node.path) && !ancestorSelected(node)) continue
|
|
156
|
+
const reason =
|
|
157
|
+
exclusion(node) ??
|
|
158
|
+
(dropped.value.has(node.path)
|
|
159
|
+
? { path: node.path, name: node.name, reason: 'deselected' as const }
|
|
160
|
+
: null)
|
|
161
|
+
// A dropped folder excludes its descendants as well.
|
|
162
|
+
const droppedByParent = ancestorSelected(node, dropped.value)
|
|
163
|
+
if (reason || droppedByParent) {
|
|
164
|
+
if (node.kind === 'file' || reason?.reason === 'unreadable')
|
|
165
|
+
excluded.push(reason ?? { path: node.path, name: node.name, reason: 'deselected' })
|
|
166
|
+
} else if (node.kind === 'file') files.push(node)
|
|
167
|
+
}
|
|
168
|
+
return { files, excluded, bytes: files.reduce((total, node) => total + (node.bytes ?? 0), 0) }
|
|
169
|
+
})
|
|
170
|
+
async function resolve() {
|
|
171
|
+
const token = ++generation
|
|
172
|
+
resolving.value = true
|
|
173
|
+
resolutionError.value = ''
|
|
174
|
+
let count = 0
|
|
175
|
+
const seen = new Set<string>()
|
|
176
|
+
async function walk(node: PickerNode, ancestors: string[] = []): Promise<PickerFile[]> {
|
|
177
|
+
if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
|
|
178
|
+
if (!nav.isAllowed(node)) return []
|
|
179
|
+
if (node.kind === 'file') return [node]
|
|
180
|
+
if (node.unavailableReason) return []
|
|
181
|
+
if (ancestors.includes(node.path))
|
|
182
|
+
throw new Error('The adapter returned a circular folder tree.')
|
|
183
|
+
if (seen.has(node.path)) return []
|
|
184
|
+
seen.add(node.path)
|
|
185
|
+
if (++count > 500 || ancestors.length > 30)
|
|
186
|
+
throw new Error('This selection is too large to validate. Choose a smaller folder.')
|
|
187
|
+
const children = await nav.loadChildren(node.path, ancestors)
|
|
188
|
+
const files: PickerFile[] = []
|
|
189
|
+
for (const child of children) files.push(...(await walk(child, [...ancestors, node.path])))
|
|
190
|
+
if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
|
|
191
|
+
const formats: Record<string, number> = {}
|
|
192
|
+
files.forEach((file) => {
|
|
193
|
+
formats[file.format] = (formats[file.format] ?? 0) + 1
|
|
194
|
+
})
|
|
195
|
+
nav.register([
|
|
196
|
+
{
|
|
197
|
+
...node,
|
|
198
|
+
fileCount: files.length,
|
|
199
|
+
bytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0),
|
|
200
|
+
formats,
|
|
201
|
+
},
|
|
202
|
+
])
|
|
203
|
+
return files
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
for (const node of roots.value) await walk(node)
|
|
207
|
+
if (token === generation) {
|
|
208
|
+
// Resolved descendants inherit their folder, including restored selections.
|
|
209
|
+
for (const key of [...keys.value]) {
|
|
210
|
+
const node = nav.nodes.value.get(key)
|
|
211
|
+
if (node && ancestorSelected(node)) keys.value.delete(key)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (cause) {
|
|
215
|
+
if (token === generation)
|
|
216
|
+
resolutionError.value =
|
|
217
|
+
cause instanceof Error ? cause.message : 'Could not validate the selection.'
|
|
218
|
+
} finally {
|
|
219
|
+
if (token === generation) resolving.value = false
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const focused = computed(() => {
|
|
223
|
+
const node = nav.nodes.value.get(focusPath.value)
|
|
224
|
+
return node && nav.isAllowed(node) ? node : roots.value[roots.value.length - 1] ?? null
|
|
225
|
+
})
|
|
226
|
+
watch(
|
|
227
|
+
() => new Map([...nav.nodes.value].map(([path, node]) => [path, nav.isAllowed(node)])),
|
|
228
|
+
(allowed, previous) => {
|
|
229
|
+
if (keys.value.size && [...allowed].some(([path, value]) =>
|
|
230
|
+
previous.has(path) && value !== previous.get(path),
|
|
231
|
+
)) void resolve()
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
function folderProgress(node: PickerNode): string {
|
|
235
|
+
if (node.kind !== 'folder' || node.fileCount == null) return ''
|
|
236
|
+
const allowed = chosen.value.files.filter((file) =>
|
|
237
|
+
ancestorSelected(file, new Set([node.path])),
|
|
238
|
+
).length
|
|
239
|
+
return `${selected(node) ? allowed : 0} / ${node.fileCount}`
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
keys,
|
|
243
|
+
dropped,
|
|
244
|
+
focusPath,
|
|
245
|
+
resolving,
|
|
246
|
+
resolutionError,
|
|
247
|
+
roots,
|
|
248
|
+
chosen,
|
|
249
|
+
focused,
|
|
250
|
+
select,
|
|
251
|
+
selected,
|
|
252
|
+
selectable,
|
|
253
|
+
exclusion,
|
|
254
|
+
reset,
|
|
255
|
+
resolve,
|
|
256
|
+
folderProgress,
|
|
257
|
+
ancestorSelected,
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
forbidden: 'This item is not permitted by the system filter.',
|
|
35
|
+
}[excluded.reason]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function pickerFormat(name: string): string {
|
|
39
|
+
const compound = /\.(mzml|mzxml)\.gz$/i.exec(name)
|
|
40
|
+
return compound ? `${compound[1]}.gz` : name.includes('.') ? name.split('.').pop()! : ''
|
|
41
|
+
}
|
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'
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|
2
2
|
import { useSettingsStore } from '../stores/settings'
|
|
3
3
|
import { useAuthStore } from '../stores/auth'
|
|
4
|
+
import { MintApiError, mintApiErrorFromResponse } from '../apiErrors'
|
|
4
5
|
|
|
5
6
|
let apiClientInstance: AxiosInstance | null = null
|
|
6
7
|
let interceptorAttached = false
|
|
@@ -8,11 +9,13 @@ let interceptorAttached = false
|
|
|
8
9
|
interface MintAxiosRequestConfig extends AxiosRequestConfig {
|
|
9
10
|
_mintSkipAuth?: boolean
|
|
10
11
|
_mintAuthToken?: string
|
|
12
|
+
_mintTypedErrors?: boolean
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
interface MintInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
|
|
14
16
|
_mintSkipAuth?: boolean
|
|
15
17
|
_mintAuthToken?: string
|
|
18
|
+
_mintTypedErrors?: boolean
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
type MutableHeaders = Record<string, unknown> & {
|
|
@@ -103,14 +106,27 @@ function getAuthorizationHeader(headers: AxiosRequestConfig['headers']): unknown
|
|
|
103
106
|
return key ? bag[key] : undefined
|
|
104
107
|
}
|
|
105
108
|
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Raised when an authenticated SDK request is rejected because the login is no
|
|
111
|
+
* longer valid. It is also a `MintApiError` (status 401), so typed-error callers
|
|
112
|
+
* and `instanceof AuthenticationRequiredError` callers both recognise it.
|
|
113
|
+
*/
|
|
114
|
+
export class AuthenticationRequiredError extends MintApiError {
|
|
115
|
+
override readonly name: string = 'AuthenticationRequiredError'
|
|
116
|
+
declare readonly code: 'AUTHENTICATION_REQUIRED'
|
|
117
|
+
declare readonly status: 401
|
|
118
|
+
|
|
119
|
+
constructor(cause?: unknown, envelope?: MintApiError) {
|
|
120
|
+
super(
|
|
121
|
+
'Authentication required. Please sign in again.',
|
|
122
|
+
'AUTHENTICATION_REQUIRED',
|
|
123
|
+
401,
|
|
124
|
+
envelope?.requestId ?? null,
|
|
125
|
+
envelope?.details ?? null,
|
|
126
|
+
envelope?.detail,
|
|
127
|
+
envelope?.body ?? null,
|
|
128
|
+
cause,
|
|
129
|
+
)
|
|
114
130
|
}
|
|
115
131
|
}
|
|
116
132
|
|
|
@@ -118,12 +134,13 @@ export class AuthenticationRequiredError extends Error {
|
|
|
118
134
|
export function authenticationRequiredError(
|
|
119
135
|
cause: unknown,
|
|
120
136
|
rejectedToken?: string,
|
|
137
|
+
envelope?: MintApiError,
|
|
121
138
|
): AuthenticationRequiredError {
|
|
122
139
|
const currentAuthStore = useAuthStore()
|
|
123
140
|
if (rejectedToken && currentAuthStore.token === rejectedToken) {
|
|
124
141
|
currentAuthStore.clearToken()
|
|
125
142
|
}
|
|
126
|
-
return new AuthenticationRequiredError(cause)
|
|
143
|
+
return new AuthenticationRequiredError(cause, envelope)
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
function getApiClient(): AxiosInstance {
|
|
@@ -141,6 +158,8 @@ export interface ApiClientOptions {
|
|
|
141
158
|
baseUrl?: string
|
|
142
159
|
timeout?: number
|
|
143
160
|
withAuth?: boolean
|
|
161
|
+
/** Normalize failed responses into MintApiError. Generated clients enable this by default. */
|
|
162
|
+
typedErrors?: boolean
|
|
144
163
|
}
|
|
145
164
|
|
|
146
165
|
export interface UseApiReturn {
|
|
@@ -187,11 +206,35 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
187
206
|
})
|
|
188
207
|
apiClient.interceptors.response.use(
|
|
189
208
|
(response) => response,
|
|
190
|
-
(error: unknown) => {
|
|
191
|
-
if (axios.isAxiosError(error) && error.response
|
|
209
|
+
async (error: unknown) => {
|
|
210
|
+
if (axios.isAxiosError(error) && error.response) {
|
|
192
211
|
const request = error.config as MintInternalAxiosRequestConfig | undefined
|
|
193
|
-
if (request?.
|
|
194
|
-
|
|
212
|
+
if (request?._mintTypedErrors) {
|
|
213
|
+
let payload: unknown = error.response.data
|
|
214
|
+
if (payload instanceof Blob) {
|
|
215
|
+
try {
|
|
216
|
+
payload = JSON.parse(await payload.text())
|
|
217
|
+
} catch {
|
|
218
|
+
// Keep non-JSON download errors available as the original body.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const typed = mintApiErrorFromResponse(
|
|
222
|
+
error.response.status,
|
|
223
|
+
payload,
|
|
224
|
+
{
|
|
225
|
+
requestId: String(error.response.headers?.['x-request-id'] ?? '') || null,
|
|
226
|
+
cause: error,
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
if (error.response.status === 401 && request._mintSkipAuth !== true) {
|
|
230
|
+
return Promise.reject(authenticationRequiredError(error, request._mintAuthToken, typed))
|
|
231
|
+
}
|
|
232
|
+
return Promise.reject(typed)
|
|
233
|
+
}
|
|
234
|
+
if (error.response.status === 401) {
|
|
235
|
+
if (request?._mintSkipAuth !== true) {
|
|
236
|
+
return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
|
|
237
|
+
}
|
|
195
238
|
}
|
|
196
239
|
}
|
|
197
240
|
return Promise.reject(error)
|
|
@@ -213,6 +256,7 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
213
256
|
const base: MintAxiosRequestConfig = {
|
|
214
257
|
baseURL: getBaseUrl(),
|
|
215
258
|
timeout: options.timeout ?? settingsStore.requestTimeout,
|
|
259
|
+
_mintTypedErrors: options.typedErrors,
|
|
216
260
|
...config,
|
|
217
261
|
}
|
|
218
262
|
// Strip auth header if explicitly disabled
|