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