@morscherlab/mint-sdk 1.2.0-beta.15 → 1.2.0-beta.17

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 (108) hide show
  1. package/README.md +30 -0
  2. package/dist/{ExperimentPopover-BIZKoRTT.js → ExperimentPopover-CJdNlCZb.js} +3 -3
  3. package/dist/{ExperimentPopover-BIZKoRTT.js.map → ExperimentPopover-CJdNlCZb.js.map} +1 -1
  4. package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-CZ1f_K9I.js} +5 -5
  5. package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-CZ1f_K9I.js.map} +1 -1
  6. package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DQUcpT4T.js} +5 -5
  7. package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-DQUcpT4T.js.map} +1 -1
  8. package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-JaDtC8-9.js} +3 -3
  9. package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-JaDtC8-9.js.map} +1 -1
  10. package/dist/__tests__/apiErrors.test.d.ts +1 -0
  11. package/dist/__tests__/components/PlotlyChart.test.d.ts +1 -0
  12. package/dist/apiErrors.d.ts +26 -0
  13. package/dist/components/BaseInput.vue.d.ts +1 -1
  14. package/dist/components/BaseSelect.vue.d.ts +1 -1
  15. package/dist/components/BaseTextarea.vue.d.ts +1 -1
  16. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +2 -2
  17. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +2 -2
  18. package/dist/components/ConcentrationInput.vue.d.ts +1 -1
  19. package/dist/components/DatePicker.vue.d.ts +1 -1
  20. package/dist/components/DateTimePicker.vue.d.ts +1 -1
  21. package/dist/components/FileBrowserModal.vue.d.ts +1 -1
  22. package/dist/components/FilePicker.vue.d.ts +3 -3
  23. package/dist/components/FileUploader.vue.d.ts +5 -2
  24. package/dist/components/FormulaInput.vue.d.ts +1 -1
  25. package/dist/components/GeneratedPlotlyResult.vue.d.ts +1 -3
  26. package/dist/components/MobileSupportGate.vue.d.ts +1 -1
  27. package/dist/components/MoleculeInput.vue.d.ts +1 -1
  28. package/dist/components/NumberInput.vue.d.ts +1 -1
  29. package/dist/components/PlotlyChart.vue.d.ts +59 -0
  30. package/dist/components/ResourceCard.vue.d.ts +1 -1
  31. package/dist/components/SecretInput.vue.d.ts +1 -1
  32. package/dist/components/SequenceInput.vue.d.ts +1 -1
  33. package/dist/components/StatusIndicator.vue.d.ts +1 -1
  34. package/dist/components/TagsInput.vue.d.ts +1 -1
  35. package/dist/components/TimePicker.vue.d.ts +1 -1
  36. package/dist/components/TimeRangeInput.vue.d.ts +1 -1
  37. package/dist/components/UnitInput.vue.d.ts +1 -1
  38. package/dist/components/WellPlate.vue.d.ts +1 -1
  39. package/dist/components/index.d.ts +1 -0
  40. package/dist/components/index.js +8 -8
  41. package/dist/{components-6IIxCSI8.js → components-DT7ywo_B.js} +581 -367
  42. package/dist/components-DT7ywo_B.js.map +1 -0
  43. package/dist/composables/filePicker/localFiles.d.ts +1 -1
  44. package/dist/composables/filePicker/usePickerNavigation.d.ts +4 -3
  45. package/dist/composables/index.js +9 -9
  46. package/dist/composables/useApi.d.ts +2 -0
  47. package/dist/composables/useFilePicker.d.ts +5 -6
  48. package/dist/{composables-hY2Q2xPI.js → composables-DmF_8iyX.js} +13 -10
  49. package/dist/{composables-hY2Q2xPI.js.map → composables-DmF_8iyX.js.map} +1 -1
  50. package/dist/{errors-_W0-LbA6.js → errors-DZDb1cIb.js} +66 -5
  51. package/dist/errors-DZDb1cIb.js.map +1 -0
  52. package/dist/{experiment-Cwl9AYxw.js → experiment-CHm_TwJy.js} +2 -2
  53. package/dist/{experiment-Cwl9AYxw.js.map → experiment-CHm_TwJy.js.map} +1 -1
  54. package/dist/{experiment-utils-DV3DrvE_.js → experiment-utils-CV2Wza3z.js} +1 -1
  55. package/dist/{experiment-utils-DV3DrvE_.js.map → experiment-utils-CV2Wza3z.js.map} +1 -1
  56. package/dist/index.d.ts +1 -0
  57. package/dist/index.js +15 -15
  58. package/dist/install.js +2 -2
  59. package/dist/stores/index.js +2 -2
  60. package/dist/styles.css +29 -18
  61. package/dist/templates/index.js +2 -2
  62. package/dist/{templates-Qq113pyZ.js → templates-CXARYr6_.js} +2 -2
  63. package/dist/{templates-Qq113pyZ.js.map → templates-CXARYr6_.js.map} +1 -1
  64. package/dist/types/filePicker.d.ts +9 -1
  65. package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BAzmP1_y.js} +42 -37
  66. package/dist/useControlSchema-BAzmP1_y.js.map +1 -0
  67. package/dist/{useDropdownState-DitCtrTT.js → useDropdownState-C7hRiVQf.js} +2 -2
  68. package/dist/{useDropdownState-DitCtrTT.js.map → useDropdownState-C7hRiVQf.js.map} +1 -1
  69. package/dist/{useExperimentSelector-I8HC8hUu.js → useExperimentSelector-D_vobhnW.js} +4 -4
  70. package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-D_vobhnW.js.map} +1 -1
  71. package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-DEHejpiL.js} +2 -2
  72. package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-DEHejpiL.js.map} +1 -1
  73. package/dist/{validation-nBbSn1TI.js → validation-OuEr0iAS.js} +9 -8
  74. package/dist/{validation-nBbSn1TI.js.map → validation-OuEr0iAS.js.map} +1 -1
  75. package/package.json +1 -1
  76. package/src/__tests__/apiErrors.test.ts +81 -0
  77. package/src/__tests__/components/FilePicker.state.test.ts +128 -1
  78. package/src/__tests__/components/FilePicker.test.ts +160 -1
  79. package/src/__tests__/components/GeneratedPlotlyResult.test.ts +52 -5
  80. package/src/__tests__/components/PlotlyChart.test.ts +305 -0
  81. package/src/__tests__/components/SegmentedControl.test.ts +19 -0
  82. package/src/__tests__/composables/useApi.test.ts +82 -9
  83. package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
  84. package/src/__tests__/composables/usePluginClient.test.ts +3 -0
  85. package/src/apiErrors.ts +85 -0
  86. package/src/components/FilePicker.story.vue +44 -0
  87. package/src/components/FilePicker.vue +23 -40
  88. package/src/components/FileUploader.vue +32 -14
  89. package/src/components/GeneratedPlotlyResult.vue +34 -69
  90. package/src/components/GeneratedResultRenderer.vue +0 -5
  91. package/src/components/PlotlyChart.story.vue +61 -0
  92. package/src/components/PlotlyChart.vue +233 -0
  93. package/src/components/SegmentedControl.vue +2 -2
  94. package/src/components/index.ts +1 -0
  95. package/src/composables/filePicker/localFiles.ts +6 -3
  96. package/src/composables/filePicker/usePickerNavigation.ts +29 -8
  97. package/src/composables/filePicker/usePickerSelection.ts +20 -5
  98. package/src/composables/filePicker/validation.ts +1 -0
  99. package/src/composables/useApi.ts +33 -4
  100. package/src/composables/useFilePicker.ts +41 -22
  101. package/src/composables/useGeneratedAnalysis.ts +10 -17
  102. package/src/composables/usePluginClient.ts +3 -0
  103. package/src/index.ts +3 -0
  104. package/src/styles/components/file-picker.css +6 -5
  105. package/src/types/filePicker.ts +9 -2
  106. package/dist/components-6IIxCSI8.js.map +0 -1
  107. package/dist/errors-_W0-LbA6.js.map +0 -1
  108. package/dist/useControlSchema-aMZlb4Sc.js.map +0 -1
