@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,203 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import FileBrowserModal from './FileBrowserModal.vue'
4
+ import type { FileEntry, FileSelection, ServerMount } from '../types/fileBrowserTypes'
5
+
6
+ const isOpen = ref(true)
7
+
8
+ const mounts: ServerMount[] = [
9
+ {
10
+ id: 'instrument-qe',
11
+ label: '/mnt/instrument-qe',
12
+ count: 4,
13
+ children: [
14
+ {
15
+ id: 'instrument-qe/2026-07',
16
+ label: '2026-07',
17
+ count: 12,
18
+ children: [
19
+ { id: 'instrument-qe/2026-07/raw', label: 'raw', count: '1.2k' },
20
+ { id: 'instrument-qe/2026-07/converted', label: 'converted', count: 840 },
21
+ { id: 'instrument-qe/2026-07/logs', label: 'logs', count: 36 },
22
+ ],
23
+ },
24
+ { id: 'instrument-qe/2026-06', label: '2026-06', count: 9 },
25
+ ],
26
+ },
27
+ { id: 'nas-shared', label: '/mnt/nas-shared', count: 18 },
28
+ { id: 'scratch', label: '/mnt/scratch', count: 7 },
29
+ { id: 'sequencer-01', label: '/mnt/sequencer-01', available: false },
30
+ ]
31
+
32
+ const entries: FileEntry[] = [
33
+ { name: 'calibration', path: '2026-07/hypoxia-run3/raw/calibration', kind: 'directory', child_count: 2 },
34
+ { name: 'blanks', path: '2026-07/hypoxia-run3/raw/blanks', kind: 'directory', child_count: 6 },
35
+ {
36
+ name: 'HeLa_norm_0h_r1.raw',
37
+ path: '2026-07/hypoxia-run3/raw/HeLa_norm_0h_r1.raw',
38
+ kind: 'file',
39
+ size_bytes: 2_040_109_465,
40
+ extension: '.raw',
41
+ },
42
+ {
43
+ name: 'HeLa_norm_0h_r2.raw',
44
+ path: '2026-07/hypoxia-run3/raw/HeLa_norm_0h_r2.raw',
45
+ kind: 'file',
46
+ size_bytes: 2_040_109_465,
47
+ extension: '.raw',
48
+ },
49
+ {
50
+ name: 'HeLa_hypoxia_1pct_6h_r1.raw',
51
+ path: '2026-07/hypoxia-run3/raw/HeLa_hypoxia_1pct_6h_r1.raw',
52
+ kind: 'file',
53
+ size_bytes: 2_254_857_830,
54
+ extension: '.raw',
55
+ },
56
+ {
57
+ name: 'HeLa_hypoxia_1pct_6h_r2.raw',
58
+ path: '2026-07/hypoxia-run3/raw/HeLa_hypoxia_1pct_6h_r2.raw',
59
+ kind: 'file',
60
+ size_bytes: 2_254_857_830,
61
+ extension: '.raw',
62
+ },
63
+ {
64
+ name: 'HeLa_hypoxia_1pct_24h_r1.mzML',
65
+ path: '2026-07/hypoxia-run3/raw/HeLa_hypoxia_1pct_24h_r1.mzML',
66
+ kind: 'file',
67
+ size_bytes: 882_900_992,
68
+ extension: '.mzml',
69
+ },
70
+ {
71
+ name: 'acquisition_log.txt',
72
+ path: '2026-07/hypoxia-run3/raw/acquisition_log.txt',
73
+ kind: 'file',
74
+ size_bytes: 49_152,
75
+ extension: '.txt',
76
+ matches_type_rules: false,
77
+ },
78
+ {
79
+ name: 'sequence_table.csv',
80
+ path: '2026-07/hypoxia-run3/raw/sequence_table.csv',
81
+ kind: 'file',
82
+ size_bytes: 12_288,
83
+ extension: '.csv',
84
+ matches_type_rules: false,
85
+ },
86
+ ]
87
+
88
+ const breadcrumbs = [
89
+ { label: '/mnt/instrument-qe', path: '' },
90
+ { label: '2026-07', path: '2026-07' },
91
+ { label: 'hypoxia-run3', path: '2026-07/hypoxia-run3' },
92
+ { label: 'raw', path: '2026-07/hypoxia-run3/raw' },
93
+ ]
94
+
95
+ const selected: FileSelection[] = [
96
+ { mount_id: 'instrument-qe/2026-07/raw', path: '2026-07/hypoxia-run3/raw/blanks', name: 'blanks', kind: 'directory', child_count: 6 },
97
+ {
98
+ mount_id: 'instrument-qe/2026-07/raw',
99
+ path: '2026-07/hypoxia-run3/raw/HeLa_norm_0h_r1.raw',
100
+ name: 'HeLa_norm_0h_r1.raw',
101
+ kind: 'file',
102
+ size_bytes: 2_040_109_465,
103
+ },
104
+ {
105
+ mount_id: 'instrument-qe/2026-07/raw',
106
+ path: '2026-07/hypoxia-run3/raw/HeLa_hypoxia_1pct_6h_r1.raw',
107
+ name: 'HeLa_hypoxia_1pct_6h_r1.raw',
108
+ kind: 'file',
109
+ size_bytes: 2_254_857_830,
110
+ },
111
+ {
112
+ mount_id: 'instrument-qe/2026-07/raw',
113
+ path: '2026-07/hypoxia-run3/raw/HeLa_hypoxia_1pct_6h_r2.raw',
114
+ name: 'HeLa_hypoxia_1pct_6h_r2.raw',
115
+ kind: 'file',
116
+ size_bytes: 2_254_857_830,
117
+ },
118
+ ]
119
+
120
+ function initState() {
121
+ return {
122
+ open: true,
123
+ title: 'Select raw data',
124
+ contextCode: 'MM-EXP-014',
125
+ subtitle: '13C glucose tracing — HeLa, 24h',
126
+ dimNonMatching: true,
127
+ showLocalPicker: true,
128
+ loading: false,
129
+ selected,
130
+ }
131
+ }
132
+ </script>
133
+
134
+ <template>
135
+ <Story title="Experiment/FileBrowserModal">
136
+ <Variant title="Playground" :init-state="initState">
137
+ <template #default="{ state }">
138
+ <div style="padding: 2rem;">
139
+ <FileBrowserModal
140
+ v-model="state.open"
141
+ :mounts="mounts"
142
+ mount-id="instrument-qe/2026-07/raw"
143
+ path="2026-07/hypoxia-run3/raw"
144
+ parent="2026-07/hypoxia-run3"
145
+ :entries="entries"
146
+ :breadcrumbs="breadcrumbs"
147
+ :selected="state.selected"
148
+ :type-rules="['.raw', '.mzML']"
149
+ :total-count="1284"
150
+ :match-count="9"
151
+ :loading="state.loading"
152
+ :title="state.title"
153
+ :context-code="state.contextCode"
154
+ :subtitle="state.subtitle"
155
+ :dim-non-matching="state.dimNonMatching"
156
+ :show-local-picker="state.showLocalPicker"
157
+ confirm-label="Mount 4 items"
158
+ @update:selected="state.selected = $event"
159
+ />
160
+ </div>
161
+ </template>
162
+ <template #controls="{ state }">
163
+ <HstCheckbox v-model="state.open" title="Open" />
164
+ <HstText v-model="state.title" title="Title" />
165
+ <HstText v-model="state.contextCode" title="Context code" />
166
+ <HstText v-model="state.subtitle" title="Subtitle" />
167
+ <HstCheckbox v-model="state.dimNonMatching" title="Dim non-matching" />
168
+ <HstCheckbox v-model="state.showLocalPicker" title="Show local picker" />
169
+ <HstCheckbox v-model="state.loading" title="Loading" />
170
+ </template>
171
+ </Variant>
172
+
173
+ <Variant title="Empty folder">
174
+ <div style="padding: 2rem;">
175
+ <FileBrowserModal
176
+ :model-value="isOpen"
177
+ :mounts="mounts"
178
+ mount-id="scratch"
179
+ path=""
180
+ :parent="null"
181
+ :entries="[]"
182
+ :breadcrumbs="[{ label: '/mnt/scratch', path: '' }]"
183
+ title="Select raw data"
184
+ />
185
+ </div>
186
+ </Variant>
187
+
188
+ <Variant title="Offline mount">
189
+ <div style="padding: 2rem;">
190
+ <FileBrowserModal
191
+ :model-value="isOpen"
192
+ :mounts="mounts"
193
+ mount-id="sequencer-01"
194
+ path=""
195
+ :parent="null"
196
+ :entries="[]"
197
+ error="This mount is offline. Check that the share is reachable from the MINT server."
198
+ title="Select raw data"
199
+ />
200
+ </div>
201
+ </Variant>
202
+ </Story>
203
+ </template>
@@ -0,0 +1,384 @@
1
+ <script setup lang="ts">
2
+ /** Modal picker over the read-only server mounts, recording paths rather than copying data. */
3
+ import { computed } from 'vue'
4
+ import { FILE_BROWSER_ICONS as ICON, formatBytes } from './fileBrowserPresentation'
5
+ import FileBrowserToolbarInternal from './internal/FileBrowserToolbarInternal.vue'
6
+ import FileBrowserListingInternal from './internal/FileBrowserListingInternal.vue'
7
+ import type { ModalSize } from '../types'
8
+ import type {
9
+ FileBrowserSortKey,
10
+ FileEntry,
11
+ FilePathCrumb,
12
+ FileSelection,
13
+ ServerMount,
14
+ } from '../types/fileBrowserTypes'
15
+ import { useListSelection } from '../composables/useListSelection'
16
+ import BaseButton from './BaseButton.vue'
17
+ import IconButton from './IconButton.vue'
18
+ import FileBrowserDialogInternal from './internal/FileBrowserDialogInternal.vue'
19
+
20
+ interface Props {
21
+ modelValue: boolean
22
+ /** Mounts in the location strip; nested `children` follow their parent. */
23
+ mounts?: ServerMount[]
24
+ /** Mount the listing belongs to. */
25
+ mountId?: string
26
+ /** Mount-relative path of the directory being shown. */
27
+ path?: string
28
+ /** Parent path, or null at a mount root. */
29
+ parent?: string | null
30
+ entries?: FileEntry[]
31
+ breadcrumbs?: FilePathCrumb[]
32
+ /** Controlled selection, mirroring DataFrame's selectedKeys contract. */
33
+ selected?: FileSelection[]
34
+ /** Extensions the caller cares about, shown as a chip in the toolbar. */
35
+ typeRules?: string[]
36
+ search?: string
37
+ sort?: FileBrowserSortKey
38
+ /** Directory size before any search filter. */
39
+ totalCount?: number | null
40
+ /** How many entries satisfy the type rules. */
41
+ matchCount?: number | null
42
+ /** True when the server capped the listing. */
43
+ truncated?: boolean
44
+ loading?: boolean
45
+ error?: string | null
46
+ title?: string
47
+ /** Free-form context line, e.g. the experiment this data is for. */
48
+ subtitle?: string
49
+ /** Monospace prefix on the context line, e.g. an experiment code. */
50
+ contextCode?: string
51
+ /** Dim entries that fail the type rules instead of showing them plainly. */
52
+ dimNonMatching?: boolean
53
+ /** Offer the hand-off to the system file picker for local, never-uploaded files. */
54
+ showLocalPicker?: boolean
55
+ confirmLabel?: string
56
+ size?: ModalSize
57
+ }
58
+
59
+ const props = withDefaults(defineProps<Props>(), {
60
+ mounts: () => [],
61
+ mountId: '',
62
+ path: '',
63
+ parent: null,
64
+ entries: () => [],
65
+ breadcrumbs: () => [],
66
+ selected: () => [],
67
+ typeRules: () => [],
68
+ search: '',
69
+ sort: 'name',
70
+ totalCount: null,
71
+ matchCount: null,
72
+ truncated: false,
73
+ loading: false,
74
+ error: null,
75
+ title: 'Select data',
76
+ subtitle: '',
77
+ contextCode: '',
78
+ dimNonMatching: true,
79
+ showLocalPicker: true,
80
+ confirmLabel: '',
81
+ size: 'full',
82
+ })
83
+
84
+ const emit = defineEmits<{
85
+ 'update:modelValue': [value: boolean]
86
+ 'update:selected': [selection: FileSelection[]]
87
+ 'update:search': [value: string]
88
+ 'update:sort': [value: FileBrowserSortKey]
89
+ /** A directory was opened — the caller fetches and feeds back a new listing. */
90
+ navigate: [location: { mount_id: string; path: string }]
91
+ /** The directory currently being viewed was taken as the input. */
92
+ selectFolder: [location: { mount_id: string; path: string }]
93
+ /** Hand off to the system picker; local files are read in-browser, never uploaded. */
94
+ chooseLocal: []
95
+ confirm: [selection: FileSelection[]]
96
+ cancel: []
97
+ /** Retry or reload the current location (bind to useFileBrowser.refresh). */
98
+ refresh: []
99
+ }>()
100
+
101
+ /** A selection is identified by mount and path, so the same name under two mounts stays distinct. */
102
+ function selectionKey(mountId: string, path: string): string {
103
+ return JSON.stringify([mountId, path])
104
+ }
105
+
106
+ const selection = useListSelection<FileSelection, string>({
107
+ selected: () => props.selected.map((item) => selectionKey(item.mount_id, item.path)),
108
+ items: () => props.selected,
109
+ getValue: (item) => selectionKey(item.mount_id, item.path),
110
+ })
111
+
112
+ const selectedCount = computed(() => props.selected.length)
113
+ const folderCount = computed(
114
+ () => props.selected.filter((item) => item.kind === 'directory').length,
115
+ )
116
+ const fileCount = computed(() => selectedCount.value - folderCount.value)
117
+ const selectedBytes = computed(() =>
118
+ props.selected.reduce((total, item) => total + (item.size_bytes ?? 0), 0),
119
+ )
120
+
121
+ const confirmText = computed(
122
+ () =>
123
+ props.confirmLabel ||
124
+ `Select ${selectedCount.value} item${selectedCount.value === 1 ? '' : 's'}`,
125
+ )
126
+
127
+ /** Flat locations with their tree depth, preserving the parent-child order. */
128
+ interface MountRow {
129
+ mount: ServerMount
130
+ depth: number
131
+ }
132
+
133
+ const mountRows = computed<MountRow[]>(() => flattenMounts(props.mounts, 0))
134
+
135
+ function flattenMounts(mounts: ServerMount[], depth: number): MountRow[] {
136
+ return mounts.flatMap((mount) => [
137
+ { mount, depth },
138
+ ...flattenMounts(mount.children ?? [], depth + 1),
139
+ ])
140
+ }
141
+
142
+ function toSelection(entry: FileEntry): FileSelection {
143
+ return {
144
+ mount_id: props.mountId,
145
+ path: entry.path,
146
+ name: entry.name,
147
+ kind: entry.kind,
148
+ child_count: entry.child_count ?? null,
149
+ size_bytes: entry.size_bytes ?? null,
150
+ }
151
+ }
152
+
153
+ function toggleEntry(entry: FileEntry): void {
154
+ if (props.loading || props.error || !props.mountId) return
155
+ const key = selectionKey(props.mountId, entry.path)
156
+ const next = selection.isSelected(key)
157
+ ? props.selected.filter((item) => selectionKey(item.mount_id, item.path) !== key)
158
+ : [...props.selected, toSelection(entry)]
159
+ emit('update:selected', next)
160
+ }
161
+
162
+ /**
163
+ * Clicking a row opens a directory; it never selects. Ticking the checkbox
164
+ * selects the whole directory; it never navigates. Two gestures, one row.
165
+ */
166
+ function handleRowClick(entry: FileEntry): void {
167
+ if (props.loading || props.error || !props.mountId || entry.kind !== 'directory') return
168
+ emit('navigate', { mount_id: props.mountId, path: entry.path })
169
+ }
170
+
171
+ function handleMountClick(mount: ServerMount): void {
172
+ if (mount.available === false) return
173
+ emit('navigate', { mount_id: mount.id, path: '' })
174
+ }
175
+
176
+ function removeSelection(item: FileSelection): void {
177
+ const key = selectionKey(item.mount_id, item.path)
178
+ emit(
179
+ 'update:selected',
180
+ props.selected.filter((candidate) => selectionKey(candidate.mount_id, candidate.path) !== key),
181
+ )
182
+ }
183
+
184
+ function handleClose(): void {
185
+ emit('cancel')
186
+ emit('update:modelValue', false)
187
+ }
188
+
189
+ function handleConfirm(): void {
190
+ if (!selectedCount.value) return
191
+ emit('confirm', props.selected)
192
+ emit('update:modelValue', false)
193
+ }
194
+
195
+ function mountIcon(row: MountRow): string {
196
+ return row.depth === 0 ? ICON.disk : ICON.folder
197
+ }
198
+
199
+ function chipNote(item: FileSelection): string {
200
+ if (item.kind !== 'directory') return item.name
201
+ return item.child_count === null || item.child_count === undefined
202
+ ? item.name
203
+ : `${item.name} · whole folder, ${item.child_count} item${item.child_count === 1 ? '' : 's'}`
204
+ }
205
+
206
+ const selectionSummary = computed(() => {
207
+ const parts: string[] = []
208
+ if (fileCount.value) parts.push(`${fileCount.value} file${fileCount.value === 1 ? '' : 's'}`)
209
+ if (folderCount.value)
210
+ parts.push(`${folderCount.value} folder${folderCount.value === 1 ? '' : 's'}`)
211
+ if (selectedBytes.value) parts.push(formatBytes(selectedBytes.value))
212
+ return parts.join(' · ')
213
+ })
214
+ </script>
215
+
216
+ <template>
217
+ <FileBrowserDialogInternal :open="modelValue" :label="title" :size="size" @close="handleClose">
218
+ <template #header>
219
+ <div class="mint-file-browser__heading">
220
+ <h2 class="mint-file-browser__title">{{ title }}</h2>
221
+ <p v-if="contextCode || subtitle" class="mint-file-browser__context">
222
+ <code v-if="contextCode">{{ contextCode }}</code>
223
+ <span v-if="contextCode && subtitle"> · </span>{{ subtitle }}
224
+ </p>
225
+ </div>
226
+ <BaseButton
227
+ v-if="showLocalPicker"
228
+ variant="secondary"
229
+ size="sm"
230
+ class="mint-file-browser__local"
231
+ @click="emit('chooseLocal')"
232
+ >
233
+ Choose local files…
234
+ </BaseButton>
235
+ </template>
236
+
237
+ <div class="mint-file-browser">
238
+ <nav v-if="mountRows.length" class="mint-file-browser__mounts" aria-label="Server mounts">
239
+ <span class="mint-file-browser__mounts-label">Locations</span>
240
+ <div class="mint-file-browser__mount-options">
241
+ <BaseButton
242
+ v-for="row in mountRows"
243
+ :key="row.mount.id"
244
+ size="sm"
245
+ :variant="row.mount.id === mountId ? 'secondary' : 'ghost'"
246
+ class="mint-file-browser__mount"
247
+ :class="{ 'mint-file-browser__mount--active': row.mount.id === mountId }"
248
+ :aria-pressed="row.mount.id === mountId"
249
+ :title="row.mount.label"
250
+ :disabled="row.mount.available === false"
251
+ @click="handleMountClick(row.mount)"
252
+ >
253
+ <svg
254
+ class="mint-file-browser__icon"
255
+ viewBox="0 0 24 24"
256
+ fill="none"
257
+ stroke="currentColor"
258
+ stroke-width="1.8"
259
+ aria-hidden="true"
260
+ >
261
+ <path :d="mountIcon(row)" />
262
+ </svg>
263
+ <span class="mint-file-browser__mount-label"
264
+ >{{ row.depth ? '↳ ' : '' }}{{ row.mount.label }}</span
265
+ >
266
+ <span
267
+ v-if="row.mount.available === false || row.mount.count != null"
268
+ class="mint-file-browser__mount-count"
269
+ >
270
+ {{ row.mount.available === false ? 'offline' : row.mount.count }}
271
+ </span>
272
+ </BaseButton>
273
+ </div>
274
+ </nav>
275
+ <FileBrowserToolbarInternal
276
+ :mount-id="mountId"
277
+ :path="path"
278
+ :parent="parent"
279
+ :breadcrumbs="breadcrumbs"
280
+ :search="search"
281
+ :sort="sort"
282
+ :type-rules="typeRules"
283
+ :loading="loading"
284
+ :error="error"
285
+ @navigate="emit('navigate', $event)"
286
+ @select-folder="emit('selectFolder', $event)"
287
+ @update:search="emit('update:search', $event)"
288
+ @update:sort="emit('update:sort', $event)"
289
+ @refresh="emit('refresh')"
290
+ />
291
+ <FileBrowserListingInternal
292
+ :mount-id="mountId"
293
+ :entries="entries"
294
+ :selected="selected"
295
+ :loading="loading"
296
+ :error="error"
297
+ :dim-non-matching="dimNonMatching"
298
+ :total-count="totalCount"
299
+ :match-count="matchCount"
300
+ :truncated="truncated"
301
+ :search="search"
302
+ :has-mounts="mountRows.length > 0"
303
+ :has-available-mounts="mountRows.some((row) => row.mount.available !== false)"
304
+ @toggle="toggleEntry"
305
+ @open="handleRowClick"
306
+ @refresh="emit('refresh')"
307
+ />
308
+ </div>
309
+
310
+ <template #footer>
311
+ <div class="mint-file-browser__tray">
312
+ <div class="mint-file-browser__tray-main">
313
+ <div class="mint-file-browser__tray-head">
314
+ <span class="mint-file-browser__tray-count">{{ selectedCount }} selected</span>
315
+ <span class="mint-file-browser__tray-summary">{{ selectionSummary }}</span>
316
+ <BaseButton
317
+ v-if="selectedCount"
318
+ variant="ghost"
319
+ size="sm"
320
+ class="mint-file-browser__clear"
321
+ @click="emit('update:selected', [])"
322
+ >Clear</BaseButton
323
+ >
324
+ </div>
325
+ <div v-if="selectedCount" class="mint-file-browser__chips" aria-label="Selected paths">
326
+ <span
327
+ v-for="item in selected"
328
+ :key="selectionKey(item.mount_id, item.path)"
329
+ class="mint-file-browser__chip"
330
+ :title="`${item.mount_id} / ${item.path}`"
331
+ >
332
+ <svg
333
+ class="mint-file-browser__icon"
334
+ viewBox="0 0 24 24"
335
+ fill="none"
336
+ stroke="currentColor"
337
+ stroke-width="1.8"
338
+ aria-hidden="true"
339
+ >
340
+ <path :d="item.kind === 'directory' ? ICON.folder : ICON.file" />
341
+ </svg>
342
+ <span class="mint-file-browser__chip-label">{{ chipNote(item) }}</span>
343
+ <IconButton
344
+ size="sm"
345
+ class="mint-file-browser__chip-remove"
346
+ :label="`Remove ${item.name} from the selection`"
347
+ @click="removeSelection(item)"
348
+ >
349
+ <svg
350
+ viewBox="0 0 24 24"
351
+ fill="none"
352
+ stroke="currentColor"
353
+ stroke-width="2"
354
+ aria-hidden="true"
355
+ >
356
+ <path d="M6 6l12 12M18 6L6 18" />
357
+ </svg>
358
+ </IconButton>
359
+ </span>
360
+ </div>
361
+ <span v-else class="mint-file-browser__selection-hint"
362
+ >Select files or whole folders to continue.</span
363
+ >
364
+ </div>
365
+ <div class="mint-file-browser__actions">
366
+ <BaseButton
367
+ variant="secondary"
368
+ size="sm"
369
+ class="mint-file-browser__cancel"
370
+ @click="handleClose"
371
+ >Cancel</BaseButton
372
+ >
373
+ <BaseButton
374
+ size="sm"
375
+ class="mint-file-browser__confirm"
376
+ :disabled="!selectedCount"
377
+ @click="handleConfirm"
378
+ >{{ confirmText }}</BaseButton
379
+ >
380
+ </div>
381
+ </div>
382
+ </template>
383
+ </FileBrowserDialogInternal>
384
+ </template>
@@ -0,0 +1,44 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import BaseButton from './BaseButton.vue'
4
+ import FilePicker from './FilePicker.vue'
5
+ import type { PickerAdapter, PickerNode } from '../types/filePicker'
6
+
7
+ const open = ref(true)
8
+ const hideRestricted = ref(true)
9
+ const nodes: PickerNode[] = [
10
+ { kind: 'folder', path: 'shared', name: 'Shared data' },
11
+ { kind: 'folder', path: 'restricted', name: 'Restricted data' },
12
+ { kind: 'file', path: 'shared/run.raw', parentPath: 'shared', name: 'run.raw', format: 'raw', bytes: 4096 },
13
+ { kind: 'file', path: 'restricted/private.raw', parentPath: 'restricted', name: 'private.raw', format: 'raw', bytes: 2048 },
14
+ ]
15
+ const adapter: PickerAdapter = {
16
+ listRoot: async () => nodes.filter((node) => !node.parentPath),
17
+ listChildren: async (path) => nodes.filter((node) => node.parentPath === path),
18
+ search: async (query, scope) => nodes.filter((node) =>
19
+ (scope.recursive ? !scope.path || node.path.startsWith(`${scope.path}/`) : (node.parentPath ?? '') === scope.path)
20
+ && node.name.toLowerCase().includes(query.toLowerCase()),
21
+ ),
22
+ }
23
+ // The host owns this rule; its server must also enforce access independently.
24
+ const systemFilter = (node: PickerNode) => !hideRestricted.value || !node.path.startsWith('restricted')
25
+ </script>
26
+
27
+ <template>
28
+ <Story title="Forms/FilePicker">
29
+ <Variant title="Local upload and system filter">
30
+ <div style="padding: 2rem; max-width: 600px;">
31
+ <BaseButton @click="open = true">Choose data</BaseButton>
32
+ <FilePicker
33
+ v-model:open="open"
34
+ :adapter="adapter"
35
+ :sources="['localFile', 'server']"
36
+ :system-filter="systemFilter"
37
+ />
38
+ </div>
39
+ <template #controls>
40
+ <HstCheckbox v-model="hideRestricted" title="Hide restricted server data" />
41
+ </template>
42
+ </Variant>
43
+ </Story>
44
+ </template>