@morscherlab/mint-sdk 1.2.0-beta.12 → 1.2.0-beta.13

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 (69) hide show
  1. package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
  2. package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
  3. package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
  4. package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
  5. package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
  6. package/dist/components/FileBrowserModal.vue.d.ts +99 -0
  7. package/dist/components/FilePicker.vue.d.ts +32 -0
  8. package/dist/components/fileBrowserPresentation.d.ts +10 -0
  9. package/dist/components/index.d.ts +3 -0
  10. package/dist/components/index.js +2 -2
  11. package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
  12. package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
  13. package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
  14. package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
  15. package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
  16. package/dist/{components-DnZzoTN4.js → components-DuQRC810.js} +3725 -1519
  17. package/dist/components-DuQRC810.js.map +1 -0
  18. package/dist/composables/filePicker/localFiles.d.ts +3 -0
  19. package/dist/composables/filePicker/usePickerNavigation.d.ts +95 -0
  20. package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
  21. package/dist/composables/filePicker/validation.d.ts +5 -0
  22. package/dist/composables/index.d.ts +2 -0
  23. package/dist/composables/index.js +3 -3
  24. package/dist/composables/useFileBrowser.d.ts +55 -0
  25. package/dist/composables/useFilePicker.d.ts +190 -0
  26. package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
  27. package/dist/{composables-B_pia3hO.js → composables-hY2Q2xPI.js} +289 -3
  28. package/dist/composables-hY2Q2xPI.js.map +1 -0
  29. package/dist/index.js +4 -4
  30. package/dist/install.js +1 -1
  31. package/dist/styles.css +2232 -1259
  32. package/dist/types/fileBrowserTypes.d.ts +77 -0
  33. package/dist/types/filePicker.d.ts +69 -0
  34. package/dist/types/index.d.ts +2 -0
  35. package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-nBbSn1TI.js} +46 -2
  36. package/dist/validation-nBbSn1TI.js.map +1 -0
  37. package/package.json +1 -1
  38. package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
  39. package/src/__tests__/components/FilePicker.state.test.ts +160 -0
  40. package/src/__tests__/components/FilePicker.test.ts +328 -0
  41. package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
  42. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
  43. package/src/components/FileBrowserModal.story.vue +203 -0
  44. package/src/components/FileBrowserModal.vue +384 -0
  45. package/src/components/FilePicker.vue +301 -0
  46. package/src/components/fileBrowserPresentation.ts +39 -0
  47. package/src/components/index.ts +4 -0
  48. package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
  49. package/src/components/internal/FileBrowserListingInternal.vue +197 -0
  50. package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
  51. package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
  52. package/src/components/internal/FilePickerTreeInternal.vue +238 -0
  53. package/src/composables/filePicker/localFiles.ts +55 -0
  54. package/src/composables/filePicker/usePickerNavigation.ts +199 -0
  55. package/src/composables/filePicker/usePickerSelection.ts +242 -0
  56. package/src/composables/filePicker/validation.ts +40 -0
  57. package/src/composables/index.ts +7 -0
  58. package/src/composables/useFileBrowser.ts +314 -0
  59. package/src/composables/useFilePicker.ts +347 -0
  60. package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
  61. package/src/styles/components/file-browser-modal.css +489 -0
  62. package/src/styles/components/file-picker.css +501 -0
  63. package/src/styles/index.css +3 -0
  64. package/src/types/fileBrowserTypes.ts +85 -0
  65. package/src/types/filePicker.ts +65 -0
  66. package/src/types/index.ts +14 -0
  67. package/dist/components-DnZzoTN4.js.map +0 -1
  68. package/dist/composables-B_pia3hO.js.map +0 -1
  69. package/dist/useProtocolTemplates-BmX_iVBR.js.map +0 -1
