@morscherlab/mint-sdk 1.2.0-beta.11 → 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.
- package/dist/{ExperimentPopover-oL0fg_A0.js → ExperimentPopover-BIZKoRTT.js} +19 -12
- package/dist/ExperimentPopover-BIZKoRTT.js.map +1 -0
- package/dist/__tests__/components/FileBrowserModal.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.state.test.d.ts +1 -0
- package/dist/__tests__/components/FilePicker.test.d.ts +1 -0
- package/dist/__tests__/composables/useFileBrowser.test.d.ts +1 -0
- package/dist/__tests__/composables/usePlatformFilePickerAdapter.test.d.ts +1 -0
- package/dist/components/FileBrowserModal.vue.d.ts +99 -0
- package/dist/components/FilePicker.vue.d.ts +32 -0
- package/dist/components/fileBrowserPresentation.d.ts +10 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.js +3 -3
- package/dist/components/internal/FileBrowserDialogInternal.vue.d.ts +34 -0
- package/dist/components/internal/FileBrowserListingInternal.vue.d.ts +25 -0
- package/dist/components/internal/FileBrowserToolbarInternal.vue.d.ts +26 -0
- package/dist/components/internal/FilePickerPreviewInternal.vue.d.ts +18 -0
- package/dist/components/internal/FilePickerTreeInternal.vue.d.ts +31 -0
- package/dist/{components-CgAUzF8F.js → components-DuQRC810.js} +3727 -1521
- package/dist/components-DuQRC810.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +3 -0
- package/dist/composables/filePicker/usePickerNavigation.d.ts +95 -0
- package/dist/composables/filePicker/usePickerSelection.d.ts +31 -0
- package/dist/composables/filePicker/validation.d.ts +5 -0
- package/dist/composables/index.d.ts +2 -0
- package/dist/composables/index.js +3 -3
- package/dist/composables/useFileBrowser.d.ts +55 -0
- package/dist/composables/useFilePicker.d.ts +190 -0
- package/dist/composables/usePlatformFilePickerAdapter.d.ts +11 -0
- package/dist/{composables-B_pia3hO.js → composables-hY2Q2xPI.js} +289 -3
- package/dist/composables-hY2Q2xPI.js.map +1 -0
- package/dist/index.js +5 -5
- package/dist/install.js +1 -1
- package/dist/styles.css +2232 -1259
- package/dist/types/fileBrowserTypes.d.ts +77 -0
- package/dist/types/filePicker.d.ts +69 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/{useProtocolTemplates-BmX_iVBR.js → validation-nBbSn1TI.js} +46 -2
- package/dist/validation-nBbSn1TI.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/ExperimentPopover.test.ts +30 -0
- package/src/__tests__/components/FileBrowserModal.test.ts +460 -0
- package/src/__tests__/components/FilePicker.state.test.ts +160 -0
- package/src/__tests__/components/FilePicker.test.ts +328 -0
- package/src/__tests__/composables/useFileBrowser.test.ts +305 -0
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +67 -0
- package/src/components/ExperimentPopover.vue +10 -2
- package/src/components/FileBrowserModal.story.vue +203 -0
- package/src/components/FileBrowserModal.vue +384 -0
- package/src/components/FilePicker.vue +301 -0
- package/src/components/fileBrowserPresentation.ts +39 -0
- package/src/components/index.ts +4 -0
- package/src/components/internal/FileBrowserDialogInternal.vue +75 -0
- package/src/components/internal/FileBrowserListingInternal.vue +197 -0
- package/src/components/internal/FileBrowserToolbarInternal.vue +136 -0
- package/src/components/internal/FilePickerPreviewInternal.vue +100 -0
- package/src/components/internal/FilePickerTreeInternal.vue +238 -0
- package/src/composables/filePicker/localFiles.ts +55 -0
- package/src/composables/filePicker/usePickerNavigation.ts +199 -0
- package/src/composables/filePicker/usePickerSelection.ts +242 -0
- package/src/composables/filePicker/validation.ts +40 -0
- package/src/composables/index.ts +7 -0
- package/src/composables/useFileBrowser.ts +314 -0
- package/src/composables/useFilePicker.ts +347 -0
- package/src/composables/usePlatformFilePickerAdapter.ts +104 -0
- package/src/styles/components/file-browser-modal.css +489 -0
- package/src/styles/components/file-picker.css +501 -0
- package/src/styles/index.css +3 -0
- package/src/types/fileBrowserTypes.ts +85 -0
- package/src/types/filePicker.ts +65 -0
- package/src/types/index.ts +14 -0
- package/dist/ExperimentPopover-oL0fg_A0.js.map +0 -1
- package/dist/components-CgAUzF8F.js.map +0 -1
- package/dist/composables-B_pia3hO.js.map +0 -1
- 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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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>
|