@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,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,
@@ -0,0 +1,39 @@
1
+ import type { FileEntry } from '../types/fileBrowserTypes'
2
+
3
+ export const FILE_BROWSER_ICONS = {
4
+ disk: 'M4 5h16v14H4zM8 9h.01M4 12h16',
5
+ folder: 'M4 7h6l2 2h8v10H4z',
6
+ file: 'M14 3v5h5M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z',
7
+ }
8
+
9
+ export function formatCount(value: number): string {
10
+ // Thin spaces group digits without the comma/period ambiguity across locales.
11
+ return value.toLocaleString('en-US').replace(/,/g, ' ')
12
+ }
13
+
14
+ export function formatSize(entry: FileEntry): string {
15
+ if (entry.kind === 'directory') {
16
+ return entry.child_count === null || entry.child_count === undefined
17
+ ? '—'
18
+ : `${entry.child_count} item${entry.child_count === 1 ? '' : 's'}`
19
+ }
20
+ return formatBytes(entry.size_bytes ?? null)
21
+ }
22
+
23
+ export function formatBytes(bytes: number | null): string {
24
+ if (bytes === null) return '—'
25
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
26
+ let value = bytes
27
+ let unit = 0
28
+ while (value >= 1024 && unit < units.length - 1) {
29
+ value /= 1024
30
+ unit += 1
31
+ }
32
+ const rounded = value >= 100 || unit === 0 ? Math.round(value) : Number(value.toFixed(1))
33
+ return `${rounded} ${units[unit]}`
34
+ }
35
+
36
+ export function entryType(entry: FileEntry): string {
37
+ if (entry.kind === 'directory') return 'Folder'
38
+ return entry.extension ? entry.extension.replace('.', '').toUpperCase() : 'File'
39
+ }
@@ -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'
@@ -142,7 +143,11 @@ export { default as SectionCard } from './SectionCard.vue'
142
143
  // Experiment / analysis components
143
144
  export { default as ExperimentSelectorModal } from './ExperimentSelectorModal.vue'
144
145
  export { default as ArtifactSelectorModal } from './ArtifactSelectorModal.vue'
146
+ /** Modal picker over the read-only server mounts, recording paths rather than copying data. */
147
+ export { default as FileBrowserModal } from './FileBrowserModal.vue'
145
148
  export { default as GeneratedPathInput } from './GeneratedPathInput.vue'
146
149
  export { default as ArtifactSaveDialog } from './ArtifactSaveDialog.vue'
147
150
  export { default as ExperimentPopover } from './ExperimentPopover.vue'
148
151
  export { default as FitPanel } from './FitPanel.vue'
