@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.
Files changed (152) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-CJdNlCZb.js} +21 -14
  3. package/dist/ExperimentPopover-CJdNlCZb.js.map +1 -0
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-DCXmwKNS.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-DCXmwKNS.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-BYR20I4c.js} +5 -5
  7. package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-BYR20I4c.js.map} +1 -1
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-BAF3CKY7.js} +3 -3
  9. package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-BAF3CKY7.js.map} +1 -1
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  12. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  13. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  14. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  15. package/dist/__tests__/components/iconButtonIconSizing.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  17. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  18. package/dist/apiErrors.d.ts +26 -0
  19. package/dist/components/BaseInput.vue.d.ts +1 -1
  20. package/dist/components/BaseSelect.vue.d.ts +1 -1
  21. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  22. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  23. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  24. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  25. package/dist/components/DatePicker.vue.d.ts +1 -1
  26. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  27. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  28. package/dist/components/FilePicker.vue.d.ts +32 -0
  29. package/dist/components/FileUploader.vue.d.ts +5 -2
  30. package/dist/components/FormulaInput.vue.d.ts +1 -1
  31. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  32. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  33. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  34. package/dist/components/NumberInput.vue.d.ts +1 -1
  35. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  36. package/dist/components/ResourceCard.vue.d.ts +1 -1
  37. package/dist/components/SecretInput.vue.d.ts +1 -1
  38. package/dist/components/SequenceInput.vue.d.ts +1 -1
  39. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  40. package/dist/components/TagsInput.vue.d.ts +1 -1
  41. package/dist/components/TimePicker.vue.d.ts +1 -1
  42. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  43. package/dist/components/UnitInput.vue.d.ts +1 -1
  44. package/dist/components/WellPlate.vue.d.ts +1 -1
  45. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  46. package/dist/components/index.d.ts +4 -0
  47. package/dist/components/index.js +8 -8
  48. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  49. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  50. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  51. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  52. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  53. package/dist/{components-CgAUzF8F.js → components-DIum5hkx.js} +4286 -1866
  54. package/dist/components-DIum5hkx.js.map +1 -0
  55. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  56. package/dist/composables/filePicker/usePickerNavigation.d.ts +96 -0
  57. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  58. package/dist/composables/filePicker/validation.d.ts +5 -0
  59. package/dist/composables/index.d.ts +2 -0
  60. package/dist/composables/index.js +10 -10
  61. package/dist/composables/useApi.d.ts +14 -7
  62. package/dist/composables/useFileBrowser.d.ts +55 -0
  63. package/dist/composables/useFilePicker.d.ts +189 -0
  64. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  65. package/dist/{composables-B_pia3hO.js → composables-mFXsTA4g.js} +300 -11
  66. package/dist/composables-mFXsTA4g.js.map +1 -0
  67. package/dist/{errors-_W0-LbA6.js → errors-BLuqMee5.js} +78 -15
  68. package/dist/errors-BLuqMee5.js.map +1 -0
  69. package/dist/{experiment-Cwl9AYxw.js → experiment-CVDRAnqp.js} +2 -2
  70. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CVDRAnqp.js.map} +1 -1
  71. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  72. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  73. package/dist/index.d.ts +1 -0
  74. package/dist/index.js +15 -15
  75. package/dist/install.js +2 -2
  76. package/dist/stores/index.js +2 -2
  77. package/dist/styles.css +2369 -1377
  78. package/dist/templates/index.js +2 -2
  79. package/dist/{templates-Qq113pyZ.js → templates-CmGfmBRA.js} +2 -2
  80. package/dist/{templates-Qq113pyZ.js.map → templates-CmGfmBRA.js.map} +1 -1
  81. package/dist/types/fileBrowserTypes.d.ts +77 -0
  82. package/dist/types/filePicker.d.ts +77 -0
  83. package/dist/types/index.d.ts +2 -0
  84. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-DldpxNOP.js} +42 -37
  85. package/dist/useControlSchema-DldpxNOP.js.map +1 -0
  86. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  87. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  88. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-CEcIaueJ.js} +4 -4
  89. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-CEcIaueJ.js.map} +1 -1
  90. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-z5L_dUIy.js} +2 -2
  91. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-z5L_dUIy.js.map} +1 -1
  92. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-uwoJGEb6.js} +53 -8
  93. package/dist/validation-uwoJGEb6.js.map +1 -0
  94. package/package.json +1 -1
  95. package/src/__tests__/apiErrors.test.ts +81 -0
  96. package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
  97. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  98. package/src/__tests__/components/FilePicker.state.test.ts +287 -0
  99. package/src/__tests__/components/FilePicker.test.ts +487 -0
  100. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  101. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  102. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  103. package/src/__tests__/components/iconButtonIconSizing.test.ts +18 -0
  104. package/src/__tests__/composables/useApi.test.ts +92 -9
  105. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  106. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  107. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  108. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  109. package/src/apiErrors.ts +85 -0
  110. package/src/components/ExperimentPopover.vue +10 -2
  111. package/src/components/FileBrowserModal.story.vue +203 -0
  112. package/src/components/FileBrowserModal.vue +384 -0
  113. package/src/components/FilePicker.story.vue +44 -0
  114. package/src/components/FilePicker.vue +284 -0
  115. package/src/components/FileUploader.vue +32 -14
  116. package/src/components/GeneratedPlotlyResult.vue +34 -69
  117. package/src/components/GeneratedResultRenderer.vue +0 -5
  118. package/src/components/PlotlyChart.story.vue +61 -0
  119. package/src/components/PlotlyChart.vue +233 -0
  120. package/src/components/SegmentedControl.vue +2 -2
  121. package/src/components/fileBrowserPresentation.ts +39 -0
  122. package/src/components/index.ts +5 -0
  123. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  124. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  125. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  126. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  127. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  128. package/src/composables/filePicker/localFiles.ts +58 -0
  129. package/src/composables/filePicker/usePickerNavigation.ts +220 -0
  130. package/src/composables/filePicker/usePickerSelection.ts +259 -0
  131. package/src/composables/filePicker/validation.ts +41 -0
  132. package/src/composables/index.ts +7 -0
  133. package/src/composables/useApi.ts +57 -13
  134. package/src/composables/useFileBrowser.ts +314 -0
  135. package/src/composables/useFilePicker.ts +366 -0
  136. package/src/composables/useGeneratedAnalysis.ts +10 -17
  137. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  138. package/src/composables/usePluginClient.ts +3 -0
  139. package/src/index.ts +3 -0
  140. package/src/styles/components/file-browser-modal.css +489 -0
  141. package/src/styles/components/file-picker.css +502 -0
  142. package/src/styles/components/icon-button.css +9 -0
  143. package/src/styles/index.css +3 -0
  144. package/src/types/fileBrowserTypes.ts +85 -0
  145. package/src/types/filePicker.ts +72 -0
  146. package/src/types/index.ts +14 -0
  147. package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
  148. package/dist/components-CgAUzF8F.js.map +0 -1
  149. package/dist/composables-B_pia3hO.js.map +0 -1
  150. package/dist/errors-_W0-LbA6.js.map +0 -1
  151. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
  152. 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
+ }
@@ -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
- /** Raised when an authenticated SDK request is rejected because the login is no longer valid. */
107
- export class AuthenticationRequiredError extends Error {
108
- readonly code = 'AUTHENTICATION_REQUIRED'
109
- readonly status = 401
110
-
111
- constructor(readonly cause?: unknown) {
112
- super('Authentication required. Please sign in again.')
113
- this.name = 'AuthenticationRequiredError'
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?.status === 401) {
209
+ async (error: unknown) => {
210
+ if (axios.isAxiosError(error) && error.response) {
192
211
  const request = error.config as MintInternalAxiosRequestConfig | undefined
193
- if (request?._mintSkipAuth !== true) {
194
- return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
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