@@ -0,0 +1,301 @@
1
+ <script setup lang="ts">
2
+ /** Adapter-driven file picker with Finder-style selection, disclosure and metadata preview. */
3
+ import BaseButton from './BaseButton.vue'
4
+ import BaseInput from './BaseInput.vue'
5
+ import IconButton from './IconButton.vue'
6
+ import FileBrowserDialogInternal from './internal/FileBrowserDialogInternal.vue'
7
+ import FilePickerTreeInternal from './internal/FilePickerTreeInternal.vue'
8
+ import FilePickerPreviewInternal from './internal/FilePickerPreviewInternal.vue'
9
+ import { formatBytes } from './fileBrowserPresentation'
10
+ import { useFilePicker } from '../composables/useFilePicker'
11
+ import { pickerReason } from '../composables/filePicker/validation'
12
+ import type {
13
+ PickerAdapter,
14
+ PickerCapabilities,
15
+ PickerSelection,
16
+ PickerSelectionMode,
17
+ PickerSource,
18
+ } from '../types/filePicker'
19
+
20
+ interface Props {
21
+ open: boolean
22
+ adapter: PickerAdapter
23
+ selectionMode?: PickerSelectionMode
24
+ sources?: PickerSource[]
25
+ capabilities?: PickerCapabilities
26
+ initialPath?: string
27
+ initialSelection?: PickerSelection | null
28
+ title?: string
29
+ }
30
+ const props = withDefaults(defineProps<Props>(), {
31
+ selectionMode: 'folder+files',
32
+ sources: () => ['server'],
33
+ capabilities: () => ({}),
34
+ initialPath: '',
35
+ initialSelection: null,
36
+ title: 'Choose data',
37
+ })
38
+ const emit = defineEmits<{
39
+ 'update:open': [open: boolean]
40
+ select: [selection: PickerSelection]
41
+ cancel: []
42
+ }>()
43
+ const {
44
+ previewNode,
45
+ sourceNames,
46
+ sourceIcons,
47
+ allowedSources,
48
+ source,
49
+ query,
50
+ recursive,
51
+ skipBlanks,
52
+ reviewing,
53
+ nav,
54
+ selection,
55
+ capability,
56
+ path,
57
+ trail,
58
+ loading,
59
+ error,
60
+ rows,
61
+ roots,
62
+ chosen,
63
+ focused,
64
+ resolving,
65
+ resolutionError,
66
+ filesInput,
67
+ folderInput,
68
+ searchArea,
69
+ accept,
70
+ confirmLabel,
71
+ folderCount,
72
+ canConfirm,
73
+ switchSource,
74
+ selectNode,
75
+ enter,
76
+ up,
77
+ refresh,
78
+ selectParent,
79
+ chooseLocal,
80
+ cancel,
81
+ confirm,
82
+ keyboard,
83
+ } = useFilePicker(props, emit)
84
+ </script>
85
+
86
+ <template>
87
+ <FileBrowserDialogInternal
88
+ :open="open"
89
+ :label="title"
90
+ size="full"
91
+ variant="picker"
92
+ @close="cancel"
93
+ >
94
+ <template #header>
95
+ <div ref="searchArea" class="mint-picker__search">
96
+ <svg
97
+ viewBox="0 0 24 24"
98
+ fill="none"
99
+ stroke="currentColor"
100
+ stroke-width="1.8"
101
+ aria-hidden="true"
102
+ >
103
+ <circle cx="10.5" cy="10.5" r="7" />
104
+ <path d="m16 16 5 5" />
105
+ </svg>
106
+ <BaseInput
107
+ v-model="query"
108
+ aria-label="Search folders and files"
109
+ placeholder="Search folders and files…"
110
+ />
111
+ <kbd>/</kbd>
112
+ </div>
113
+ <div
114
+ v-if="allowedSources.length > 1"
115
+ class="mint-picker__sources"
116
+ role="group"
117
+ aria-label="Data source"
118
+ :style="{ '--picker-source-index': allowedSources.indexOf(source) }"
119
+ >
120
+ <span class="mint-picker__source-indicator" aria-hidden="true" />
121
+ <IconButton
122
+ v-for="item in allowedSources"
123
+ :key="item"
124
+ size="sm"
125
+ variant="ghost"
126
+ :label="sourceNames[item]"
127
+ :aria-pressed="item === source"
128
+ @click="switchSource(item)"
129
+ >
130
+ <svg
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ stroke-width="1.8"
135
+ aria-hidden="true"
136
+ >
137
+ <path :d="sourceIcons[item]" />
138
+ </svg>
139
+ </IconButton>
140
+ </div>
141
+ </template>
142
+ <div :key="source" class="mint-picker mint-picker__source-panel" @keydown="keyboard">
143
+ <div class="mint-picker__pathbar">
144
+ <IconButton
145
+ label="Up one level"
146
+ size="sm"
147
+ variant="secondary"
148
+ :disabled="!path"
149
+ @click="up()"
150
+ ><svg
151
+ viewBox="0 0 24 24"
152
+ fill="none"
153
+ stroke="currentColor"
154
+ stroke-width="2"
155
+ aria-hidden="true"
156
+ >
157
+ <path d="M11 17l-5-5 5-5M6 12h13" /></svg
158
+ ></IconButton>
159
+ <nav aria-label="Breadcrumb" class="mint-picker__breadcrumbs">
160
+ <BaseButton variant="ghost" size="sm" @click="up('')">{{
161
+ sourceNames[source]
162
+ }}</BaseButton>
163
+ <template v-for="crumb in trail" :key="crumb.path"
164
+ ><span>/</span
165
+ ><BaseButton variant="ghost" size="sm" @click="up(crumb.path)">{{
166
+ crumb.label
167
+ }}</BaseButton></template
168
+ >
169
+ </nav>
170
+ <BaseButton
171
+ variant="ghost"
172
+ size="sm"
173
+ class="mint-picker__scope"
174
+ :aria-pressed="recursive"
175
+ @click="recursive = !recursive"
176
+ >{{ recursive ? 'Subfolders' : 'This folder' }}</BaseButton
177
+ >
178
+ <IconButton size="sm" label="Refresh location" :disabled="loading" @click="refresh()"
179
+ ><svg
180
+ viewBox="0 0 24 24"
181
+ fill="none"
182
+ stroke="currentColor"
183
+ stroke-width="1.8"
184
+ aria-hidden="true"
185
+ >
186
+ <path d="M20 7v5h-5M4 17v-5h5M6 8a7 7 0 0 1 12-2l2 3M4 15l2 3a7 7 0 0 0 12-2" /></svg
187
+ ></IconButton>
188
+ </div>
189
+ <div v-if="!allowedSources.length" class="mint-picker__empty">
190
+ No data sources are enabled.
191
+ </div>
192
+ <template v-else>
193
+ <div v-if="source === 'localFile'" class="mint-picker__local-actions">
194
+ <input
195
+ ref="filesInput"
196
+ type="file"
197
+ :multiple="selectionMode !== 'single-file'"
198
+ :accept="accept"
199
+ hidden
200
+ @change="chooseLocal"
201
+ />
202
+ <input
203
+ ref="folderInput"
204
+ type="file"
205
+ webkitdirectory
206
+ multiple
207
+ hidden
208
+ @change="chooseLocal"
209
+ />
210
+ <BaseButton
211
+ v-if="selectionMode !== 'folder'"
212
+ variant="secondary"
213
+ size="sm"
214
+ @click="filesInput?.click()"
215
+ >Choose files…</BaseButton
216
+ >
217
+ <BaseButton
218
+ v-if="selectionMode !== 'single-file'"
219
+ variant="secondary"
220
+ size="sm"
221
+ @click="folderInput?.click()"
222
+ >Choose folder…</BaseButton
223
+ >
224
+ <span>Files stay in this browser.</span>
225
+ </div>
226
+ <div class="mint-picker__workspace">
227
+ <FilePickerTreeInternal
228
+ :empty-title="
229
+ path
230
+ ? 'No readable files'
231
+ : source === 'localFile'
232
+ ? 'No files chosen'
233
+ : 'No server locations configured'
234
+ "
235
+ :rows="rows"
236
+ :loading="loading"
237
+ :error="error"
238
+ :query="query"
239
+ :mode="selectionMode"
240
+ :selected="selection.selected"
241
+ :reason="(node) => pickerReason(selection.exclusion(node), node)"
242
+ :progress="selection.folderProgress"
243
+ :focus-path="selection.focusPath.value"
244
+ @select="selectNode"
245
+ @enter="enter"
246
+ @expand="nav.expand"
247
+ @up="up"
248
+ @retry="refresh"
249
+ @parent="selectParent"
250
+ />
251
+ <FilePickerPreviewInternal
252
+ :node="previewNode"
253
+ :total-count="focused?.kind === 'folder' ? focused.fileCount : undefined"
254
+ :source="source"
255
+ :exclusions="chosen.excluded"
256
+ :busy="resolving"
257
+ :error="resolutionError"
258
+ :reviewing="reviewing"
259
+ @review="reviewing = !reviewing"
260
+ @retry="selection.resolve"
261
+ />
262
+ </div>
263
+ </template>
264
+ </div>
265
+ <template #footer>
266
+ <div class="mint-picker__footer">
267
+ <div class="mint-picker__receipt" role="status">
268
+ <span v-if="folderCount" class="mint-picker__badge"
269
+ >{{ folderCount }} folder{{ folderCount === 1 ? '' : 's' }}</span
270
+ >
271
+ <span v-if="chosen.files.length" class="mint-picker__badge"
272
+ >{{ chosen.files.length }} {{ chosen.files.length === 1 ? 'file' : 'files' }}</span
273
+ >
274
+ <span v-if="roots.length">{{ resolving ? 'Checking…' : formatBytes(chosen.bytes) }}</span>
275
+ <span v-else>No selection</span>
276
+ <BaseButton
277
+ v-if="chosen.excluded.length"
278
+ variant="ghost"
279
+ size="sm"
280
+ @click="reviewing = !reviewing"
281
+ >{{ chosen.excluded.length }} skipped</BaseButton
282
+ >
283
+ <BaseButton
284
+ v-if="capability?.isBlank"
285
+ size="sm"
286
+ variant="ghost"
287
+ :aria-pressed="skipBlanks"
288
+ @click="skipBlanks = !skipBlanks"
289
+ >Skip blanks {{ skipBlanks ? 'on' : 'off' }}</BaseButton
290
+ >
291
+ </div>
292
+ <div class="mint-picker__actions">
293
+ <BaseButton variant="ghost" size="sm" @click="cancel">Cancel</BaseButton
294
+ ><BaseButton size="sm" :disabled="!canConfirm" @click="confirm">{{
295
+ confirmLabel
296
+ }}</BaseButton>
297
+ </div>
298
+ </div>
299
+ </template>
300
+ </FileBrowserDialogInternal>
301
+ </template>
@@ -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
+ }
@@ -142,7 +142,11 @@ export { default as SectionCard } from './SectionCard.vue'
142
142
  // Experiment / analysis components
143
143
  export { default as ExperimentSelectorModal } from './ExperimentSelectorModal.vue'
144
144
  export { default as ArtifactSelectorModal } from './ArtifactSelectorModal.vue'
145
+ /** Modal picker over the read-only server mounts, recording paths rather than copying data. */
146
+ export { default as FileBrowserModal } from './FileBrowserModal.vue'
145
147
  export { default as GeneratedPathInput } from './GeneratedPathInput.vue'
146
148
  export { default as ArtifactSaveDialog } from './ArtifactSaveDialog.vue'
147
149
  export { default as ExperimentPopover } from './ExperimentPopover.vue'
148
150
  export { default as FitPanel } from './FitPanel.vue'
151
+
152
+ 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>