152
+
153
+ export { default as FilePicker } from './FilePicker.vue'
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
3
+ import type { ModalSize } from '../../types'
4
+ import { useFocusTrap } from '../../composables/useFocusTrap'
5
+ import IconButton from '../IconButton.vue'
6
+
7
+ const props = defineProps<{ open: boolean; label: string; size: ModalSize; variant?: 'picker' }>()
8
+ const emit = defineEmits<{ close: [] }>()
9
+ const containerRef = shallowRef<HTMLElement | null>(null)
10
+ const active = shallowRef(false)
11
+ useFocusTrap({ containerRef, active })
12
+
13
+ // Activate after mounting so an initially open picker traps focus too.
14
+ onMounted(() => {
15
+ active.value = props.open
16
+ })
17
+ watch(
18
+ () => props.open,
19
+ (open) => {
20
+ active.value = open
21
+ },
22
+ )
23
+ watch(
24
+ active,
25
+ (open, _, onCleanup) => {
26
+ if (!open) return
27
+ const overflow = document.body.style.overflow
28
+ document.body.style.overflow = 'hidden'
29
+ onCleanup(() => {
30
+ document.body.style.overflow = overflow
31
+ })
32
+ },
33
+ { flush: 'sync' },
34
+ )
35
+ onBeforeUnmount(() => {
36
+ active.value = false
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <Teleport to="body">
42
+ <div v-if="open" class="mint-file-browser-overlay">
43
+ <section
44
+ ref="containerRef"
45
+ class="mint-file-browser-dialog"
46
+ :class="[
47
+ `mint-file-browser-dialog--${size}`,
48
+ variant === 'picker' ? 'mint-picker-dialog' : '',
49
+ ]"
50
+ role="dialog"
51
+ aria-modal="true"
52
+ :aria-label="label"
53
+ tabindex="-1"
54
+ @keydown.esc.stop.prevent="emit('close')"
55
+ >
56
+ <header class="mint-file-browser-dialog__header">
57
+ <slot name="header" />
58
+ <IconButton size="sm" label="Close modal" @click="emit('close')">
59
+ <svg
60
+ viewBox="0 0 24 24"
61
+ fill="none"
62
+ stroke="currentColor"
63
+ stroke-width="2"
64
+ aria-hidden="true"
65
+ >
66
+ <path d="M6 6l12 12M18 6L6 18" />
67
+ </svg>
68
+ </IconButton>
69
+ </header>
70
+ <slot />
71
+ <footer class="mint-file-browser-dialog__footer"><slot name="footer" /></footer>
72
+ </section>
73
+ </div>
74
+ </Teleport>
75
+ </template>
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { FileEntry, FileSelection } from '../../types/fileBrowserTypes'
4
+ import {
5
+ FILE_BROWSER_ICONS as ICON,
6
+ formatCount,
7
+ formatSize,
8
+ entryType,
9
+ } from '../fileBrowserPresentation'
10
+ import EmptyState from '../EmptyState.vue'
11
+ import BaseButton from '../BaseButton.vue'
12
+ import IconButton from '../IconButton.vue'
13
+ import Skeleton from '../Skeleton.vue'
14
+ const props = defineProps<{
15
+ mountId: string
16
+ entries: FileEntry[]
17
+ selected: FileSelection[]
18
+ loading: boolean
19
+ error?: string | null
20
+ dimNonMatching: boolean
21
+ totalCount: number | null
22
+ matchCount: number | null
23
+ truncated: boolean
24
+ hasMounts: boolean
25
+ hasAvailableMounts: boolean
26
+ search: string
27
+ }>()
28
+ const emit = defineEmits<{ toggle: [entry: FileEntry]; open: [entry: FileEntry]; refresh: [] }>()
29
+ const emptyTitle = computed(() =>
30
+ !props.hasMounts
31
+ ? 'No server mounts configured'
32
+ : !props.hasAvailableMounts
33
+ ? 'Server mounts are offline'
34
+ : props.search
35
+ ? 'No matching files or folders'
36
+ : 'This folder is empty',
37
+ )
38
+ const emptyDescription = computed(() =>
39
+ !props.hasMounts
40
+ ? 'Ask your administrator to configure a read-only data mount.'
41
+ : !props.hasAvailableMounts
42
+ ? 'Refresh when the data mount is available again.'
43
+ : props.search
44
+ ? 'Try a different search or clear the search field.'
45
+ : 'Choose another folder from the mount list or breadcrumbs.',
46
+ )
47
+ function isSelected(entry: FileEntry) {
48
+ return props.selected.some((item) => item.mount_id === props.mountId && item.path === entry.path)
49
+ }
50
+ function isDimmed(entry: FileEntry) {
51
+ return props.dimNonMatching && entry.matches_type_rules === false
52
+ }
53
+ function entryIcon(entry: FileEntry) {
54
+ return entry.kind === 'directory' ? ICON.folder : ICON.file
55
+ }
56
+ function handleRowClick(entry: FileEntry) {
57
+ if (!props.loading && !props.error && props.mountId && entry.kind === 'directory')
58
+ emit('open', entry)
59
+ }
60
+ function toggleEntry(entry: FileEntry) {
61
+ if (!props.loading && !props.error && props.mountId) emit('toggle', entry)
62
+ }
63
+ function folderNote(entry: FileEntry) {
64
+ if (entry.kind !== 'directory' || !isSelected(entry)) return ''
65
+ return entry.child_count == null
66
+ ? 'whole folder'
67
+ : `whole folder, ${entry.child_count} item${entry.child_count === 1 ? '' : 's'}`
68
+ }
69
+ const statusLine = computed(() => {
70
+ if (props.loading) return 'Loading folder…'
71
+ if (props.error || !props.mountId) return ''
72
+ const total = props.totalCount ?? props.entries.length
73
+ const parts = [`${formatCount(total)} item${total === 1 ? '' : 's'}`]
74
+ if (props.entries.length !== total) parts.push(`${formatCount(props.entries.length)} shown`)
75
+ if (props.matchCount !== null && props.matchCount !== total)
76
+ parts.push(`${formatCount(props.matchCount)} match the type rules`)
77
+ if (props.truncated) parts.push('listing capped')
78
+ return parts.join(' · ')
79
+ })
80
+ </script>
81
+ <template>
82
+ <div class="mint-file-browser__listing" :aria-busy="loading">
83
+ <div class="mint-file-browser__head">
84
+ <span class="mint-file-browser__col-check" />
85
+ <span class="mint-file-browser__col-icon" />
86
+ <span class="mint-file-browser__col-name">Name</span>
87
+ <span class="mint-file-browser__col-size">Size</span>
88
+ <span class="mint-file-browser__col-type">Type</span>
89
+ <span class="mint-file-browser__col-open" />
90
+ </div>
91
+
92
+ <div v-if="loading" class="mint-file-browser__skeleton">
93
+ <div v-for="n in 6" :key="n" class="mint-file-browser__skeleton-row">
94
+ <Skeleton width="60%" height="14px" />
95
+ <Skeleton width="60px" height="10px" />
96
+ </div>
97
+ </div>
98
+
99
+ <div v-else-if="error" class="mint-file-browser__error" role="alert">
100
+ <span>{{ error }}</span>
101
+ <BaseButton
102
+ variant="secondary"
103
+ size="sm"
104
+ class="mint-file-browser__retry"
105
+ aria-label="Retry folder listing"
106
+ @click="emit('refresh')"
107
+ >
108
+ Try again
109
+ </BaseButton>
110
+ </div>
111
+
112
+ <EmptyState
113
+ v-else-if="!entries.length"
114
+ :title="emptyTitle"
115
+ :description="emptyDescription"
116
+ size="sm"
117
+ />
118
+
119
+ <div v-else class="mint-file-browser__rows">
120
+ <div
121
+ v-for="entry in entries"
122
+ :key="entry.path"
123
+ class="mint-file-browser__row"
124
+ :class="{
125
+ 'mint-file-browser__row--selected': isSelected(entry),
126
+ 'mint-file-browser__row--dimmed': isDimmed(entry),
127
+ 'mint-file-browser__row--dir': entry.kind === 'directory',
128
+ }"
129
+ @click="handleRowClick(entry)"
130
+ >
131
+ <input
132
+ class="mint-file-browser__checkbox"
133
+ type="checkbox"
134
+ :checked="isSelected(entry)"
135
+ :title="entry.kind === 'directory' ? 'Select the whole folder' : 'Select this file'"
136
+ :aria-label="
137
+ entry.kind === 'directory'
138
+ ? `Select the whole ${entry.name} folder`
139
+ : `Select ${entry.name}`
140
+ "
141
+ @click.stop
142
+ @change="toggleEntry(entry)"
143
+ />
144
+ <span
145
+ class="mint-file-browser__row-icon"
146
+ :class="`mint-file-browser__row-icon--${entry.kind}`"
147
+ >
148
+ <svg
149
+ class="mint-file-browser__icon"
150
+ viewBox="0 0 24 24"
151
+ fill="none"
152
+ stroke="currentColor"
153
+ stroke-width="1.8"
154
+ stroke-linecap="round"
155
+ stroke-linejoin="round"
156
+ >
157
+ <path :d="entryIcon(entry)" />
158
+ </svg>
159
+ </span>
160
+ <span class="mint-file-browser__name-cell">
161
+ <span class="mint-file-browser__name" :title="entry.name">{{ entry.name }}</span>
162
+ <span v-if="folderNote(entry)" class="mint-file-browser__note-pill">Whole folder</span>
163
+ </span>
164
+ <span class="mint-file-browser__size">{{ formatSize(entry) }}</span>
165
+ <span class="mint-file-browser__type">{{ entryType(entry) }}</span>
166
+ <!-- The chevron is the keyboard route into a folder: the row
167
+ itself is a div, so this button is what Tab can reach. -->
168
+ <IconButton
169
+ v-if="entry.kind === 'directory'"
170
+ size="sm"
171
+ class="mint-file-browser__open"
172
+ :label="`Open ${entry.name}`"
173
+ @click.stop="handleRowClick(entry)"
174
+ >
175
+ <svg
176
+ class="mint-file-browser__icon"
177
+ viewBox="0 0 24 24"
178
+ fill="none"
179
+ stroke="currentColor"
180
+ stroke-width="2.5"
181
+ stroke-linecap="round"
182
+ stroke-linejoin="round"
183
+ >
184
+ <path d="M9 6l6 6-6 6" />
185
+ </svg>
186
+ </IconButton>
187
+ <span v-else class="mint-file-browser__open" aria-hidden="true" />
188
+ </div>
189
+ </div>
190
+
191
+ <div class="mint-file-browser__status" role="status" aria-live="polite">
192
+ <span>{{ statusLine }}</span>
193
+ <div class="mint-file-browser__status-spacer" />
194
+ <span class="mint-file-browser__legend">Read-only · Select paths, not copies</span>
195
+ </div>
196
+ </div>
197
+ </template>