@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,314 @@
1
+ import {
2
+ computed,
3
+ getCurrentScope,
4
+ onScopeDispose,
5
+ ref,
6
+ shallowRef,
7
+ toValue,
8
+ watch,
9
+ type ComputedRef,
10
+ type MaybeRefOrGetter,
11
+ type Ref,
12
+ } from 'vue'
13
+
14
+ import type {
15
+ FileBrowserSortKey,
16
+ FileDirectoryListing,
17
+ FileEntry,
18
+ FilePathCrumb,
19
+ FileSelection,
20
+ ServerMount,
21
+ } from '../types/fileBrowserTypes'
22
+ import { useApi } from './useApi'
23
+ import { getInjectedPlatformContext } from './platformContextHelpers'
24
+ import { useRequestSyncState } from './useRequestSyncState'
25
+
26
+ export interface UseFileBrowserOptions {
27
+ /** Extensions the caller cares about; entries are flagged, never removed. */
28
+ typeRules?: MaybeRefOrGetter<string[] | undefined>
29
+ /** Mount to open first; defaults to the first available one. */
30
+ initialMountId?: MaybeRefOrGetter<string | undefined>
31
+ /** Override the platform API base URL (standalone plugin dev). */
32
+ apiBaseUrl?: string
33
+ }
34
+
35
+ export interface UseFileBrowserReturn {
36
+ /** Every configured mount, offline ones included. */
37
+ mounts: Ref<ServerMount[]>
38
+ /** Mount the current listing belongs to. */
39
+ mountId: Ref<string>
40
+ /** Mount-relative path of the current listing. */
41
+ path: Ref<string>
42
+ /** Parent path, or null at a mount root. */
43
+ parent: Ref<string | null>
44
+ entries: Ref<FileEntry[]>
45
+ breadcrumbs: Ref<FilePathCrumb[]>
46
+ /** Directory size before any search filter. */
47
+ totalCount: Ref<number | null>
48
+ /** How many entries satisfy the type rules. */
49
+ matchCount: Ref<number | null>
50
+ /** How many rows this listing carries, after search and any cap. */
51
+ shownCount: Ref<number | null>
52
+ /** True when the server capped the listing. */
53
+ truncated: Ref<boolean>
54
+ search: Ref<string>
55
+ sort: Ref<FileBrowserSortKey>
56
+ /** Controlled selection, ready to bind to FileBrowserModal. */
57
+ selected: Ref<FileSelection[]>
58
+ isLoading: ComputedRef<boolean>
59
+ error: Ref<string | null>
60
+ /** Load the mount list and open the first available mount. */
61
+ init: () => Promise<void>
62
+ /** Open a directory, or a mount root when path is empty. */
63
+ navigate: (location: { mount_id: string; path: string }) => Promise<void>
64
+ /** Re-read the current directory. */
65
+ refresh: () => Promise<void>
66
+ /** Take the directory currently being viewed as the input. */
67
+ selectCurrentFolder: () => void
68
+ clear: () => void
69
+ }
70
+
71
+ /**
72
+ * Drives FileBrowserModal against the platform's `/api/filesystem` routes.
73
+ *
74
+ * Requires the `filesystem.browse` permission and a deployment that declares
75
+ * mounts; with none configured the routes are absent and `mounts` stays empty.
76
+ */
77
+ export function useFileBrowser(options: UseFileBrowserOptions = {}): UseFileBrowserReturn {
78
+ const injectedContext = getInjectedPlatformContext()
79
+ const api = useApi({ baseUrl: options.apiBaseUrl ?? injectedContext?.platformApiUrl })
80
+ const sync = useRequestSyncState('Could not read the server mounts.')
81
+
82
+ const mounts = ref<ServerMount[]>([])
83
+ const mountId = shallowRef('')
84
+ const path = shallowRef('')
85
+ const parent = ref<string | null>(null)
86
+ const entries = ref<FileEntry[]>([])
87
+ const breadcrumbs = ref<FilePathCrumb[]>([])
88
+ const totalCount = ref<number | null>(null)
89
+ const matchCount = ref<number | null>(null)
90
+ const shownCount = ref<number | null>(null)
91
+ const truncated = ref(false)
92
+ const search = shallowRef('')
93
+ const sort = shallowRef<FileBrowserSortKey>('name')
94
+ const selected = ref<FileSelection[]>([])
95
+
96
+ const mountsLoading = shallowRef(false)
97
+ const listingLoading = shallowRef(false)
98
+ const listingReady = shallowRef(false)
99
+ const isLoading = computed(() => mountsLoading.value || listingLoading.value)
100
+ let listToken = 0
101
+ let mountsToken = 0
102
+ let disposed = false
103
+ let listingController: AbortController | undefined
104
+ let mountsController: AbortController | undefined
105
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
106
+
107
+ function resetListing(): void {
108
+ listingReady.value = false
109
+ entries.value = []
110
+ breadcrumbs.value = []
111
+ parent.value = null
112
+ totalCount.value = null
113
+ matchCount.value = null
114
+ shownCount.value = null
115
+ truncated.value = false
116
+ }
117
+
118
+ function typeRuleParams(): string {
119
+ const rules = toValue(options.typeRules) ?? []
120
+ return rules.map((rule) => `&type_rules=${encodeURIComponent(rule)}`).join('')
121
+ }
122
+
123
+ /** Prefer the API's own `detail` over axios's "Request failed with status N". */
124
+ function describe(cause: unknown, fallback: string): string {
125
+ const detail = (cause as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail
126
+ return typeof detail === 'string' && detail ? detail : sync.readErrorMessage(cause, fallback)
127
+ }
128
+
129
+ function fail(cause: unknown, fallback: string): void {
130
+ sync.setError(new Error(describe(cause, fallback)), fallback)
131
+ }
132
+
133
+ function statusOf(cause: unknown): number | undefined {
134
+ return (cause as { response?: { status?: number } })?.response?.status
135
+ }
136
+
137
+ async function loadMounts(): Promise<boolean> {
138
+ const token = ++mountsToken
139
+ mountsController?.abort()
140
+ mountsController = new AbortController()
141
+ mountsLoading.value = true
142
+ try {
143
+ const data = await api.get<{ mounts: ServerMount[] }>('/filesystem/mounts', {
144
+ signal: mountsController.signal,
145
+ })
146
+ if (disposed || token !== mountsToken) return false
147
+ mounts.value = data.mounts ?? []
148
+ sync.clearError()
149
+ sync.markLoaded()
150
+ return true
151
+ } catch (cause) {
152
+ if (disposed || token !== mountsToken) return false
153
+ // The platform only registers these routes once mounts are configured, so
154
+ // a 404 means "none declared" — an empty sidebar, not a failure.
155
+ if (statusOf(cause) === 404) {
156
+ mounts.value = []
157
+ sync.clearError()
158
+ sync.markLoaded()
159
+ return true
160
+ }
161
+ mounts.value = []
162
+ fail(cause, 'Could not read the server mounts.')
163
+ return false
164
+ } finally {
165
+ if (token === mountsToken) mountsLoading.value = false
166
+ }
167
+ }
168
+
169
+ async function navigate(location: { mount_id: string; path: string }): Promise<void> {
170
+ if (disposed || !location.mount_id) return
171
+ clearTimeout(searchTimer)
172
+ const token = ++listToken
173
+ listingController?.abort()
174
+ listingController = new AbortController()
175
+ listingLoading.value = true
176
+ mountId.value = location.mount_id
177
+ path.value = location.path
178
+ resetListing()
179
+ sync.clearError()
180
+ try {
181
+ const query =
182
+ `mount_id=${encodeURIComponent(location.mount_id)}` +
183
+ `&path=${encodeURIComponent(location.path)}` +
184
+ `&sort=${sort.value}` +
185
+ (search.value ? `&search=${encodeURIComponent(search.value)}` : '') +
186
+ typeRuleParams()
187
+ const listing = await api.get<FileDirectoryListing>(`/filesystem/browse?${query}`, {
188
+ signal: listingController.signal,
189
+ })
190
+ if (disposed || token !== listToken) return
191
+ mountId.value = listing.mount_id
192
+ path.value = listing.path
193
+ parent.value = listing.parent ?? null
194
+ entries.value = listing.entries ?? []
195
+ breadcrumbs.value = listing.breadcrumbs ?? []
196
+ totalCount.value = listing.total_count ?? null
197
+ matchCount.value = listing.match_count ?? null
198
+ shownCount.value = listing.shown_count ?? listing.entries?.length ?? null
199
+ truncated.value = listing.truncated ?? false
200
+ listingReady.value = true
201
+ sync.clearError()
202
+ sync.markLoaded()
203
+ } catch (cause) {
204
+ if (disposed || token !== listToken) return
205
+ entries.value = []
206
+ fail(cause, 'Could not read that folder.')
207
+ } finally {
208
+ if (token === listToken) listingLoading.value = false
209
+ }
210
+ }
211
+
212
+ async function init(): Promise<void> {
213
+ if (disposed) return
214
+ const navigationToken = listToken
215
+ if (!(await loadMounts()) || disposed || navigationToken !== listToken) return
216
+ const preferred = toValue(options.initialMountId)
217
+ // An offline mount is not navigable, so a remembered preference for one
218
+ // falls through to the first reachable mount instead of opening on an error.
219
+ const reachable = (mount: ServerMount): boolean => mount.available !== false
220
+ const target =
221
+ mounts.value.find((mount) => mount.id === preferred && reachable(mount)) ??
222
+ mounts.value.find(reachable)
223
+ if (target) await navigate({ mount_id: target.id, path: '' })
224
+ else {
225
+ mountId.value = ''
226
+ path.value = ''
227
+ resetListing()
228
+ }
229
+ }
230
+
231
+ async function refresh(): Promise<void> {
232
+ if (disposed) return
233
+ if (mountId.value) await navigate({ mount_id: mountId.value, path: path.value })
234
+ else await init()
235
+ }
236
+
237
+ function selectCurrentFolder(): void {
238
+ if (!mountId.value || !listingReady.value || isLoading.value || sync.error.value) return
239
+ const crumb = breadcrumbs.value[breadcrumbs.value.length - 1]
240
+ const key = JSON.stringify([mountId.value, path.value])
241
+ if (selected.value.some((item) => JSON.stringify([item.mount_id, item.path]) === key)) return
242
+ selected.value = [
243
+ ...selected.value,
244
+ {
245
+ mount_id: mountId.value,
246
+ path: path.value,
247
+ name: crumb?.label ?? path.value,
248
+ kind: 'directory',
249
+ child_count: totalCount.value,
250
+ },
251
+ ]
252
+ }
253
+
254
+ function clear(): void {
255
+ selected.value = []
256
+ }
257
+
258
+ // Both controls are server-side, so they only take effect on a re-fetch.
259
+ // Without these watchers the search box and sort button look live but are
260
+ // inert until the user happens to navigate.
261
+ watch(search, () => {
262
+ if (!mountId.value) return
263
+ clearTimeout(searchTimer)
264
+ // Debounced: search changes on every keystroke.
265
+ searchTimer = setTimeout(() => {
266
+ void refresh()
267
+ }, 250)
268
+ })
269
+
270
+ watch(sort, () => {
271
+ if (mountId.value) void refresh()
272
+ })
273
+
274
+ watch(
275
+ () => toValue(options.typeRules),
276
+ () => {
277
+ if (mountId.value) void refresh()
278
+ },
279
+ { deep: true },
280
+ )
281
+
282
+ if (getCurrentScope())
283
+ onScopeDispose(() => {
284
+ disposed = true
285
+ ++listToken
286
+ ++mountsToken
287
+ clearTimeout(searchTimer)
288
+ listingController?.abort()
289
+ mountsController?.abort()
290
+ })
291
+
292
+ return {
293
+ mounts,
294
+ mountId,
295
+ path,
296
+ parent,
297
+ entries,
298
+ breadcrumbs,
299
+ totalCount,
300
+ matchCount,
301
+ shownCount,
302
+ truncated,
303
+ search,
304
+ sort,
305
+ selected,
306
+ isLoading,
307
+ error: sync.error,
308
+ init,
309
+ navigate,
310
+ refresh,
311
+ selectCurrentFolder,
312
+ clear,
313
+ }
314
+ }
@@ -0,0 +1,366 @@
1
+ import { computed, nextTick, onScopeDispose, ref, shallowRef, toRaw, watch } from 'vue'
2
+ import type {
3
+ PickerAdapter,
4
+ PickerCapabilities,
5
+ PickerNode,
6
+ PickerSelection,
7
+ PickerSelectionMode,
8
+ PickerSource,
9
+ } from '../types/filePicker'
10
+ import { usePickerNavigation } from './filePicker/usePickerNavigation'
11
+ import { usePickerSelection } from './filePicker/usePickerSelection'
12
+ import { localFilesAdapter } from './filePicker/localFiles'
13
+
14
+ interface PickerStateProps {
15
+ open: boolean
16
+ adapter: PickerAdapter
17
+ selectionMode: PickerSelectionMode
18
+ sources: PickerSource[]
19
+ capabilities: PickerCapabilities
20
+ initialPath: string
21
+ initialSelection: PickerSelection | null
22
+ systemFilter?: (node: PickerNode, source: PickerSource) => boolean
23
+ }
24
+ type PickerEmit = {
25
+ (event: 'update:open', value: boolean): void
26
+ (event: 'select', value: PickerSelection): void
27
+ (event: 'cancel'): void
28
+ }
29
+
30
+ /** Internal orchestration; public consumers use FilePicker's props and events. */
31
+ export function useFilePicker(props: PickerStateProps, emit: PickerEmit) {
32
+ const sourceNames: Record<PickerSource, string> = {
33
+ server: 'Server',
34
+ localFile: 'Local files',
35
+ }
36
+ const sourceIcons: Record<PickerSource, string> = {
37
+ server: 'M4 6h16v5H4zM4 13h16v5H4zM7 8.5h.01M7 15.5h.01',
38
+ localFile: 'M4 17h16M6 5h12v9H6z',
39
+ }
40
+ const allowedSources = computed(() => [...new Set(props.sources)])
41
+ const source = shallowRef<PickerSource>(
42
+ allowedSources.value.includes(props.initialSelection?.source as PickerSource)
43
+ ? props.initialSelection!.source
44
+ : (allowedSources.value[0] ?? 'server'),
45
+ )
46
+ const query = shallowRef('')
47
+ const recursive = shallowRef(false)
48
+ const skipBlanks = shallowRef(false)
49
+ const reviewing = shallowRef(false)
50
+ const localFiles = shallowRef<File[]>([])
51
+ const localPaths = new WeakMap<File, string>()
52
+ const localAdapter = computed(() =>
53
+ localFilesAdapter(localFiles.value, localFiles.value.map((file) => localPaths.get(toRaw(file)))),
54
+ )
55
+ const nav = usePickerNavigation(
56
+ () => (source.value === 'localFile' ? localAdapter.value : props.adapter),
57
+ () => ({ source: source.value }),
58
+ (node) => props.systemFilter?.(node, source.value) !== false,
59
+ )
60
+ const capability = computed(() => props.capabilities[source.value])
61
+ const selection = usePickerSelection(
62
+ nav,
63
+ () => props.selectionMode,
64
+ () => capability.value,
65
+ () => skipBlanks.value,
66
+ )
67
+ const { path, trail, loading, error, rows } = nav
68
+ const { roots, chosen, focused, resolving, resolutionError } = selection
69
+ const previewNode = computed(() => {
70
+ const node = focused.value
71
+ if (!node || node.kind !== 'folder' || !selection.selected(node) || resolving.value) return node
72
+ const files = chosen.value.files.filter((file) =>
73
+ selection.ancestorSelected(file, new Set([node.path])),
74
+ )
75
+ const formats: Record<string, number> = {}
76
+ files.forEach((file) => {
77
+ formats[file.format] = (formats[file.format] ?? 0) + 1
78
+ })
79
+ return {
80
+ ...node,
81
+ bytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0),
82
+ fileCount: files.length,
83
+ formats,
84
+ }
85
+ })
86
+
87
+ const searchArea = ref<HTMLElement | null>(null)
88
+ const confirmLabel = computed(
89
+ () =>
90
+ ({
91
+ 'single-file': 'Use file',
92
+ 'multi-file': 'Use files',
93
+ folder: 'Use folder',
94
+ 'folder+files': 'Use selection',
95
+ })[props.selectionMode],
96
+ )
97
+ const folderCount = computed(() => roots.value.filter((node) => node.kind === 'folder').length)
98
+ const canConfirm = computed(
99
+ () =>
100
+ allowedSources.value.includes(source.value) &&
101
+ roots.value.length > 0 &&
102
+ chosen.value.files.length > 0 &&
103
+ !resolving.value &&
104
+ !loading.value &&
105
+ !error.value &&
106
+ !resolutionError.value,
107
+ )
108
+ let lastConfirmed: PickerSelection | null = null
109
+ let initialized = false
110
+ let timer: ReturnType<typeof setTimeout> | undefined
111
+ let openGeneration = 0
112
+ const remembered = new Map<
113
+ PickerSource,
114
+ { path: string; trail: { path: string; label: string }[] }
115
+ >()
116
+
117
+ const knownFolders = new Map<PickerSource, PickerNode[]>()
118
+ function rememberFolders() {
119
+ knownFolders.set(
120
+ source.value,
121
+ [...nav.nodes.value.values()].filter((node) => node.kind === 'folder'),
122
+ )
123
+ }
124
+ function currentSeed() {
125
+ return props.initialSelection ?? lastConfirmed
126
+ }
127
+ async function restore() {
128
+ const token = ++openGeneration
129
+ const seed = currentSeed()
130
+ nav.register(knownFolders.get(source.value) ?? [])
131
+ selection.reset(seed?.source === source.value ? seed : null)
132
+ reviewing.value = false
133
+ if (!allowedSources.value.includes(source.value)) return
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
+ }
140
+ await nav.load(query.value, recursive.value)
141
+ if (token !== openGeneration || !props.open) return
142
+ if (seed?.source === 'localFile' && source.value === 'localFile') {
143
+ // Restored local File objects are already the confirmed subset.
144
+ await selectLocalFiles()
145
+ }
146
+ if (selection.keys.value.size) await selection.resolve()
147
+ }
148
+ watch(
149
+ () => props.open,
150
+ (open) => {
151
+ clearTimeout(timer)
152
+ if (open) {
153
+ if (!initialized) {
154
+ path.value = props.initialPath
155
+ if (path.value) trail.value = [{ path: path.value, label: 'Current folder' }]
156
+ initialized = true
157
+ }
158
+ const seed = currentSeed()
159
+ if (seed && allowedSources.value.includes(seed.source) && seed.source !== source.value)
160
+ void switchSource(seed.source)
161
+ else void restore()
162
+ } else {
163
+ ++openGeneration
164
+ rememberFolders()
165
+ selection.reset()
166
+ nav.clear()
167
+ }
168
+ },
169
+ { immediate: true },
170
+ )
171
+ watch([query, recursive], () => {
172
+ clearTimeout(timer)
173
+ if (props.open)
174
+ timer = setTimeout(() => {
175
+ void nav.load(query.value, recursive.value)
176
+ }, 200)
177
+ })
178
+ watch(
179
+ () => [...selection.keys.value],
180
+ () => {
181
+ if (props.open) void selection.resolve()
182
+ },
183
+ )
184
+ watch(
185
+ () => props.selectionMode,
186
+ () => {
187
+ if (props.open) void restore()
188
+ },
189
+ )
190
+ watch(allowedSources, (sources) => {
191
+ if (!sources.includes(source.value) && sources[0]) void switchSource(sources[0])
192
+ })
193
+ onScopeDispose(() => {
194
+ ++openGeneration
195
+ clearTimeout(timer)
196
+ selection.reset()
197
+ })
198
+
199
+ async function switchSource(next: PickerSource) {
200
+ if (next === source.value || !allowedSources.value.includes(next)) return
201
+ rememberFolders()
202
+ remembered.set(source.value, { path: path.value, trail: [...trail.value] })
203
+ clearTimeout(timer)
204
+ nav.clear()
205
+ source.value = next
206
+ query.value = ''
207
+ path.value = remembered.get(next)?.path ?? ''
208
+ trail.value = remembered.get(next)?.trail ?? []
209
+ await restore()
210
+ }
211
+ function selectNode(node: PickerNode, event: MouseEvent | KeyboardEvent) {
212
+ // The second click in a double-click only opens the row; it must not toggle twice.
213
+ if (event.type === 'click' && event.detail > 1) return
214
+ selection.select(node, {
215
+ ...{ metaKey: event.metaKey, ctrlKey: event.ctrlKey, shiftKey: event.shiftKey },
216
+ toggle: event.type === 'click' || (event instanceof KeyboardEvent && event.key === ' '),
217
+ })
218
+ }
219
+ async function enter(node: PickerNode) {
220
+ const token = openGeneration
221
+ if (selection.exclusion(node)) return
222
+ if (node.kind === 'file') {
223
+ if (props.selectionMode === 'single-file') {
224
+ selection.select(node)
225
+ await nextTick()
226
+ await selection.resolve()
227
+ if (token === openGeneration && props.open) confirm()
228
+ }
229
+ return
230
+ }
231
+ clearTimeout(timer)
232
+ query.value = ''
233
+ nav.enter(node)
234
+ await nav.load()
235
+ }
236
+ async function up(target?: string) {
237
+ query.value = ''
238
+ clearTimeout(timer)
239
+ nav.up(target)
240
+ await nav.load()
241
+ }
242
+ async function refresh() {
243
+ nav.invalidate()
244
+ await nav.load(query.value, recursive.value, true)
245
+ if (selection.keys.value.size) await selection.resolve()
246
+ }
247
+ function selectParent(node: PickerNode) {
248
+ if (!node.parentPath) return
249
+ const parent = nav.nodes.value.get(node.parentPath) ?? {
250
+ kind: 'folder' as const,
251
+ path: node.parentPath,
252
+ name: node.parentName ?? 'Containing folder',
253
+ }
254
+ nav.register([parent])
255
+ selection.select(parent)
256
+ }
257
+ async function chooseLocal(files: File[], _rootName?: string, relativePaths?: string[]) {
258
+ const token = openGeneration
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)
265
+ nav.clear()
266
+ selection.reset()
267
+ path.value = ''
268
+ trail.value = []
269
+ query.value = ''
270
+ await nav.load()
271
+ if (token !== openGeneration || !props.open || source.value !== 'localFile') return
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))
282
+ for (const node of props.selectionMode === 'single-file' || props.selectionMode === 'folder'
283
+ ? candidates.slice(0, 1)
284
+ : candidates)
285
+ selection.select(node, { ctrlKey: true })
286
+ }
287
+ function cancel() {
288
+ emit('cancel')
289
+ emit('update:open', false)
290
+ }
291
+ function confirm() {
292
+ if (!canConfirm.value) return
293
+ const result: PickerSelection =
294
+ source.value === 'localFile'
295
+ ? {
296
+ source: 'localFile',
297
+ files: chosen.value.files.flatMap((node) => (node.file ? [toRaw(node.file)] : [])),
298
+ }
299
+ : {
300
+ source: 'server',
301
+ folders: roots.value.filter((node) => node.kind === 'folder').map((node) => node.path),
302
+ files: [...chosen.value.files],
303
+ excluded: [...chosen.value.excluded],
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
+ }
313
+ lastConfirmed = result
314
+ emit('select', result)
315
+ emit('update:open', false)
316
+ }
317
+ function keyboard(event: KeyboardEvent) {
318
+ if (
319
+ event.key !== '/' ||
320
+ event.target instanceof HTMLInputElement ||
321
+ event.target instanceof HTMLTextAreaElement
322
+ )
323
+ return
324
+ event.preventDefault()
325
+ searchArea.value?.querySelector('input')?.focus()
326
+ }
327
+
328
+ return {
329
+ previewNode,
330
+ sourceNames,
331
+ sourceIcons,
332
+ allowedSources,
333
+ source,
334
+ query,
335
+ recursive,
336
+ skipBlanks,
337
+ reviewing,
338
+ nav,
339
+ selection,
340
+ capability,
341
+ path,
342
+ trail,
343
+ loading,
344
+ error,
345
+ rows,
346
+ roots,
347
+ chosen,
348
+ focused,
349
+ resolving,
350
+ resolutionError,
351
+ searchArea,
352
+ confirmLabel,
353
+ folderCount,
354
+ canConfirm,
355
+ switchSource,
356
+ selectNode,
357
+ enter,
358
+ up,
359
+ refresh,
360
+ selectParent,
361
+ chooseLocal,
362
+ cancel,
363
+ confirm,
364
+ keyboard,
365
+ }
366
+ }