@@ -0,0 +1,233 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Render native Plotly data with MINT loading, empty, theme, and error states.
4
+ *
5
+ * Plotly is loaded only when the component is mounted, so importing the SDK does
6
+ * not add Plotly to the application's initial bundle.
7
+ *
8
+ * @example
9
+ * ```vue
10
+ * <PlotlyChart
11
+ * title="QC overview"
12
+ * :data="traces"
13
+ * :layout="{ xaxis: { title: { text: 'Sample' } } }"
14
+ * :config="{ toImageButtonOptions: { filename: 'qc-overview' } }"
15
+ * />
16
+ * ```
17
+ */
18
+ import type * as PlotlyType from 'plotly.js'
19
+ import { nextTick, onBeforeUnmount, onMounted, ref, toRaw, watch } from 'vue'
20
+
21
+ import ChartContainer from './ChartContainer.vue'
22
+
23
+ interface Props {
24
+ data: PlotlyType.Data[]
25
+ layout?: Partial<PlotlyType.Layout>
26
+ config?: Partial<PlotlyType.Config>
27
+ title?: string
28
+ description?: string
29
+ loading?: boolean
30
+ empty?: boolean
31
+ emptyMessage?: string
32
+ ariaLabel?: string
33
+ }
34
+
35
+ const props = withDefaults(defineProps<Props>(), {
36
+ layout: () => ({}),
37
+ config: () => ({}),
38
+ loading: false,
39
+ empty: false,
40
+ emptyMessage: 'No data available',
41
+ ariaLabel: 'Interactive Plotly chart',
42
+ })
43
+
44
+ const chartElement = ref<HTMLDivElement | null>(null)
45
+ const failed = ref(false)
46
+ let plotly: typeof PlotlyType | null = null
47
+ let resizeObserver: ResizeObserver | null = null
48
+ let themeObserver: MutationObserver | null = null
49
+ let renderedElement: HTMLDivElement | null = null
50
+ let rendering = false
51
+ let disposed = false
52
+ let revision = 0
53
+
54
+ async function loadPlotly(): Promise<typeof PlotlyType> {
55
+ const module = await import('plotly.js-dist-min')
56
+ return ('default' in module ? module.default : module) as unknown as typeof PlotlyType
57
+ }
58
+
59
+ function cssValue(name: string, fallback: string): string {
60
+ const value = getComputedStyle(chartElement.value ?? document.documentElement).getPropertyValue(name).trim()
61
+ return value || getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
62
+ }
63
+
64
+ // Unwrap nested Vue proxies while copying Plotly's mutable input tree. Keep
65
+ // native dates/typed arrays and callbacks instead of round-tripping through JSON.
66
+ function clonePlotlyInput<T>(value: T): T {
67
+ const raw = toRaw(value)
68
+ if (Array.isArray(raw)) return raw.map(clonePlotlyInput) as T
69
+ if (raw !== null && typeof raw === 'object') {
70
+ if (Object.getPrototypeOf(raw) === Object.prototype) {
71
+ return Object.fromEntries(Object.entries(raw).map(([key, item]) => [key, clonePlotlyInput(item)])) as T
72
+ }
73
+ return structuredClone(raw)
74
+ }
75
+ return raw
76
+ }
77
+
78
+ function themedLayout(): Partial<PlotlyType.Layout> {
79
+ const textColor = cssValue('--text-primary', '#1E293B')
80
+ const gridColor = cssValue('--border-color', '#E5E7EB')
81
+ const layout = clonePlotlyInput(props.layout)
82
+ return {
83
+ autosize: true,
84
+ paper_bgcolor: 'rgba(0,0,0,0)',
85
+ plot_bgcolor: 'rgba(0,0,0,0)',
86
+ ...layout,
87
+ font: { color: textColor, family: cssValue('--font-sans', 'Arial, sans-serif'), ...layout.font },
88
+ xaxis: { gridcolor: gridColor, ...layout.xaxis },
89
+ yaxis: { gridcolor: gridColor, ...layout.yaxis },
90
+ }
91
+ }
92
+
93
+ function purgeChart(): void {
94
+ resizeObserver?.disconnect()
95
+ if (renderedElement) plotly?.purge(renderedElement)
96
+ renderedElement = null
97
+ }
98
+
99
+ async function renderChart(): Promise<void> {
100
+ revision++
101
+ if (rendering || disposed || props.loading || props.empty) return
102
+ rendering = true
103
+ try {
104
+ while (!disposed && !props.loading && !props.empty) {
105
+ const currentRevision = revision
106
+ failed.value = false
107
+ await nextTick()
108
+ if (disposed || props.loading || props.empty) break
109
+ try {
110
+ plotly ??= await loadPlotly()
111
+ if (disposed || props.loading || props.empty) break
112
+ if (currentRevision !== revision) continue
113
+ const element = chartElement.value
114
+ if (!element) break
115
+ renderedElement = element
116
+ // Plotly mutates traces and nested layout fields; keep caller-owned values intact.
117
+ await plotly.react(
118
+ element,
119
+ clonePlotlyInput(props.data),
120
+ themedLayout(),
121
+ { responsive: true, displaylogo: false, ...props.config },
122
+ )
123
+ if (disposed || props.loading || props.empty || element !== chartElement.value) {
124
+ purgeChart()
125
+ } else {
126
+ resizeObserver?.observe(element)
127
+ }
128
+ } catch {
129
+ purgeChart()
130
+ if (!disposed && currentRevision === revision) failed.value = true
131
+ }
132
+ if (currentRevision === revision) break
133
+ }
134
+ } finally {
135
+ rendering = false
136
+ if (disposed || props.loading || props.empty) purgeChart()
137
+ }
138
+ }
139
+
140
+ onMounted(() => {
141
+ if (typeof ResizeObserver !== 'undefined') {
142
+ resizeObserver = new ResizeObserver(() => {
143
+ if (rendering || !plotly || !renderedElement) return
144
+ // A tab can become hidden or unmount before Plotly's async resize finishes.
145
+ void Promise.resolve(plotly.Plots.resize(renderedElement)).catch(() => {})
146
+ })
147
+ }
148
+
149
+ if (typeof MutationObserver !== 'undefined') {
150
+ themeObserver = new MutationObserver(() => void renderChart())
151
+ themeObserver.observe(document.documentElement, {
152
+ attributes: true,
153
+ attributeFilter: ['class', 'style'],
154
+ })
155
+ }
156
+ void renderChart()
157
+ })
158
+
159
+ watch(
160
+ [() => props.data, () => props.layout, () => props.config, () => props.loading, () => props.empty],
161
+ () => {
162
+ if (props.loading || props.empty) {
163
+ resizeObserver?.disconnect()
164
+ if (!rendering) purgeChart()
165
+ }
166
+ void renderChart()
167
+ },
168
+ )
169
+
170
+ onBeforeUnmount(() => {
171
+ disposed = true
172
+ resizeObserver?.disconnect()
173
+ themeObserver?.disconnect()
174
+ // An in-flight react must finish before its resources can be purged completely.
175
+ if (!rendering) purgeChart()
176
+ })
177
+ </script>
178
+
179
+ <template>
180
+ <ChartContainer
181
+ :title="title"
182
+ :description="description"
183
+ :loading="loading"
184
+ :empty="empty"
185
+ :empty-message="emptyMessage"
186
+ >
187
+ <template v-if="$slots.toolbar" #toolbar>
188
+ <slot name="toolbar" />
189
+ </template>
190
+ <template v-if="$slots.empty" #empty>
191
+ <slot name="empty" />
192
+ </template>
193
+
194
+ <template v-if="failed">
195
+ <slot name="error">
196
+ <div class="mint-plotly-chart__error">
197
+ <p role="alert">Unable to display this chart.</p>
198
+ </div>
199
+ </slot>
200
+ </template>
201
+ <div
202
+ ref="chartElement"
203
+ v-show="!failed"
204
+ class="mint-plotly-chart__plot"
205
+ role="group"
206
+ :aria-label="ariaLabel"
207
+ />
208
+
209
+ <template v-if="$slots.legend" #legend>
210
+ <slot name="legend" />
211
+ </template>
212
+ </ChartContainer>
213
+ </template>
214
+
215
+ <style scoped>
216
+ .mint-plotly-chart__plot {
217
+ width: 100%;
218
+ min-height: 24rem;
219
+ }
220
+
221
+ .mint-plotly-chart__error {
222
+ margin: 0;
223
+ padding: 1rem;
224
+ border: 1px solid var(--mint-error-border);
225
+ border-radius: 0.75rem;
226
+ background: var(--mint-error-bg);
227
+ color: var(--mint-error-hover);
228
+ }
229
+
230
+ .mint-plotly-chart__error :deep(p) {
231
+ margin: 0;
232
+ }
233
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- /** Renders a segmented button group for single-option selection, with simple or solid variants. */
2
+ /** Renders a segmented button group for single-option selection, defaulting to the card variant. */
3
3
  import { computed, type ComponentPublicInstance } from 'vue'
4
4
  import type { SegmentedOption, SegmentedOptionInput, SegmentedControlVariant, SegmentedControlSize } from '../types'
5
5
  import { normalizeOptionInput } from '../utils/options'
@@ -16,7 +16,7 @@ interface Props {
16
16
  }
17
17
 
18
18
  const props = withDefaults(defineProps<Props>(), {
19
- variant: 'simple',
19
+ variant: 'card',
20
20
  size: 'md',
21
21
  fullWidth: false,
22
22
  disabled: false,
@@ -99,6 +99,7 @@ export { default as SampleHierarchyTree } from './SampleHierarchyTree.vue'
99
99
  export { default as ProtocolStepEditor } from './ProtocolStepEditor.vue'
100
100
 
101
101
  // Scientific display components
102
+ export { default as PlotlyChart } from './PlotlyChart.vue'
102
103
  export { default as ScientificNumber } from './ScientificNumber.vue'
103
104
  /** Renders a chemical formula string (H₂O, C₆H₁₂O₆, Ca(OH)₂) with typographically correct subscripts and superscripts for charges. @deprecated Unused by the MINT platform and scheduled for removal in MINT 1.3; no platform replacement is planned. */
104
105
  export { default as ChemicalFormula } from './ChemicalFormula.vue'
@@ -2,10 +2,13 @@ import type { PickerAdapter, PickerFile, PickerNode } from '../../types/filePick
2
2
  import { pickerFormat } from './validation'
3
3
 
4
4
  /** Construct metadata only: no FileReader, arrayBuffer or upload. */
5
- export function localFilesAdapter(files: File[]): PickerAdapter {
5
+ export function localFilesAdapter(
6
+ files: File[],
7
+ relativePaths?: (string | undefined)[],
8
+ ): PickerAdapter {
6
9
  const nodes = new Map<string, PickerNode>()
7
- for (const file of files) {
8
- const relative = file.webkitRelativePath || file.name
10
+ for (const [index, file] of files.entries()) {
11
+ const relative = relativePaths?.[index] || file.webkitRelativePath || file.name
9
12
  const parts = relative.split('/')
10
13
  let parent: string | null = null
11
14
  for (let i = 0; i < parts.length - 1; i++) {
@@ -6,7 +6,11 @@ import type {
6
6
  PickerTreeRow,
7
7
  } from '../../types/filePicker'
8
8
 
9
- export function usePickerNavigation(adapter: () => PickerAdapter, request: () => PickerRequest) {
9
+ export function usePickerNavigation(
10
+ adapter: () => PickerAdapter,
11
+ request: () => PickerRequest,
12
+ systemFilter: (node: PickerNode) => boolean = () => true,
13
+ ) {
10
14
  const path = shallowRef('')
11
15
  const trail = ref<{ path: string; label: string }[]>([])
12
16
  const entries = shallowRef<PickerNode[]>([])
@@ -23,6 +27,17 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
23
27
  const pending = new Map<string, Promise<PickerNode[]>>()
24
28
  const childControllers = new Set<AbortController>()
25
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
+
26
41
  function register(items: PickerNode[], parent?: string) {
27
42
  for (const item of items)
28
43
  nodes.value.set(item.path, {
@@ -56,7 +71,11 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
56
71
  loading.value = false
57
72
  error.value = ''
58
73
  }
59
- async function loadChildren(folder: string, ancestors: string[] = []): Promise<PickerNode[]> {
74
+ async function loadChildren(
75
+ folder: string,
76
+ ancestors: string[] = [],
77
+ refresh = false,
78
+ ): Promise<PickerNode[]> {
60
79
  if (ancestors.includes(folder)) throw new Error('The adapter returned a circular folder tree.')
61
80
  const cached = children.value.get(folder)
62
81
  if (cached) return cached
@@ -66,7 +85,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
66
85
  const abort = new AbortController()
67
86
  childControllers.add(abort)
68
87
  const task = adapter()
69
- .listChildren(folder, { ...request(), signal: abort.signal })
88
+ .listChildren(folder, { ...request(), ...(refresh && { refresh }), signal: abort.signal })
70
89
  .then((items) => {
71
90
  if (token !== epoch) throw new DOMException('Cancelled', 'AbortError')
72
91
  const retained = new Set(items.map((item) => item.path))
@@ -91,11 +110,11 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
91
110
  pending.set(folder, task)
92
111
  return task
93
112
  }
94
- async function load(query = '', recursive = false) {
113
+ async function load(query = '', recursive = false, refresh = false) {
95
114
  const token = ++navigation
96
115
  controller?.abort()
97
116
  controller = new AbortController()
98
- const options = { ...request(), signal: controller.signal }
117
+ const options = { ...request(), ...(refresh && { refresh }), signal: controller.signal }
99
118
  loading.value = true
100
119
  error.value = ''
101
120
  entries.value = []
@@ -103,7 +122,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
103
122
  const items = query.trim()
104
123
  ? await adapter().search(query.trim(), { path: path.value, recursive }, options)
105
124
  : path.value
106
- ? await loadChildren(path.value)
125
+ ? await loadChildren(path.value, [], refresh)
107
126
  : await adapter().listRoot(options)
108
127
  if (token !== navigation) return
109
128
  register(items, query.trim() ? undefined : path.value)
@@ -116,7 +135,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
116
135
  }
117
136
  }
118
137
  async function expand(node: PickerNode) {
119
- if (node.kind !== 'folder' || node.unavailableReason) return
138
+ if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
120
139
  if (expanded.value.has(node.path)) {
121
140
  expanded.value.delete(node.path)
122
141
  return
@@ -138,7 +157,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
138
157
  }
139
158
  }
140
159
  function enter(node: PickerNode) {
141
- if (node.kind !== 'folder' || node.unavailableReason) return
160
+ if (node.kind !== 'folder' || node.unavailableReason || !isAllowed(node)) return
142
161
  const index = trail.value.findIndex((crumb) => crumb.path === node.path)
143
162
  trail.value =
144
163
  index >= 0
@@ -160,6 +179,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
160
179
  for (const item of items) {
161
180
  if (ancestors.has(item.path) || depth > 30) continue
162
181
  const node = nodes.value.get(item.path) ?? item
182
+ if (!isAllowed(node)) continue
163
183
  result.push({
164
184
  node,
165
185
  depth,
@@ -187,6 +207,7 @@ export function usePickerNavigation(adapter: () => PickerAdapter, request: () =>
187
207
  loading,
188
208
  error,
189
209
  rows,
210
+ isAllowed,
190
211
  clear,
191
212
  invalidate,
192
213
  register,
@@ -1,4 +1,4 @@
1
- import { computed, ref, shallowRef } from 'vue'
1
+ import { computed, ref, shallowRef, watch } from 'vue'
2
2
  import type {
3
3
  PickerCapability,
4
4
  PickerExcluded,
@@ -36,6 +36,8 @@ export function usePickerSelection(
36
36
  return false
37
37
  }
38
38
  function exclusion(node: PickerNode) {
39
+ if (!nav.isAllowed(node))
40
+ return { path: node.path, name: node.name, reason: 'forbidden' as const }
39
41
  const own = pickerExclusion(node, capability(), skipBlanks())
40
42
  if (own) return own
41
43
  const seen = new Set<string>()
@@ -72,7 +74,9 @@ export function usePickerSelection(
72
74
  focusPath.value = node.path
73
75
  if (!selectable(node)) return
74
76
  const multi = mode() === 'multi-file' || mode() === 'folder+files'
75
- const additive = multi && (modifiers.metaKey || modifiers.ctrlKey)
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)
76
80
  if (multi && modifiers.shiftKey && anchor) {
77
81
  const rows = nav.rows.value.map((row) => row.node)
78
82
  const from = rows.findIndex((item) => item.path === anchor)
@@ -141,12 +145,13 @@ export function usePickerSelection(
141
145
  const roots = computed(() =>
142
146
  [...keys.value]
143
147
  .map((key) => nav.nodes.value.get(key))
144
- .filter((node): node is PickerNode => !!node && !ancestorSelected(node)),
148
+ .filter((node): node is PickerNode => !!node && !ancestorSelected(node) && nav.isAllowed(node)),
145
149
  )
146
150
  const chosen = computed(() => {
147
151
  const files: PickerFile[] = []
148
152
  const excluded: PickerExcluded[] = []
149
153
  for (const node of nav.nodes.value.values()) {
154
+ if (!nav.isAllowed(node)) continue
150
155
  if (!keys.value.has(node.path) && !ancestorSelected(node)) continue
151
156
  const reason =
152
157
  exclusion(node) ??
@@ -170,6 +175,7 @@ export function usePickerSelection(
170
175
  const seen = new Set<string>()
171
176
  async function walk(node: PickerNode, ancestors: string[] = []): Promise<PickerFile[]> {
172
177
  if (token !== generation) throw new DOMException('Cancelled', 'AbortError')
178
+ if (!nav.isAllowed(node)) return []
173
179
  if (node.kind === 'file') return [node]
174
180
  if (node.unavailableReason) return []
175
181
  if (ancestors.includes(node.path))
@@ -213,8 +219,17 @@ export function usePickerSelection(
213
219
  if (token === generation) resolving.value = false
214
220
  }
215
221
  }
216
- const focused = computed(
217
- () => nav.nodes.value.get(focusPath.value) ?? roots.value[roots.value.length - 1] ?? null,
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
+ },
218
233
  )
219
234
  function folderProgress(node: PickerNode): string {
220
235
  if (node.kind !== 'folder' || node.fileCount == null) return ''
@@ -31,6 +31,7 @@ export function pickerReason(excluded: PickerExcluded | null, node?: PickerNode)
31
31
  blank: 'Skipped by the blank-file rule.',
32
32
  unreadable: node?.unavailableReason || 'This item cannot be read.',
33
33
  deselected: 'Removed from the selection.',
34
+ forbidden: 'This item is not permitted by the system filter.',
34
35
  }[excluded.reason]
35
36
  }
36
37
 
@@ -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 { 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> & {
@@ -141,6 +144,8 @@ export interface ApiClientOptions {
141
144
  baseUrl?: string
142
145
  timeout?: number
143
146
  withAuth?: boolean
147
+ /** Normalize failed responses into MintApiError. Generated clients enable this by default. */
148
+ typedErrors?: boolean
144
149
  }
145
150
 
146
151
  export interface UseApiReturn {
@@ -187,11 +192,34 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
187
192
  })
188
193
  apiClient.interceptors.response.use(
189
194
  (response) => response,
190
- (error: unknown) => {
191
- if (axios.isAxiosError(error) && error.response?.status === 401) {
195
+ async (error: unknown) => {
196
+ if (axios.isAxiosError(error) && error.response) {
192
197
  const request = error.config as MintInternalAxiosRequestConfig | undefined
193
- if (request?._mintSkipAuth !== true) {
194
- return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
198
+ if (request?._mintTypedErrors) {
199
+ if (error.response.status === 401 && request._mintSkipAuth !== true) {
200
+ authenticationRequiredError(error, request._mintAuthToken)
201
+ }
202
+ let payload: unknown = error.response.data
203
+ if (payload instanceof Blob) {
204
+ try {
205
+ payload = JSON.parse(await payload.text())
206
+ } catch {
207
+ // Keep non-JSON download errors available as the original body.
208
+ }
209
+ }
210
+ return Promise.reject(mintApiErrorFromResponse(
211
+ error.response.status,
212
+ payload,
213
+ {
214
+ requestId: String(error.response.headers?.['x-request-id'] ?? '') || null,
215
+ cause: error,
216
+ },
217
+ ))
218
+ }
219
+ if (error.response.status === 401) {
220
+ if (request?._mintSkipAuth !== true) {
221
+ return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
222
+ }
195
223
  }
196
224
  }
197
225
  return Promise.reject(error)
@@ -213,6 +241,7 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
213
241
  const base: MintAxiosRequestConfig = {
214
242
  baseURL: getBaseUrl(),
215
243
  timeout: options.timeout ?? settingsStore.requestTimeout,
244
+ _mintTypedErrors: options.typedErrors,
216
245
  ...config,
217
246
  }
218
247
  // Strip auth header if explicitly disabled
@@ -1,4 +1,4 @@
1
- import { computed, nextTick, onScopeDispose, ref, shallowRef, watch } from 'vue'
1
+ import { computed, nextTick, onScopeDispose, ref, shallowRef, toRaw, watch } from 'vue'
2
2
  import type {
3
3
  PickerAdapter,
4
4
  PickerCapabilities,
@@ -19,6 +19,7 @@ interface PickerStateProps {
19
19
  capabilities: PickerCapabilities
20
20
  initialPath: string
21
21
  initialSelection: PickerSelection | null
22
+ systemFilter?: (node: PickerNode, source: PickerSource) => boolean
22
23
  }
23
24
  type PickerEmit = {
24
25
  (event: 'update:open', value: boolean): void
@@ -47,10 +48,14 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
47
48
  const skipBlanks = shallowRef(false)
48
49
  const reviewing = shallowRef(false)
49
50
  const localFiles = shallowRef<File[]>([])
50
- const localAdapter = computed(() => localFilesAdapter(localFiles.value))
51
+ const localPaths = new WeakMap<File, string>()
52
+ const localAdapter = computed(() =>
53
+ localFilesAdapter(localFiles.value, localFiles.value.map((file) => localPaths.get(toRaw(file)))),
54
+ )
51
55
  const nav = usePickerNavigation(
52
56
  () => (source.value === 'localFile' ? localAdapter.value : props.adapter),
53
57
  () => ({ source: source.value }),
58
+ (node) => props.systemFilter?.(node, source.value) !== false,
54
59
  )
55
60
  const capability = computed(() => props.capabilities[source.value])
56
61
  const selection = usePickerSelection(
@@ -79,14 +84,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
79
84
  }
80
85
  })
81
86
 
82
- const filesInput = ref<HTMLInputElement | null>(null)
83
- const folderInput = ref<HTMLInputElement | null>(null)
84
87
  const searchArea = ref<HTMLElement | null>(null)
85
- const accept = computed(
86
- () =>
87
- capability.value?.formats.map((format) => `.${format.replace(/^\./, '')}`).join(',') ||
88
- undefined,
89
- )
90
88
  const confirmLabel = computed(
91
89
  () =>
92
90
  ({
@@ -133,12 +131,17 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
133
131
  selection.reset(seed?.source === source.value ? seed : null)
134
132
  reviewing.value = false
135
133
  if (!allowedSources.value.includes(source.value)) return
136
- if (seed?.source === 'localFile' && source.value === 'localFile') localFiles.value = seed.files
134
+ if (seed?.source === 'localFile' && source.value === 'localFile') {
135
+ seed.files.forEach((file, index) => {
136
+ if (seed.relativePaths?.[index]) localPaths.set(toRaw(file), seed.relativePaths[index]!)
137
+ })
138
+ localFiles.value = seed.files
139
+ }
137
140
  await nav.load(query.value, recursive.value)
138
141
  if (token !== openGeneration || !props.open) return
139
142
  if (seed?.source === 'localFile' && source.value === 'localFile') {
140
143
  // Restored local File objects are already the confirmed subset.
141
- for (const node of nav.entries.value) selection.select(node, { ctrlKey: true })
144
+ await selectLocalFiles()
142
145
  }
143
146
  if (selection.keys.value.size) await selection.resolve()
144
147
  }
@@ -238,7 +241,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
238
241
  }
239
242
  async function refresh() {
240
243
  nav.invalidate()
241
- await nav.load(query.value, recursive.value)
244
+ await nav.load(query.value, recursive.value, true)
242
245
  if (selection.keys.value.size) await selection.resolve()
243
246
  }
244
247
  function selectParent(node: PickerNode) {
@@ -251,12 +254,14 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
251
254
  nav.register([parent])
252
255
  selection.select(parent)
253
256
  }
254
- async function chooseLocal(event: Event) {
257
+ async function chooseLocal(files: File[], _rootName?: string, relativePaths?: string[]) {
255
258
  const token = openGeneration
256
- const input = event.target as HTMLInputElement
257
- if (!input.files?.length) return
258
- localFiles.value = Array.from(input.files)
259
- input.value = ''
259
+ if (!files.length || !props.open || source.value !== 'localFile') return
260
+ files.forEach((file, index) => {
261
+ if (relativePaths?.[index]) localPaths.set(toRaw(file), relativePaths[index]!)
262
+ })
263
+ localFiles.value = files
264
+ clearTimeout(timer)
260
265
  nav.clear()
261
266
  selection.reset()
262
267
  path.value = ''
@@ -264,7 +269,16 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
264
269
  query.value = ''
265
270
  await nav.load()
266
271
  if (token !== openGeneration || !props.open || source.value !== 'localFile') return
267
- const candidates = nav.entries.value.filter((node) => selection.selectable(node))
272
+ await selectLocalFiles()
273
+ }
274
+ async function selectLocalFiles() {
275
+ const token = openGeneration
276
+ const items = props.selectionMode === 'single-file' || props.selectionMode === 'multi-file'
277
+ ? await localAdapter.value.search('', { path: '', recursive: true })
278
+ : nav.entries.value
279
+ if (token !== openGeneration || !props.open || source.value !== 'localFile') return
280
+ nav.register(items)
281
+ const candidates = items.filter((node) => selection.selectable(node))
268
282
  for (const node of props.selectionMode === 'single-file' || props.selectionMode === 'folder'
269
283
  ? candidates.slice(0, 1)
270
284
  : candidates)
@@ -280,7 +294,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
280
294
  source.value === 'localFile'
281
295
  ? {
282
296
  source: 'localFile',
283
- files: chosen.value.files.flatMap((node) => (node.file ? [node.file] : [])),
297
+ files: chosen.value.files.flatMap((node) => (node.file ? [toRaw(node.file)] : [])),
284
298
  }
285
299
  : {
286
300
  source: 'server',
@@ -288,6 +302,14 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
288
302
  files: [...chosen.value.files],
289
303
  excluded: [...chosen.value.excluded],
290
304
  }
305
+ if (result.source === 'localFile') {
306
+ const relativePaths = result.files.map((file) =>
307
+ localPaths.get(file) || file.webkitRelativePath || file.name,
308
+ )
309
+ if (relativePaths.some((path, index) =>
310
+ path !== (result.files[index]!.webkitRelativePath || result.files[index]!.name),
311
+ )) result.relativePaths = relativePaths
312
+ }
291
313
  lastConfirmed = result
292
314
  emit('select', result)
293
315
  emit('update:open', false)
@@ -326,10 +348,7 @@ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
326
348
  focused,
327
349
  resolving,
328
350
  resolutionError,
329
- filesInput,
330
- folderInput,
331
351
  searchArea,
332
- accept,
333
352
  confirmLabel,
334
353
  folderCount,
335
354
  canConfirm,