@aws-amplify/ui-react-storage 3.17.2 → 3.18.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.
- package/bin/copy-serviceworker.js +36 -0
- package/dist/browser.js +23 -23
- package/dist/{createStorageBrowser-CtfBkr62.js → createStorageBrowser-S_PgkSVv.js} +1123 -264
- package/dist/download-sw.js +70 -0
- package/dist/esm/components/StorageBrowser/actions/configs/defaults.mjs +3 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/composedDownloadHandler.mjs +2 -2
- package/dist/esm/components/StorageBrowser/actions/handlers/download.mjs +1 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/utils.mjs +23 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/zipdownload.mjs +342 -118
- package/dist/esm/components/StorageBrowser/createStorageBrowser/StorageBrowserDefault.mjs +3 -1
- package/dist/esm/components/StorageBrowser/createStorageBrowser/createStorageBrowser.mjs +1 -1
- package/dist/esm/components/StorageBrowser/displayText/libraries/en/downloadView.mjs +4 -0
- package/dist/esm/components/StorageBrowser/service-worker/useServiceWorkerRegistration.mjs +25 -0
- package/dist/esm/components/StorageBrowser/useAction/useHandler.mjs +6 -2
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.mjs +60 -8
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.mjs +376 -5
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/utils.mjs +172 -0
- package/dist/esm/components/StorageBrowser/views/context/actionViews.mjs +1 -0
- package/dist/esm/components/StorageBrowser/views/context/primaryViews.mjs +1 -1
- package/dist/esm/version.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/styles.css +144 -114
- package/dist/types/components/StorageBrowser/actions/handlers/download.d.ts +15 -0
- package/dist/types/components/StorageBrowser/actions/handlers/utils.d.ts +12 -0
- package/dist/types/components/StorageBrowser/actions/handlers/zipdownload.d.ts +2 -2
- package/dist/types/components/StorageBrowser/actions/index.d.ts +1 -1
- package/dist/types/components/StorageBrowser/displayText/types.d.ts +21 -0
- package/dist/types/components/StorageBrowser/service-worker/download-sw.d.ts +2 -0
- package/dist/types/components/StorageBrowser/service-worker/useServiceWorkerRegistration.d.ts +2 -0
- package/dist/types/components/StorageBrowser/useAction/types.d.ts +1 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/types.d.ts +40 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/utils.d.ts +99 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/index.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/package.json +12 -8
|
@@ -33,8 +33,6 @@ function _interopNamespace(e) {
|
|
|
33
33
|
|
|
34
34
|
var React__namespace = /*#__PURE__*/_interopNamespace(React);
|
|
35
35
|
|
|
36
|
-
const VERSION = '3.17.2';
|
|
37
|
-
|
|
38
36
|
const DEFAULT_CHECKSUM_ALGORITHM = 'crc-32';
|
|
39
37
|
// 5MiB for multipart upload
|
|
40
38
|
// https://github.com/aws-amplify/amplify-js/blob/1a5366d113c9af4ce994168653df3aadb142c581/packages/storage/src/providers/s3/utils/constants.ts#L16
|
|
@@ -208,138 +206,30 @@ const createFileDataItem = (data) => ({
|
|
|
208
206
|
...data,
|
|
209
207
|
fileKey: getFileKey(data.key),
|
|
210
208
|
});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
});
|
|
228
|
-
if (result === '') {
|
|
229
|
-
throw new Error('Improper Permission: Please provide correct permission.');
|
|
230
|
-
}
|
|
231
|
-
return result;
|
|
232
|
-
};
|
|
233
|
-
const parseAmplifyAuthPermission = (permissions) => {
|
|
234
|
-
const result = [];
|
|
235
|
-
permissions.forEach((access) => {
|
|
236
|
-
if (access === 'read') {
|
|
237
|
-
if (!result.includes('list')) {
|
|
238
|
-
result.push('list');
|
|
239
|
-
}
|
|
240
|
-
if (!result.includes('get')) {
|
|
241
|
-
result.push('get');
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
else if (['delete', 'get', 'list', 'write'].includes(access) &&
|
|
245
|
-
!result.includes(access)) {
|
|
246
|
-
result.push(access);
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
if (result.length === 0) {
|
|
250
|
-
throw new Error('Improper Permission: Please provide correct permission.');
|
|
251
|
-
}
|
|
252
|
-
return result.sort();
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
const getPaginatedLocations = ({ items, pageSize, nextToken, }) => {
|
|
256
|
-
if (pageSize) {
|
|
257
|
-
if (nextToken) {
|
|
258
|
-
if (Number(nextToken) > items.length) {
|
|
259
|
-
return { items: [], nextToken: undefined };
|
|
260
|
-
}
|
|
261
|
-
const start = parseInt(nextToken, 10) * -1;
|
|
262
|
-
const end = start + pageSize < 0 ? start + pageSize : undefined;
|
|
263
|
-
return {
|
|
264
|
-
items: items.slice(start, end),
|
|
265
|
-
nextToken: end ? `${-end}` : undefined,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
return {
|
|
269
|
-
items: items.slice(0, pageSize),
|
|
270
|
-
nextToken: items.length > pageSize ? `${items.length - pageSize}` : undefined,
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
return { items, nextToken: undefined };
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
const createAmplifyListLocationsHandler = () => {
|
|
277
|
-
let cachedItems = [];
|
|
278
|
-
return async function listLocations(input) {
|
|
279
|
-
const { options } = input ?? {};
|
|
280
|
-
const { nextToken, pageSize } = options ?? {};
|
|
281
|
-
if (cachedItems.length > 0) {
|
|
282
|
-
return getPaginatedLocations({
|
|
283
|
-
items: cachedItems,
|
|
284
|
-
pageSize,
|
|
285
|
-
nextToken,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
const { locations } = await internals.listPaths();
|
|
289
|
-
const sanitizedItems = locations.map(({ bucket, permission, prefix, type }) => {
|
|
290
|
-
return {
|
|
291
|
-
type,
|
|
292
|
-
permissions: parseAmplifyAuthPermission(permission),
|
|
293
|
-
bucket,
|
|
294
|
-
prefix: prefix.endsWith('*') ? prefix.slice(0, -1) : prefix,
|
|
295
|
-
id: crypto.randomUUID(),
|
|
296
|
-
};
|
|
297
|
-
});
|
|
298
|
-
// Deduplicate locations with the same bucket and prefix, keeping broader permissions
|
|
299
|
-
cachedItems = deduplicateLocations(sanitizedItems);
|
|
300
|
-
return getPaginatedLocations({
|
|
301
|
-
items: cachedItems,
|
|
302
|
-
pageSize,
|
|
303
|
-
nextToken,
|
|
304
|
-
});
|
|
305
|
-
};
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
const MISSING_BUCKET_OR_REGION_ERROR = 'Amplify Storage configuration not found. Did you run `Amplify.configure` from your project root?';
|
|
309
|
-
const MISSING_IDENTITY_ID_ERROR = '`identityId` not found.';
|
|
310
|
-
const MISSING_TEMPORARY_CREDENTIALS_ERROR = 'Temporary Auth `credentials` not found.';
|
|
311
|
-
const isTemporaryCredentials = (value) => !!value?.sessionToken || !!value?.expiration;
|
|
312
|
-
const createAmplifyAuthAdapter = () => {
|
|
313
|
-
const { bucket, region } = awsAmplify.Amplify.getConfig()?.Storage?.S3 ?? {};
|
|
314
|
-
if (!bucket || !region) {
|
|
315
|
-
throw new Error(MISSING_BUCKET_OR_REGION_ERROR);
|
|
316
|
-
}
|
|
317
|
-
const listLocations = createAmplifyListLocationsHandler();
|
|
318
|
-
const getLocationCredentials = async () => {
|
|
319
|
-
const { credentials, identityId } = await auth.fetchAuthSession();
|
|
320
|
-
if (!isTemporaryCredentials(credentials)) {
|
|
321
|
-
throw new Error(MISSING_TEMPORARY_CREDENTIALS_ERROR);
|
|
322
|
-
}
|
|
323
|
-
if (!identityId) {
|
|
324
|
-
throw new Error(MISSING_IDENTITY_ID_ERROR);
|
|
325
|
-
}
|
|
326
|
-
return { credentials, identityId };
|
|
327
|
-
};
|
|
328
|
-
const registerAuthListener = (onStateChange) => {
|
|
329
|
-
const remove = utils.Hub.listen('auth', (data) => {
|
|
330
|
-
if (data.payload.event === 'signedOut') {
|
|
331
|
-
onStateChange();
|
|
332
|
-
remove();
|
|
333
|
-
}
|
|
334
|
-
});
|
|
335
|
-
};
|
|
209
|
+
/**
|
|
210
|
+
* Builds a download item ({@link DownloadHandlerData}) from a {@link FileData}
|
|
211
|
+
* plus the current browse-location prefix `P`.
|
|
212
|
+
*
|
|
213
|
+
* `relativePath = key.slice(P.length)` yields a zip entry path relative to the
|
|
214
|
+
* selected folder's *parent* (e.g. selecting `photos/` produces entries like
|
|
215
|
+
* `photos/vacation/beach.jpg`). This keeps each top-level folder name as a
|
|
216
|
+
* namespace, avoiding collisions across multiple selected folders. For loose
|
|
217
|
+
* files at the current prefix it reduces to the basename.
|
|
218
|
+
*/
|
|
219
|
+
const createDownloadItem = (data, locationPrefix) => {
|
|
220
|
+
// Assumes `locationPrefix` is '' or ends in '/' (StorageBrowser convention),
|
|
221
|
+
// so the slice yields a clean folder-relative path with no leading segment.
|
|
222
|
+
// Defensive: strip a single leading '/' so a prefix NOT ending in '/' can
|
|
223
|
+
// never yield a leading-slash (absolute) zip entry path.
|
|
224
|
+
const sliced = data.key.slice(locationPrefix.length);
|
|
336
225
|
return {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
region,
|
|
226
|
+
...data,
|
|
227
|
+
fileKey: getFileKey(data.key),
|
|
228
|
+
relativePath: sliced.startsWith('/') ? sliced.slice(1) : sliced,
|
|
341
229
|
};
|
|
342
230
|
};
|
|
231
|
+
const getProgress = ({ totalBytes, transferredBytes, }) => totalBytes ? transferredBytes / totalBytes : undefined;
|
|
232
|
+
const isMultipartUpload = (file) => file.size > MULTIPART_UPLOAD_THRESHOLD_BYTES;
|
|
343
233
|
|
|
344
234
|
const copyHandler = (input) => {
|
|
345
235
|
const { config, data } = input;
|
|
@@ -482,7 +372,7 @@ function downloadFromUrl(fileName, url) {
|
|
|
482
372
|
a.click();
|
|
483
373
|
document.body.removeChild(a);
|
|
484
374
|
}
|
|
485
|
-
const downloadHandler
|
|
375
|
+
const downloadHandler = ({ config, data, }) => {
|
|
486
376
|
const { accountId, credentials, customEndpoint } = config;
|
|
487
377
|
const { key } = data;
|
|
488
378
|
const result = internals.getUrl({
|
|
@@ -507,70 +397,176 @@ const downloadHandler$1 = ({ config, data }) => {
|
|
|
507
397
|
return { result };
|
|
508
398
|
};
|
|
509
399
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
400
|
+
/**
|
|
401
|
+
* Zip Download Handler
|
|
402
|
+
*
|
|
403
|
+
* Downloads multiple S3 files sequentially into a streaming zip archive,
|
|
404
|
+
* delivered via service worker (with blob fallback for browsers without SW support).
|
|
405
|
+
*
|
|
406
|
+
* State machine: IDLE → DOWNLOADING → COMPLETE | CANCELLED
|
|
407
|
+
*
|
|
408
|
+
* Batch state is scoped per download session using a WeakMap keyed by the `all`
|
|
409
|
+
* array identity (guaranteed stable per useProcessTasks invocation).
|
|
410
|
+
* This avoids module-level singleton issues if multiple StorageBrowser instances
|
|
411
|
+
* share the same module.
|
|
412
|
+
*
|
|
413
|
+
* Cancel paths:
|
|
414
|
+
* - UI cancel (onActionCancel button): cancelBatch() → aborts fetch + writable → reset
|
|
415
|
+
* - Browser-dialog cancel: SW stream closes → addPromise rejects → cancelled=true → drain remaining files
|
|
416
|
+
*/
|
|
417
|
+
/**
|
|
418
|
+
* Batch state is keyed by a stable serialization of the file IDs in the batch.
|
|
419
|
+
* useProcessTasks recreates the `all` array on each handler call (spread + map),
|
|
420
|
+
* so we cannot rely on reference identity. Sorting IDs produces a deterministic
|
|
421
|
+
* key that remains identical across calls for the same set of files.
|
|
422
|
+
*/
|
|
423
|
+
const batchMap = new Map();
|
|
424
|
+
/** Derives a stable batch key from the task data array. */
|
|
425
|
+
const getBatchKey = (all) => all
|
|
426
|
+
.map((item) => item.id)
|
|
427
|
+
.sort()
|
|
428
|
+
.join('\0');
|
|
429
|
+
/** Tears down all listeners and removes batch from map. */
|
|
430
|
+
const reset = (batchKey, state) => {
|
|
431
|
+
if (state.keepaliveInterval) {
|
|
432
|
+
clearInterval(state.keepaliveInterval);
|
|
433
|
+
}
|
|
434
|
+
batchMap.delete(batchKey);
|
|
435
|
+
};
|
|
436
|
+
/**
|
|
437
|
+
* Marks the batch cancelled and tears down the active stream, but deliberately
|
|
438
|
+
* KEEPS the batchMap entry alive. Idempotent.
|
|
439
|
+
*
|
|
440
|
+
* The `cancelled` sentinel and the map entry share one key, so deleting the
|
|
441
|
+
* entry here would erase the very flag the drain path relies on: with
|
|
442
|
+
* `concurrency: 1`, useProcessTasks re-dispatches each remaining QUEUED file to
|
|
443
|
+
* the handler on settle. If the entry were gone, those files would miss the
|
|
444
|
+
* `existingBatch.cancelled` early-exit and build a brand-new batch — resurrecting
|
|
445
|
+
* the download. Instead we leave a tombstoned entry; the remaining files hit the
|
|
446
|
+
* early-exit branch and `reset()` runs only once `batchDone === batchTotal`
|
|
447
|
+
* (in the STEP 1 early-exit or onFileSettled), for both cancel and completion.
|
|
448
|
+
*/
|
|
449
|
+
const cancelBatch = (batchKey) => {
|
|
450
|
+
const state = batchMap.get(batchKey);
|
|
451
|
+
if (!state || state.cancelled)
|
|
452
|
+
return;
|
|
453
|
+
state.cancelled = true;
|
|
454
|
+
state.batchAbort.abort();
|
|
455
|
+
// Terminate the SW response stream by aborting the writable side of the TransformStream.
|
|
456
|
+
// This errors the readable (transferred to SW), which errors the Response, failing the browser download.
|
|
457
|
+
state.zipWritable.abort('Download cancelled').catch(() => { });
|
|
458
|
+
// Stop keepalive pings immediately, but do NOT delete the map entry — the
|
|
459
|
+
// remaining queued files must still find this (cancelled) batch. The entry is
|
|
460
|
+
// removed by reset() at the final drain.
|
|
461
|
+
if (state.keepaliveInterval) {
|
|
462
|
+
clearInterval(state.keepaliveInterval);
|
|
463
|
+
state.keepaliveInterval = null;
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
// ─── Utilities ───
|
|
467
|
+
/** Extracts the S3 bucket descriptor from handler config. */
|
|
546
468
|
const constructBucket = ({ bucket: bucketName, region, }) => ({ bucketName, region });
|
|
547
|
-
|
|
548
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Derives the zip filename from the first key's parent folder.
|
|
471
|
+
*
|
|
472
|
+
* Examples:
|
|
473
|
+
* - "photos/vacation/beach.jpg" → "vacation"
|
|
474
|
+
* - "photos/file.txt" → "photos"
|
|
475
|
+
* - "file.txt" (no slash) → "archive"
|
|
476
|
+
* - "/file.txt" (slash at index 0 only) → "archive"
|
|
477
|
+
*
|
|
478
|
+
* For root-level multi-file selections (no common parent folder), returns "archive".
|
|
479
|
+
*/
|
|
480
|
+
const getFolderName$1 = (key) => {
|
|
481
|
+
const lastSlash = key.lastIndexOf('/');
|
|
482
|
+
if (lastSlash <= 0)
|
|
483
|
+
return 'archive';
|
|
484
|
+
const parentPath = key.substring(0, lastSlash);
|
|
485
|
+
return parentPath.split('/').pop() ?? 'archive';
|
|
486
|
+
};
|
|
487
|
+
/** Collects a ReadableStream into a single Blob (fallback when SW is unavailable). */
|
|
488
|
+
const collectBlob = async (readable) => {
|
|
489
|
+
const reader = readable.getReader();
|
|
549
490
|
const chunks = [];
|
|
550
|
-
|
|
551
|
-
const size = +(response.headers.get('content-length') ?? 0);
|
|
552
|
-
let received = 0;
|
|
553
|
-
while (loading) {
|
|
491
|
+
for (;;) {
|
|
554
492
|
const { value, done } = await reader.read();
|
|
555
|
-
if (done)
|
|
556
|
-
|
|
493
|
+
if (done)
|
|
494
|
+
break;
|
|
495
|
+
chunks.push(value);
|
|
496
|
+
}
|
|
497
|
+
return new Blob(chunks, { type: 'application/zip' });
|
|
498
|
+
};
|
|
499
|
+
// ─── Service Worker Initialization ───
|
|
500
|
+
/**
|
|
501
|
+
* Registers the SW stream transfer (MessageChannel handshake + keepalive)
|
|
502
|
+
* or falls back to in-memory blob collection when SW is unavailable.
|
|
503
|
+
* Mutates state.swReady and state.blobPromise.
|
|
504
|
+
*/
|
|
505
|
+
const initServiceWorkerStream = (state) => {
|
|
506
|
+
if (!navigator.serviceWorker) {
|
|
507
|
+
state.blobPromise = collectBlob(state.zipReadable);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
state.swReady = navigator.serviceWorker
|
|
511
|
+
.getRegistration('/amplify-storage-download/')
|
|
512
|
+
.then((reg) => {
|
|
513
|
+
// If the batch was cancelled while getRegistration() was pending, bail out
|
|
514
|
+
// before wiring up the MessageChannel or keepalive interval. Otherwise the
|
|
515
|
+
// interval would be created after reset() already cleared batchMap, leaking
|
|
516
|
+
// a timer with no reference to clear it.
|
|
517
|
+
if (state.cancelled) {
|
|
518
|
+
return;
|
|
557
519
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (ui.isFunction(options?.onProgress)) {
|
|
562
|
-
options?.onProgress(data, getProgress({
|
|
563
|
-
totalBytes: size,
|
|
564
|
-
transferredBytes: received,
|
|
565
|
-
}), 'PENDING');
|
|
566
|
-
}
|
|
520
|
+
if (!reg?.active) {
|
|
521
|
+
state.blobPromise = collectBlob(state.zipReadable);
|
|
522
|
+
return;
|
|
567
523
|
}
|
|
568
|
-
|
|
569
|
-
|
|
524
|
+
// Cancel detection works via addPromise.catch: when the user dismisses the
|
|
525
|
+
// download dialog, the SW response stream closes → TransformStream writable
|
|
526
|
+
// errors → zipWriter.add() rejects → cancelled flag is set.
|
|
527
|
+
const { port1, port2 } = new MessageChannel();
|
|
528
|
+
port1.onmessage = () => {
|
|
529
|
+
const a = document.createElement('a');
|
|
530
|
+
a.href = `/amplify-storage-download/${state.downloadId}`;
|
|
531
|
+
a.download = `${state.folder}.zip`;
|
|
532
|
+
a.click();
|
|
533
|
+
port1.close();
|
|
534
|
+
};
|
|
535
|
+
// Send the user-facing filename explicitly. `downloadId` embeds Date.now()
|
|
536
|
+
// to keep the SW's stream-map key unique across batches — that timestamp
|
|
537
|
+
// must NOT leak into the saved filename. The SW uses `filename` for the
|
|
538
|
+
// Content-Disposition header so the SW path and the blob-fallback path both
|
|
539
|
+
// save `${folder}.zip`.
|
|
540
|
+
reg.active.postMessage({
|
|
541
|
+
downloadId: state.downloadId,
|
|
542
|
+
filename: `${state.folder}.zip`,
|
|
543
|
+
stream: state.zipReadable,
|
|
544
|
+
}, [state.zipReadable, port2]);
|
|
545
|
+
state.zipReadable = null;
|
|
546
|
+
// Keepalive pings run for the entire batch duration to prevent Firefox's
|
|
547
|
+
// 30s SW idle timeout from terminating the worker mid-stream.
|
|
548
|
+
state.keepaliveInterval = setInterval(() => {
|
|
549
|
+
reg.active?.postMessage({ type: 'keepalive' });
|
|
550
|
+
}, 10000);
|
|
551
|
+
});
|
|
570
552
|
};
|
|
571
|
-
|
|
553
|
+
// ─── Per-file Download ───
|
|
554
|
+
/**
|
|
555
|
+
* Downloads a single file, streams it into the zip writer entry.
|
|
556
|
+
* Receives the active batch state explicitly — throws if state is invalid.
|
|
557
|
+
*/
|
|
558
|
+
const download = async (state, { config, data, options }) => {
|
|
572
559
|
const { customEndpoint, credentials, accountId } = config;
|
|
573
560
|
const { key } = data;
|
|
561
|
+
// Prefer the folder-relative path (set during folder expansion) so nested
|
|
562
|
+
// files keep their structure inside the zip; fall back to the bare basename
|
|
563
|
+
// for loose files / legacy inputs that don't carry a relativePath.
|
|
564
|
+
const filename = data.relativePath ?? key.split('/').pop();
|
|
565
|
+
await state.swReady;
|
|
566
|
+
if (state.cancelled) {
|
|
567
|
+
throw new Error('Download cancelled');
|
|
568
|
+
}
|
|
569
|
+
// Note: getUrl is a presigned URL generation call (local, fast) — not cancellable
|
|
574
570
|
const { url } = await internals.getUrl({
|
|
575
571
|
path: key,
|
|
576
572
|
options: {
|
|
@@ -584,78 +580,196 @@ const download = async ({ config, data, all, options }, abortController) => {
|
|
|
584
580
|
});
|
|
585
581
|
const response = await fetch(url, {
|
|
586
582
|
mode: 'cors',
|
|
587
|
-
signal:
|
|
583
|
+
signal: state.batchAbort.signal,
|
|
588
584
|
});
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
585
|
+
if (!response.body)
|
|
586
|
+
throw new Error(`Empty response body for ${key}`);
|
|
587
|
+
const size = data.size ?? +(response.headers.get('content-length') ?? 0);
|
|
588
|
+
let transferred = 0;
|
|
589
|
+
// Intermediate stream decouples fetch errors from zip.js internals.
|
|
590
|
+
// Closing this stream cleanly lets zip.js finalize the entry without
|
|
591
|
+
// uncaught AbortError rejections from its codec-worker.
|
|
592
|
+
let streamController;
|
|
593
|
+
const fileStream = new ReadableStream({
|
|
594
|
+
start(controller) {
|
|
595
|
+
streamController = controller;
|
|
598
596
|
},
|
|
599
597
|
});
|
|
598
|
+
// Start the zip add — returns a promise that resolves when the
|
|
599
|
+
// ReadableStream we gave it closes.
|
|
600
|
+
const addPromise = state.zipWriter.add(filename, fileStream, { level: 0 });
|
|
601
|
+
// When the browser dismisses the download dialog, the SW response stream
|
|
602
|
+
// closes → TransformStream writable errors → zipWriter.add() rejects.
|
|
603
|
+
// We flag cancellation from two robust signals: an explicit abort in flight
|
|
604
|
+
// (UI cancel aborts batchAbort) or a standard `AbortError` (the DOM error name
|
|
605
|
+
// zip.js surfaces when its output stream is terminated by a dialog cancel).
|
|
606
|
+
// We deliberately avoid substring-matching zip.js's internal error messages,
|
|
607
|
+
// which are not a public API contract. Any other rejection is a genuine error.
|
|
608
|
+
addPromise.catch((error) => {
|
|
609
|
+
const err = error instanceof Error ? error : undefined;
|
|
610
|
+
if (state.batchAbort.signal.aborted || err?.name === 'AbortError') {
|
|
611
|
+
state.cancelled = true;
|
|
612
|
+
}
|
|
613
|
+
// Re-throw is not needed — the await below will surface the rejection.
|
|
614
|
+
});
|
|
615
|
+
try {
|
|
616
|
+
const reader = response.body.getReader();
|
|
617
|
+
for (;;) {
|
|
618
|
+
if (state.cancelled) {
|
|
619
|
+
streamController.close();
|
|
620
|
+
throw new Error('Download cancelled');
|
|
621
|
+
}
|
|
622
|
+
const { value, done } = await reader.read();
|
|
623
|
+
if (done)
|
|
624
|
+
break;
|
|
625
|
+
transferred += value.length;
|
|
626
|
+
streamController.enqueue(value);
|
|
627
|
+
if (size > 0 && ui.isFunction(options?.onProgress)) {
|
|
628
|
+
options.onProgress(data, getProgress({ totalBytes: size, transferredBytes: transferred }), 'PENDING');
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// All chunks read — close the stream so zip.js finalizes the entry
|
|
632
|
+
streamController.close();
|
|
633
|
+
await addPromise;
|
|
634
|
+
if (ui.isFunction(options?.onProgress)) {
|
|
635
|
+
options.onProgress(data, 1, 'COMPLETE');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
catch (e) {
|
|
639
|
+
const err = e;
|
|
640
|
+
try {
|
|
641
|
+
streamController.close();
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
/* already closed */
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
await Promise.race([
|
|
648
|
+
addPromise,
|
|
649
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('addPromise timeout')), 1000)),
|
|
650
|
+
]);
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
/* swallow — zip entry incomplete */
|
|
654
|
+
}
|
|
655
|
+
throw state.cancelled || err.name === 'AbortError'
|
|
656
|
+
? new Error('Download cancelled')
|
|
657
|
+
: err;
|
|
658
|
+
}
|
|
600
659
|
return filename;
|
|
601
660
|
};
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
661
|
+
// ─── Post-file Settlement ───
|
|
662
|
+
/**
|
|
663
|
+
* Called after each file completes (success or failure).
|
|
664
|
+
* Increments progress and triggers batch cleanup when all files are settled.
|
|
665
|
+
*
|
|
666
|
+
* IMPORTANT: For the final file, this function awaits cleanup completion
|
|
667
|
+
* (zipWriter.close + blob download) before returning. This ensures the
|
|
668
|
+
* user receives the download before useProcessTasks marks the task "done"
|
|
669
|
+
* and the component can unmount.
|
|
670
|
+
*/
|
|
671
|
+
const onFileSettled = async (batchKey, state, taskResult) => {
|
|
672
|
+
state.batchDone++;
|
|
673
|
+
if (state.batchDone < state.batchTotal) {
|
|
674
|
+
return taskResult;
|
|
675
|
+
}
|
|
676
|
+
// Final file — run cleanup synchronously in this promise chain so that
|
|
677
|
+
// the task result is not returned until the download is triggered.
|
|
678
|
+
try {
|
|
679
|
+
if (!state.cancelled && state.zipWriter) {
|
|
680
|
+
await state.zipWriter.close();
|
|
681
|
+
if (state.blobPromise) {
|
|
682
|
+
const blob = await state.blobPromise;
|
|
683
|
+
const a = document.createElement('a');
|
|
684
|
+
a.href = URL.createObjectURL(blob);
|
|
685
|
+
a.download = `${state.folder}.zip`;
|
|
686
|
+
a.click();
|
|
687
|
+
URL.revokeObjectURL(a.href);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// zip close failed — batch was likely cancelled
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
reset(batchKey, state);
|
|
696
|
+
}
|
|
697
|
+
return taskResult;
|
|
698
|
+
};
|
|
699
|
+
// ─── Handler ───
|
|
700
|
+
/** Main entry point — called once per file in a multi-select download batch. */
|
|
701
|
+
const zipDownloadHandler = ({ config, data, all, options, }) => {
|
|
702
|
+
const { key } = data;
|
|
703
|
+
const firstKey = all[0]?.key ?? key;
|
|
704
|
+
// The view computes the batch zip name (last segment of the common ancestor
|
|
705
|
+
// dir of all files) and stamps it onto every item. Prefer it; fall back to
|
|
706
|
+
// the legacy first-key parent-folder heuristic when it's absent.
|
|
707
|
+
const folder = all[0]?.archiveName ?? getFolderName$1(firstKey);
|
|
708
|
+
const batchKey = getBatchKey(all);
|
|
709
|
+
const existingBatch = batchMap.get(batchKey);
|
|
710
|
+
// ─── STEP 1: Handle cancelled state ───
|
|
711
|
+
if (existingBatch?.cancelled) {
|
|
712
|
+
existingBatch.batchDone++;
|
|
713
|
+
const result = Promise.resolve({
|
|
714
|
+
status: 'CANCELED',
|
|
715
|
+
message: 'Download cancelled',
|
|
716
|
+
});
|
|
717
|
+
if (existingBatch.batchDone >= existingBatch.batchTotal) {
|
|
718
|
+
result.finally(() => reset(batchKey, existingBatch));
|
|
719
|
+
}
|
|
609
720
|
return {
|
|
721
|
+
result,
|
|
610
722
|
cancel: () => {
|
|
611
|
-
|
|
612
|
-
fileDownloadQueue.set(key, true);
|
|
723
|
+
/* already cancelled — noop */
|
|
613
724
|
},
|
|
614
|
-
result: download({ config, data, all, options }, abortController)
|
|
615
|
-
.then(() => {
|
|
616
|
-
fileDownloadQueue.set(key, true);
|
|
617
|
-
return {
|
|
618
|
-
status: 'COMPLETE',
|
|
619
|
-
};
|
|
620
|
-
})
|
|
621
|
-
.catch((e) => {
|
|
622
|
-
const error = e;
|
|
623
|
-
fileDownloadQueue.set(key, true);
|
|
624
|
-
return {
|
|
625
|
-
status: 'FAILED',
|
|
626
|
-
message: error.message,
|
|
627
|
-
error,
|
|
628
|
-
};
|
|
629
|
-
})
|
|
630
|
-
.finally(() => {
|
|
631
|
-
const done = all.every(({ key }) => {
|
|
632
|
-
return fileDownloadQueue.get(key);
|
|
633
|
-
});
|
|
634
|
-
if (done) {
|
|
635
|
-
zipper
|
|
636
|
-
.getBlobUrl()
|
|
637
|
-
.then((blobURL) => {
|
|
638
|
-
if (blobURL) {
|
|
639
|
-
zipper.destroy();
|
|
640
|
-
const anchor = document.createElement('a');
|
|
641
|
-
const clickEvent = new MouseEvent('click');
|
|
642
|
-
anchor.href = blobURL;
|
|
643
|
-
anchor.download = `${folder || 'archive'}.zip`;
|
|
644
|
-
anchor.dispatchEvent(clickEvent);
|
|
645
|
-
}
|
|
646
|
-
})
|
|
647
|
-
.catch(() => {
|
|
648
|
-
// this catch happens, when no zip was created.
|
|
649
|
-
// it is handled by the UI showing "FAILED" for all files
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
}),
|
|
653
725
|
};
|
|
726
|
+
}
|
|
727
|
+
// ─── STEP 2: Initialize zip writer on first file ───
|
|
728
|
+
let currentBatch;
|
|
729
|
+
if (!existingBatch) {
|
|
730
|
+
const { readable, writable } = new TransformStream();
|
|
731
|
+
currentBatch = {
|
|
732
|
+
zipWriter: new zip_js.ZipWriter(writable),
|
|
733
|
+
zipWritable: writable,
|
|
734
|
+
zipReadable: readable,
|
|
735
|
+
downloadId: `${folder}-${Date.now()}.zip`,
|
|
736
|
+
blobPromise: null,
|
|
737
|
+
swReady: Promise.resolve(),
|
|
738
|
+
cancelled: false,
|
|
739
|
+
batchAbort: new AbortController(),
|
|
740
|
+
batchTotal: all.length,
|
|
741
|
+
batchDone: 0,
|
|
742
|
+
keepaliveInterval: null,
|
|
743
|
+
folder,
|
|
744
|
+
};
|
|
745
|
+
batchMap.set(batchKey, currentBatch);
|
|
746
|
+
initServiceWorkerStream(currentBatch);
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
currentBatch = existingBatch;
|
|
750
|
+
}
|
|
751
|
+
// ─── STEP 3: Normal download ───
|
|
752
|
+
return {
|
|
753
|
+
cancel: () => {
|
|
754
|
+
cancelBatch(batchKey);
|
|
755
|
+
},
|
|
756
|
+
result: download(currentBatch, { config, data, options })
|
|
757
|
+
.then(() => ({ status: 'COMPLETE' }))
|
|
758
|
+
.catch((e) => {
|
|
759
|
+
const err = e;
|
|
760
|
+
if (err.message === 'Download cancelled' ||
|
|
761
|
+
err.name === 'AbortError' ||
|
|
762
|
+
currentBatch.cancelled) {
|
|
763
|
+
currentBatch.cancelled = true;
|
|
764
|
+
return { status: 'CANCELED', message: 'Download cancelled' };
|
|
765
|
+
}
|
|
766
|
+
return { status: 'FAILED', message: err.message, error: err };
|
|
767
|
+
})
|
|
768
|
+
.then((taskResult) => onFileSettled(batchKey, currentBatch, taskResult)),
|
|
654
769
|
};
|
|
655
|
-
|
|
656
|
-
})();
|
|
770
|
+
};
|
|
657
771
|
|
|
658
|
-
const composedDownloadHandler = (input) => input.all.length === 1 ? downloadHandler
|
|
772
|
+
const composedDownloadHandler = (input) => input.all.length === 1 ? downloadHandler(input) : zipDownloadHandler(input);
|
|
659
773
|
|
|
660
774
|
const DEFAULT_PAGE_SIZE$2 = 1000;
|
|
661
775
|
const parseItems = (items, excludedPath) => items
|
|
@@ -899,9 +1013,11 @@ const uploadActionConfig = {
|
|
|
899
1013
|
const downloadActionConfig = {
|
|
900
1014
|
viewName: 'DownloadView',
|
|
901
1015
|
actionListItem: {
|
|
1016
|
+
// Only an empty selection disables Download; folder selections are
|
|
1017
|
+
// expanded into files at the view level.
|
|
902
1018
|
disable: (selected) => {
|
|
903
1019
|
const hasNoSelection = !selected || selected.length === 0;
|
|
904
|
-
return hasNoSelection
|
|
1020
|
+
return hasNoSelection;
|
|
905
1021
|
},
|
|
906
1022
|
hide: (permissions) => !permissions.includes('get'),
|
|
907
1023
|
icon: 'download',
|
|
@@ -929,6 +1045,136 @@ const getActionConfigs = (configs) => {
|
|
|
929
1045
|
return Object.entries({ ...configs.default, ...configs.custom }).reduce((configs, [type, config]) => !isActionConfig(config) ? configs : { ...configs, [type]: config }, {});
|
|
930
1046
|
};
|
|
931
1047
|
|
|
1048
|
+
const toAccessGrantPermission = (permission) => {
|
|
1049
|
+
let result = '';
|
|
1050
|
+
permission.forEach((access) => {
|
|
1051
|
+
if (['read', 'get', 'list'].includes(access)) {
|
|
1052
|
+
if (!result.includes('READ')) {
|
|
1053
|
+
result = 'READ' + result;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
if (['write', 'delete'].includes(access)) {
|
|
1057
|
+
if (!result.includes('WRITE')) {
|
|
1058
|
+
result += 'WRITE';
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
if (result === '') {
|
|
1063
|
+
throw new Error('Improper Permission: Please provide correct permission.');
|
|
1064
|
+
}
|
|
1065
|
+
return result;
|
|
1066
|
+
};
|
|
1067
|
+
const parseAmplifyAuthPermission = (permissions) => {
|
|
1068
|
+
const result = [];
|
|
1069
|
+
permissions.forEach((access) => {
|
|
1070
|
+
if (access === 'read') {
|
|
1071
|
+
if (!result.includes('list')) {
|
|
1072
|
+
result.push('list');
|
|
1073
|
+
}
|
|
1074
|
+
if (!result.includes('get')) {
|
|
1075
|
+
result.push('get');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else if (['delete', 'get', 'list', 'write'].includes(access) &&
|
|
1079
|
+
!result.includes(access)) {
|
|
1080
|
+
result.push(access);
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
if (result.length === 0) {
|
|
1084
|
+
throw new Error('Improper Permission: Please provide correct permission.');
|
|
1085
|
+
}
|
|
1086
|
+
return result.sort();
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
const getPaginatedLocations = ({ items, pageSize, nextToken, }) => {
|
|
1090
|
+
if (pageSize) {
|
|
1091
|
+
if (nextToken) {
|
|
1092
|
+
if (Number(nextToken) > items.length) {
|
|
1093
|
+
return { items: [], nextToken: undefined };
|
|
1094
|
+
}
|
|
1095
|
+
const start = parseInt(nextToken, 10) * -1;
|
|
1096
|
+
const end = start + pageSize < 0 ? start + pageSize : undefined;
|
|
1097
|
+
return {
|
|
1098
|
+
items: items.slice(start, end),
|
|
1099
|
+
nextToken: end ? `${-end}` : undefined,
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
return {
|
|
1103
|
+
items: items.slice(0, pageSize),
|
|
1104
|
+
nextToken: items.length > pageSize ? `${items.length - pageSize}` : undefined,
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
return { items, nextToken: undefined };
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
const createAmplifyListLocationsHandler = () => {
|
|
1111
|
+
let cachedItems = [];
|
|
1112
|
+
return async function listLocations(input) {
|
|
1113
|
+
const { options } = input ?? {};
|
|
1114
|
+
const { nextToken, pageSize } = options ?? {};
|
|
1115
|
+
if (cachedItems.length > 0) {
|
|
1116
|
+
return getPaginatedLocations({
|
|
1117
|
+
items: cachedItems,
|
|
1118
|
+
pageSize,
|
|
1119
|
+
nextToken,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
const { locations } = await internals.listPaths();
|
|
1123
|
+
const sanitizedItems = locations.map(({ bucket, permission, prefix, type }) => {
|
|
1124
|
+
return {
|
|
1125
|
+
type,
|
|
1126
|
+
permissions: parseAmplifyAuthPermission(permission),
|
|
1127
|
+
bucket,
|
|
1128
|
+
prefix: prefix.endsWith('*') ? prefix.slice(0, -1) : prefix,
|
|
1129
|
+
id: crypto.randomUUID(),
|
|
1130
|
+
};
|
|
1131
|
+
});
|
|
1132
|
+
// Deduplicate locations with the same bucket and prefix, keeping broader permissions
|
|
1133
|
+
cachedItems = deduplicateLocations(sanitizedItems);
|
|
1134
|
+
return getPaginatedLocations({
|
|
1135
|
+
items: cachedItems,
|
|
1136
|
+
pageSize,
|
|
1137
|
+
nextToken,
|
|
1138
|
+
});
|
|
1139
|
+
};
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const MISSING_BUCKET_OR_REGION_ERROR = 'Amplify Storage configuration not found. Did you run `Amplify.configure` from your project root?';
|
|
1143
|
+
const MISSING_IDENTITY_ID_ERROR = '`identityId` not found.';
|
|
1144
|
+
const MISSING_TEMPORARY_CREDENTIALS_ERROR = 'Temporary Auth `credentials` not found.';
|
|
1145
|
+
const isTemporaryCredentials = (value) => !!value?.sessionToken || !!value?.expiration;
|
|
1146
|
+
const createAmplifyAuthAdapter = () => {
|
|
1147
|
+
const { bucket, region } = awsAmplify.Amplify.getConfig()?.Storage?.S3 ?? {};
|
|
1148
|
+
if (!bucket || !region) {
|
|
1149
|
+
throw new Error(MISSING_BUCKET_OR_REGION_ERROR);
|
|
1150
|
+
}
|
|
1151
|
+
const listLocations = createAmplifyListLocationsHandler();
|
|
1152
|
+
const getLocationCredentials = async () => {
|
|
1153
|
+
const { credentials, identityId } = await auth.fetchAuthSession();
|
|
1154
|
+
if (!isTemporaryCredentials(credentials)) {
|
|
1155
|
+
throw new Error(MISSING_TEMPORARY_CREDENTIALS_ERROR);
|
|
1156
|
+
}
|
|
1157
|
+
if (!identityId) {
|
|
1158
|
+
throw new Error(MISSING_IDENTITY_ID_ERROR);
|
|
1159
|
+
}
|
|
1160
|
+
return { credentials, identityId };
|
|
1161
|
+
};
|
|
1162
|
+
const registerAuthListener = (onStateChange) => {
|
|
1163
|
+
const remove = utils.Hub.listen('auth', (data) => {
|
|
1164
|
+
if (data.payload.event === 'signedOut') {
|
|
1165
|
+
onStateChange();
|
|
1166
|
+
remove();
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
};
|
|
1170
|
+
return {
|
|
1171
|
+
getLocationCredentials,
|
|
1172
|
+
listLocations,
|
|
1173
|
+
registerAuthListener,
|
|
1174
|
+
region,
|
|
1175
|
+
};
|
|
1176
|
+
};
|
|
1177
|
+
|
|
932
1178
|
function Button(props) {
|
|
933
1179
|
const { disabled, variant } = props;
|
|
934
1180
|
switch (variant) {
|
|
@@ -2285,9 +2531,13 @@ function useHandler(handler, options) {
|
|
|
2285
2531
|
...(hasData
|
|
2286
2532
|
? { data: input.data, all: [input.data] }
|
|
2287
2533
|
: // if no `data` provided, provide `concurrency` to `options`
|
|
2288
|
-
{
|
|
2534
|
+
{
|
|
2535
|
+
options: {
|
|
2536
|
+
concurrency: options?.concurrency ?? DEFAULT_ACTION_CONCURRENCY,
|
|
2537
|
+
},
|
|
2538
|
+
}),
|
|
2289
2539
|
});
|
|
2290
|
-
}, [getConfig, handleProcessing, reset]);
|
|
2540
|
+
}, [getConfig, handleProcessing, reset, options?.concurrency]);
|
|
2291
2541
|
if (isOptionsWithItems(options)) {
|
|
2292
2542
|
return [{ ...rest, isProcessing, reset, tasks }, handleDispatch];
|
|
2293
2543
|
}
|
|
@@ -2879,6 +3129,10 @@ const DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT = {
|
|
|
2879
3129
|
};
|
|
2880
3130
|
},
|
|
2881
3131
|
tableColumnProgressHeader: 'Progress',
|
|
3132
|
+
enumeratingMessage: 'Listing folder contents…',
|
|
3133
|
+
enumerationErrorMessage: 'Failed to list folder contents. Click Download to try again.',
|
|
3134
|
+
noFilesMessage: 'The selected folders contain no files to download.',
|
|
3135
|
+
tooManyFilesMessage: 'The selection exceeds the maximum of 5000 files for a single download. Download folders in smaller batches.',
|
|
2882
3136
|
};
|
|
2883
3137
|
|
|
2884
3138
|
const DEFAULT_STORAGE_BROWSER_DISPLAY_TEXT = {
|
|
@@ -3658,6 +3912,8 @@ const componentsDefault = {
|
|
|
3658
3912
|
Title,
|
|
3659
3913
|
};
|
|
3660
3914
|
|
|
3915
|
+
const VERSION = '3.18.0';
|
|
3916
|
+
|
|
3661
3917
|
const Fallback = () => (React__namespace["default"].createElement("div", { className: STORAGE_BROWSER_BLOCK_TO_BE_UPDATED },
|
|
3662
3918
|
React__namespace["default"].createElement("div", { className: `${STORAGE_BROWSER_BLOCK_TO_BE_UPDATED}__error-boundary` }, "Something went wrong.")));
|
|
3663
3919
|
class ErrorBoundary extends React__namespace["default"].Component {
|
|
@@ -4435,7 +4691,7 @@ const createDeleteConfirmationModalProps = ({ items, showConfirmation, displayTe
|
|
|
4435
4691
|
* This prevents expensive operations on very large folders
|
|
4436
4692
|
*/
|
|
4437
4693
|
const MAX_FILE_COUNT_LIMIT = 5000;
|
|
4438
|
-
const LIST_PAGE_SIZE = 1000;
|
|
4694
|
+
const LIST_PAGE_SIZE$1 = 1000;
|
|
4439
4695
|
/**
|
|
4440
4696
|
* Count the total number of files in a folder with pagination and limits
|
|
4441
4697
|
* @param folderKey - The folder path to count files in
|
|
@@ -4457,7 +4713,7 @@ const countFilesInFolder = async (folderKey, config) => {
|
|
|
4457
4713
|
locationCredentialsProvider: credentials,
|
|
4458
4714
|
expectedBucketOwner: accountId,
|
|
4459
4715
|
customEndpoint,
|
|
4460
|
-
pageSize: LIST_PAGE_SIZE,
|
|
4716
|
+
pageSize: LIST_PAGE_SIZE$1,
|
|
4461
4717
|
nextToken,
|
|
4462
4718
|
},
|
|
4463
4719
|
});
|
|
@@ -5809,11 +6065,51 @@ DeleteView.Title = TitleControl;
|
|
|
5809
6065
|
|
|
5810
6066
|
function DownloadViewProvider({ children, ...props }) {
|
|
5811
6067
|
const { DownloadView: displayText } = useDisplayText();
|
|
5812
|
-
const { actionCancelLabel, actionExitLabel, actionStartLabel, title, statusDisplayCanceledLabel, statusDisplayCompletedLabel, statusDisplayFailedLabel, statusDisplayQueuedLabel, getActionCompleteMessage, } = displayText;
|
|
5813
|
-
const { isProcessing, isProcessingComplete, statusCounts, tasks: items, onActionCancel, onActionStart,
|
|
5814
|
-
const
|
|
5815
|
-
|
|
5816
|
-
|
|
6068
|
+
const { actionCancelLabel, actionExitLabel, actionStartLabel, title, statusDisplayCanceledLabel, statusDisplayCompletedLabel, statusDisplayFailedLabel, statusDisplayQueuedLabel, getActionCompleteMessage, enumeratingMessage, enumerationErrorMessage, noFilesMessage, tooManyFilesMessage, } = displayText;
|
|
6069
|
+
const { isProcessing, isProcessingComplete, enumerationStatus, hasFilesToDownload, hasSelection, statusCounts, tasks: items, onActionCancel, onActionStart, onTaskRemove, onActionExit, } = props;
|
|
6070
|
+
const isEnumerationPending = enumerationStatus === 'PENDING';
|
|
6071
|
+
const isEnumerationSucceeded = enumerationStatus === 'SUCCEEDED';
|
|
6072
|
+
const isOverFileLimit = enumerationStatus === 'OVER_LIMIT';
|
|
6073
|
+
// Surface the no-files message when the READY set is empty, covering BOTH
|
|
6074
|
+
// empty states with one expression:
|
|
6075
|
+
// - enumeration succeeded but found only empty folders, and
|
|
6076
|
+
// - the ready set went empty because the user manually removed every row
|
|
6077
|
+
// (mirrors the Start-disable gate below).
|
|
6078
|
+
// `'SUCCEEDED'` keeps the pending/error/over-limit statuses owning the
|
|
6079
|
+
// message via the precedence order; the processing guards keep an active or
|
|
6080
|
+
// completed download owning it. `hasSelection` scopes the message to a
|
|
6081
|
+
// selection that is or was non-empty, so a bare mount with an empty
|
|
6082
|
+
// selection (vacuously ready, nothing to download) shows no message.
|
|
6083
|
+
const showNoFiles = hasSelection &&
|
|
6084
|
+
isEnumerationSucceeded &&
|
|
6085
|
+
!hasFilesToDownload &&
|
|
6086
|
+
!isProcessing &&
|
|
6087
|
+
!isProcessingComplete;
|
|
6088
|
+
// Message precedence (most transient/actionable first):
|
|
6089
|
+
// 1. 'PENDING' -> "listing folder contents" (info)
|
|
6090
|
+
// 2. 'ERROR' -> failure + retry hint (error)
|
|
6091
|
+
// 3. 'OVER_LIMIT' -> selection exceeds the file cap (error)
|
|
6092
|
+
// 4. showNoFiles -> empty folders OR manually-emptied set (info)
|
|
6093
|
+
// 5. isProcessingComplete-> post-download summary (existing)
|
|
6094
|
+
// 6. otherwise -> no message
|
|
6095
|
+
// Ordering matters: the enumeration statuses are pre-dispatch and mutually
|
|
6096
|
+
// exclusive with a completed download, so an earlier match short-circuits.
|
|
6097
|
+
const message = isEnumerationPending
|
|
6098
|
+
? { content: enumeratingMessage, type: 'info' }
|
|
6099
|
+
: enumerationStatus === 'ERROR'
|
|
6100
|
+
? { content: enumerationErrorMessage, type: 'error' }
|
|
6101
|
+
: isOverFileLimit
|
|
6102
|
+
? { content: tooManyFilesMessage, type: 'error' }
|
|
6103
|
+
: showNoFiles
|
|
6104
|
+
? { content: noFilesMessage, type: 'info' }
|
|
6105
|
+
: isProcessingComplete
|
|
6106
|
+
? getActionCompleteMessage({ counts: statusCounts })
|
|
6107
|
+
: undefined;
|
|
6108
|
+
// `'NOT_STARTED'` and `'ERROR'` are the not-ready/partial statuses. They are
|
|
6109
|
+
// deliberately NOT added to `isActionStartDisabled`: the no-partial-dispatch
|
|
6110
|
+
// invariant is enforced inside the hook's `onActionStart` (guarded dispatch)
|
|
6111
|
+
// so the Start button stays CLICKABLE in those statuses and re-clicking
|
|
6112
|
+
// Start acts as the enumeration RETRY trigger.
|
|
5817
6113
|
const tableData = useResolveTableData(DOWNLOAD_TABLE_KEYS, DOWNLOAD_TABLE_RESOLVERS, {
|
|
5818
6114
|
items,
|
|
5819
6115
|
props: { displayText, isProcessing, onTaskRemove },
|
|
@@ -5822,9 +6118,21 @@ function DownloadViewProvider({ children, ...props }) {
|
|
|
5822
6118
|
actionCancelLabel,
|
|
5823
6119
|
actionExitLabel,
|
|
5824
6120
|
actionStartLabel,
|
|
5825
|
-
isActionCancelDisabled: !isProcessing || isProcessingComplete,
|
|
5826
|
-
isActionExitDisabled: isProcessing,
|
|
5827
|
-
isActionStartDisabled: isProcessing ||
|
|
6121
|
+
isActionCancelDisabled: (!isProcessing || isProcessingComplete) && !isEnumerationPending,
|
|
6122
|
+
isActionExitDisabled: isProcessing || isEnumerationPending,
|
|
6123
|
+
isActionStartDisabled: isProcessing ||
|
|
6124
|
+
isProcessingComplete ||
|
|
6125
|
+
isEnumerationPending ||
|
|
6126
|
+
// The selection exceeds the file cap: retrying cannot succeed without
|
|
6127
|
+
// changing the selection, so Start is hard-disabled (unlike the
|
|
6128
|
+
// 'ERROR' status, where Start doubles as the retry trigger).
|
|
6129
|
+
isOverFileLimit ||
|
|
6130
|
+
// Every row was removed (or the ready set is otherwise empty, e.g.
|
|
6131
|
+
// only empty folders were selected): nothing to download. Scoped to
|
|
6132
|
+
// `'SUCCEEDED'` so this NEVER disables Start in the
|
|
6133
|
+
// 'NOT_STARTED'/'ERROR' statuses, where a clickable Start is the
|
|
6134
|
+
// enumeration RETRY trigger (empty resolvedItems is expected there).
|
|
6135
|
+
(isEnumerationSucceeded && !hasFilesToDownload),
|
|
5828
6136
|
statusDisplayCanceledLabel,
|
|
5829
6137
|
statusDisplayCompletedLabel,
|
|
5830
6138
|
statusDisplayFailedLabel,
|
|
@@ -5836,24 +6144,530 @@ function DownloadViewProvider({ children, ...props }) {
|
|
|
5836
6144
|
}, onActionStart: onActionStart, onActionExit: onActionExit, onActionCancel: onActionCancel }, children));
|
|
5837
6145
|
}
|
|
5838
6146
|
|
|
6147
|
+
/**
|
|
6148
|
+
* Page size for the recursive `list()` used to expand a folder into its files.
|
|
6149
|
+
* Mirrors the value used by DeleteView's `countFilesInFolder`.
|
|
6150
|
+
*/
|
|
6151
|
+
const LIST_PAGE_SIZE = 1000;
|
|
6152
|
+
/**
|
|
6153
|
+
* Hard cap on the combined number of files a single download may contain,
|
|
6154
|
+
* counted across every folder (and loose file) in the selection. Folder
|
|
6155
|
+
* expansion stops paginating as soon as the running total would exceed this
|
|
6156
|
+
* value and throws {@link FileLimitError}; the view then surfaces a blocked
|
|
6157
|
+
* state instead of downloading a truncated set (silent truncation would be
|
|
6158
|
+
* data loss). Mirrors the 5000 threshold DeleteView uses for its file count.
|
|
6159
|
+
*/
|
|
6160
|
+
const LARGE_DOWNLOAD_FILE_COUNT = 5000;
|
|
6161
|
+
/**
|
|
6162
|
+
* Thrown by {@link expandFolderToFiles} when the combined expanded file count
|
|
6163
|
+
* of the selection exceeds {@link LARGE_DOWNLOAD_FILE_COUNT}. Callers use it
|
|
6164
|
+
* to distinguish the over-limit state from enumeration failures.
|
|
6165
|
+
*/
|
|
6166
|
+
class FileLimitError extends Error {
|
|
6167
|
+
constructor() {
|
|
6168
|
+
super(`Download selection exceeds the maximum of ${LARGE_DOWNLOAD_FILE_COUNT} files`);
|
|
6169
|
+
this.name = 'FileLimitError';
|
|
6170
|
+
}
|
|
6171
|
+
}
|
|
6172
|
+
/**
|
|
6173
|
+
* Computes the base name for a multi-file zip archive from the flat list of
|
|
6174
|
+
* file keys being zipped. Used for file-only, multi-item, and mixed
|
|
6175
|
+
* selections; a selection of exactly one folder is named after that folder
|
|
6176
|
+
* instead (see {@link resolveArchiveName}).
|
|
6177
|
+
*
|
|
6178
|
+
* Rule: the name is the last path segment of the LONGEST COMMON ANCESTOR
|
|
6179
|
+
* DIRECTORY of all files. For each key the directory segments are
|
|
6180
|
+
* `key.split('/')` without the final basename; the longest common prefix of
|
|
6181
|
+
* those segment arrays is taken, and its last element is the name. When the
|
|
6182
|
+
* common prefix is empty (root-level files or files with no shared ancestor)
|
|
6183
|
+
* this falls back to `'download'`.
|
|
6184
|
+
*
|
|
6185
|
+
* Examples:
|
|
6186
|
+
* - ['public/nested/one/pic.jpg', 'public/nested/two/pic.jpg'] -> 'nested'
|
|
6187
|
+
* - ['public/nested/a.jpg', 'public/images/b.jpg', 'public/c.jpg'] -> 'public'
|
|
6188
|
+
* - ['photos/a.jpg', 'photos/b.jpg'] -> 'photos'
|
|
6189
|
+
* - ['a.jpg', 'b.jpg'] -> 'download'
|
|
6190
|
+
* - ['public/x.jpg', 'other/y.jpg'] -> 'download'
|
|
6191
|
+
* - [] -> 'download'
|
|
6192
|
+
*
|
|
6193
|
+
* Pure/synchronous; the base name only — the handler appends `.zip`.
|
|
6194
|
+
*/
|
|
6195
|
+
const getArchiveName = (fileKeys) => {
|
|
6196
|
+
if (fileKeys.length === 0) {
|
|
6197
|
+
return 'download';
|
|
6198
|
+
}
|
|
6199
|
+
// Directory segments per file = path split without the final basename.
|
|
6200
|
+
const dirSegments = fileKeys.map((key) => key.split('/').slice(0, -1));
|
|
6201
|
+
// Longest common prefix across every file's directory segment array.
|
|
6202
|
+
const [first, ...rest] = dirSegments;
|
|
6203
|
+
let commonLength = first.length;
|
|
6204
|
+
for (const segments of rest) {
|
|
6205
|
+
let i = 0;
|
|
6206
|
+
while (i < commonLength &&
|
|
6207
|
+
i < segments.length &&
|
|
6208
|
+
segments[i] === first[i]) {
|
|
6209
|
+
i += 1;
|
|
6210
|
+
}
|
|
6211
|
+
commonLength = i;
|
|
6212
|
+
if (commonLength === 0) {
|
|
6213
|
+
break;
|
|
6214
|
+
}
|
|
6215
|
+
}
|
|
6216
|
+
// Last segment of the common ancestor dir; empty prefix OR an empty segment
|
|
6217
|
+
// (e.g. a key like 'a//b.jpg') -> 'download'.
|
|
6218
|
+
const name = commonLength === 0 ? '' : first[commonLength - 1];
|
|
6219
|
+
return name === '' ? 'download' : name;
|
|
6220
|
+
};
|
|
6221
|
+
/**
|
|
6222
|
+
* Resolves the zip archive base name for the current SELECTION.
|
|
6223
|
+
*
|
|
6224
|
+
* When the selection consists of exactly ONE FOLDER (no other items), the
|
|
6225
|
+
* archive is named after that folder (basename of `folder.key`, trailing
|
|
6226
|
+
* slash stripped): the download was initiated from that folder, so selecting
|
|
6227
|
+
* `photos/` yields `photos.zip` even when every file lives in a deeper
|
|
6228
|
+
* subfolder like `photos/vacation/`. Every other selection shape (file-only,
|
|
6229
|
+
* multi-folder, mixed) falls back to the longest-common-ancestor rule of
|
|
6230
|
+
* {@link getArchiveName}.
|
|
6231
|
+
*/
|
|
6232
|
+
const resolveArchiveName = (dataItems, fileKeys) => {
|
|
6233
|
+
if (dataItems.length === 1 && dataItems[0].type === 'FOLDER') {
|
|
6234
|
+
const basename = dataItems[0].key.replace(/\/$/, '').split('/').pop();
|
|
6235
|
+
if (basename) {
|
|
6236
|
+
return basename;
|
|
6237
|
+
}
|
|
6238
|
+
}
|
|
6239
|
+
return getArchiveName(fileKeys);
|
|
6240
|
+
};
|
|
6241
|
+
/**
|
|
6242
|
+
* Recursively expands a folder into the flat list of downloadable files it
|
|
6243
|
+
* contains, preserving each file's folder-relative zip path.
|
|
6244
|
+
*
|
|
6245
|
+
* Clones the pagination loop from DeleteView's `countFilesInFolder`, but:
|
|
6246
|
+
* - collects {@link DownloadHandlerData} items instead of counting,
|
|
6247
|
+
* - stops paginating and throws {@link FileLimitError} once the shared
|
|
6248
|
+
* `fileCounter` would exceed {@link LARGE_DOWNLOAD_FILE_COUNT} (the caller
|
|
6249
|
+
* surfaces a blocked state; a truncated zip is never produced),
|
|
6250
|
+
* - filters out directory markers (keys ending in `'/'`),
|
|
6251
|
+
* - is cancellable via `signal`, checked between `list()` pages.
|
|
6252
|
+
*/
|
|
6253
|
+
const expandFolderToFiles = async ({ folderKey, config, locationPrefix, signal, fileCounter = { count: 0 }, }) => {
|
|
6254
|
+
const { accountId, credentials, customEndpoint } = config;
|
|
6255
|
+
const bucket = constructBucket$1(config);
|
|
6256
|
+
const files = [];
|
|
6257
|
+
let nextToken;
|
|
6258
|
+
do {
|
|
6259
|
+
// Abort is checked between pages (and before the first page) so a cancel
|
|
6260
|
+
// during enumeration stops promptly without emitting a partial result.
|
|
6261
|
+
if (signal?.aborted) {
|
|
6262
|
+
throw new DOMException('Folder expansion aborted', 'AbortError');
|
|
6263
|
+
}
|
|
6264
|
+
// Short-circuit between pages when the SHARED counter already exceeds the
|
|
6265
|
+
// cap (e.g. a sibling folder's expansion pushed it over while this one
|
|
6266
|
+
// awaited `list()`): no point paginating a selection that is blocked.
|
|
6267
|
+
if (fileCounter.count > LARGE_DOWNLOAD_FILE_COUNT) {
|
|
6268
|
+
throw new FileLimitError();
|
|
6269
|
+
}
|
|
6270
|
+
const { items, nextToken: listNextToken } = await internals.list({
|
|
6271
|
+
path: folderKey,
|
|
6272
|
+
options: {
|
|
6273
|
+
bucket,
|
|
6274
|
+
locationCredentialsProvider: credentials,
|
|
6275
|
+
expectedBucketOwner: accountId,
|
|
6276
|
+
customEndpoint,
|
|
6277
|
+
pageSize: LIST_PAGE_SIZE,
|
|
6278
|
+
nextToken,
|
|
6279
|
+
},
|
|
6280
|
+
});
|
|
6281
|
+
for (const item of items) {
|
|
6282
|
+
// Skip directory markers (zero-byte keys ending in '/') — only real files
|
|
6283
|
+
// become zip entries.
|
|
6284
|
+
if (item.path.endsWith('/')) {
|
|
6285
|
+
continue;
|
|
6286
|
+
}
|
|
6287
|
+
// Enforce the cap per file so the loop stops as soon as the combined
|
|
6288
|
+
// selection exceeds it, mid-page included.
|
|
6289
|
+
fileCounter.count += 1;
|
|
6290
|
+
if (fileCounter.count > LARGE_DOWNLOAD_FILE_COUNT) {
|
|
6291
|
+
throw new FileLimitError();
|
|
6292
|
+
}
|
|
6293
|
+
const fileData = {
|
|
6294
|
+
key: item.path,
|
|
6295
|
+
id: crypto.randomUUID(),
|
|
6296
|
+
// `list()` may omit size/lastModified; fall back defensively instead
|
|
6297
|
+
// of asserting non-null.
|
|
6298
|
+
size: item.size ?? 0,
|
|
6299
|
+
lastModified: item.lastModified ?? new Date(0),
|
|
6300
|
+
eTag: item.eTag,
|
|
6301
|
+
type: 'FILE',
|
|
6302
|
+
};
|
|
6303
|
+
files.push(createDownloadItem(fileData, locationPrefix));
|
|
6304
|
+
}
|
|
6305
|
+
nextToken = listNextToken;
|
|
6306
|
+
} while (nextToken);
|
|
6307
|
+
return files;
|
|
6308
|
+
};
|
|
6309
|
+
|
|
5839
6310
|
// assign to constant to ensure referential equality
|
|
5840
6311
|
const EMPTY_ITEMS = [];
|
|
6312
|
+
// Referentially-stable empty set used as the "no removals" sentinel so a
|
|
6313
|
+
// selection change can filter with an empty set without allocating.
|
|
6314
|
+
const EMPTY_SET = new Set();
|
|
6315
|
+
/**
|
|
6316
|
+
* Drops rows the user removed via `onTaskRemove`, keyed by the item's stable
|
|
6317
|
+
* id. Needed because folder-EXPANDED file rows carry ids minted during
|
|
6318
|
+
* enumeration that never exist in `dataItems`, so the REMOVE_LOCATION_ITEM
|
|
6319
|
+
* reducer path no-ops for them — this id filter is what actually removes them.
|
|
6320
|
+
*/
|
|
6321
|
+
const filterRemoved = (items, removedIds) => removedIds.size === 0
|
|
6322
|
+
? items
|
|
6323
|
+
: items.filter((item) => !removedIds.has(item.id));
|
|
6324
|
+
/**
|
|
6325
|
+
* Builds the flat list of download items from the current selection:
|
|
6326
|
+
* loose files become download items directly; folders contribute their
|
|
6327
|
+
* already-expanded files from `cache` (empty until enumeration runs).
|
|
6328
|
+
*/
|
|
6329
|
+
const buildDownloadItems = (dataItems, prefix, cache) => {
|
|
6330
|
+
const items = [];
|
|
6331
|
+
for (const item of dataItems) {
|
|
6332
|
+
if (item.type === 'FILE') {
|
|
6333
|
+
items.push(createDownloadItem(item, prefix));
|
|
6334
|
+
}
|
|
6335
|
+
else {
|
|
6336
|
+
const expanded = cache.get(item.id);
|
|
6337
|
+
if (expanded) {
|
|
6338
|
+
items.push(...expanded);
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
}
|
|
6342
|
+
return items;
|
|
6343
|
+
};
|
|
6344
|
+
/**
|
|
6345
|
+
* Resolves the EFFECTIVE download set for the current selection: builds the
|
|
6346
|
+
* flat item list, drops user-removed rows, then names the zip archive from
|
|
6347
|
+
* the POST-removal set.
|
|
6348
|
+
*
|
|
6349
|
+
* The archive name is computed here, in the view — NOT in the handler — and
|
|
6350
|
+
* stamped uniformly onto every item so the zip handler can read it from
|
|
6351
|
+
* `all[0].archiveName`. It MUST be computed after `filterRemoved` so the name
|
|
6352
|
+
* reflects the files actually being downloaded (removing rows can shift the
|
|
6353
|
+
* common ancestor). A single-folder SELECTION is still named after that
|
|
6354
|
+
* folder — the selection (`dataItems`) is unaffected by per-row removal, only
|
|
6355
|
+
* the LCA input (file keys) is post-filter; every other shape uses the common
|
|
6356
|
+
* ancestor directory of the remaining files (see resolveArchiveName).
|
|
6357
|
+
*/
|
|
6358
|
+
const resolveDownloadItems = ({ dataItems, prefix, cache, removedIds, }) => {
|
|
6359
|
+
const items = filterRemoved(buildDownloadItems(dataItems, prefix, cache), removedIds);
|
|
6360
|
+
const archiveName = resolveArchiveName(dataItems, items.map((i) => i.key));
|
|
6361
|
+
return items.map((i) => ({ ...i, archiveName }));
|
|
6362
|
+
};
|
|
5841
6363
|
const useDownloadView = (options) => {
|
|
5842
6364
|
const { onExit: _onExit } = options ?? {};
|
|
5843
6365
|
const [{ location }, storeDispatch] = useStore();
|
|
5844
6366
|
const [locationItems, locationItemsDispatch] = useLocationItems();
|
|
5845
6367
|
const { current } = location;
|
|
5846
|
-
const {
|
|
6368
|
+
const { dataItems = EMPTY_ITEMS } = locationItems;
|
|
6369
|
+
const getConfig = useGetActionInput();
|
|
6370
|
+
// Cache of folder id -> expanded download items, mirroring DeleteView's
|
|
6371
|
+
// `folderCountsRef`. Survives re-renders so re-expanding is avoided.
|
|
6372
|
+
// Intentional per-session cache: entries are keyed by the (stable, per-mount)
|
|
6373
|
+
// folder id and are never individually invalidated — the whole ref is
|
|
6374
|
+
// discarded when the view unmounts on exit (RESET_LOCATION_ITEMS), which is
|
|
6375
|
+
// the only path that changes the underlying location. This matches
|
|
6376
|
+
// DeleteView's `folderCountsRef` semantics.
|
|
6377
|
+
const folderExpansionRef = React__namespace["default"].useRef(new Map());
|
|
6378
|
+
// AbortController for the in-flight enumeration (cancellable pre-dispatch).
|
|
6379
|
+
const enumAbortRef = React__namespace["default"].useRef(null);
|
|
6380
|
+
// Tracks the last selection (id SET of dataItems + current) the sync effect
|
|
6381
|
+
// ran for, so it can tell a GENUINE selection change (which resets stale
|
|
6382
|
+
// flags and the per-row removal set) apart from a within-selection row
|
|
6383
|
+
// removal or a re-run triggered solely by a `removedItemIds` update (which
|
|
6384
|
+
// must NOT reset the removals it just applied). The id SET — not the array
|
|
6385
|
+
// reference — is stored because the reducer rebuilds the `dataItems` array
|
|
6386
|
+
// on a LOOSE-row removal (same selection minus a row), so identity alone
|
|
6387
|
+
// can't distinguish removal from re-selection (see the subset check in the
|
|
6388
|
+
// sync effect below).
|
|
6389
|
+
const prevSelectionRef = React__namespace["default"].useRef({ dataItemIds: new Set(dataItems.map((item) => item.id)), current });
|
|
6390
|
+
const [resolvedItems, setResolvedItems] = React__namespace["default"].useState([]);
|
|
6391
|
+
// `true` while folder selections are being expanded into their files. Drives
|
|
6392
|
+
// the `'PENDING'` enumeration status, which disables Start until enumeration
|
|
6393
|
+
// settles.
|
|
6394
|
+
const [isEnumerating, setIsEnumerating] = React__namespace["default"].useState(false);
|
|
6395
|
+
// `true` when the pre-dispatch enumeration failed for a non-abort reason.
|
|
6396
|
+
// Surfaced on the view model so the Start control re-enabling isn't the only
|
|
6397
|
+
// (silent) feedback the user gets on failure.
|
|
6398
|
+
const [isEnumerationError, setIsEnumerationError] = React__namespace["default"].useState(false);
|
|
6399
|
+
// `true` when the combined expanded file count of the selection exceeded
|
|
6400
|
+
// LARGE_DOWNLOAD_FILE_COUNT during enumeration. A truncated zip would be
|
|
6401
|
+
// silent data loss, so this state BLOCKS dispatch entirely (same invariant
|
|
6402
|
+
// as `allFoldersReady`) and the view surfaces an explanatory message.
|
|
6403
|
+
const [isOverFileLimit, setIsOverFileLimit] = React__namespace["default"].useState(false);
|
|
6404
|
+
// Retry counter; bumping re-runs the enumeration effect for still-uncached
|
|
6405
|
+
// folders.
|
|
6406
|
+
const [enumAttempt, setEnumAttempt] = React__namespace["default"].useState(0);
|
|
6407
|
+
// Stable ids of rows the user removed via `onTaskRemove`. Folder-EXPANDED
|
|
6408
|
+
// file rows can't be removed through the locationItems reducer (their ids
|
|
6409
|
+
// aren't in `dataItems`, so REMOVE_LOCATION_ITEM no-ops), so we track removals
|
|
6410
|
+
// here and filter `resolvedItems` by them. Reset on a GENUINE selection change
|
|
6411
|
+
// (a new id or location change — NOT a within-selection row removal) so
|
|
6412
|
+
// removals don't leak into a new selection (see the sync effect below).
|
|
6413
|
+
const [removedItemIds, setRemovedItemIds] = React__namespace["default"].useState(() => new Set());
|
|
6414
|
+
// Latest-value mirror of `removedItemIds` so the async enumeration closure can
|
|
6415
|
+
// read the current removals WITHOUT `removedItemIds` becoming an enumeration
|
|
6416
|
+
// dep (which would abort/re-run enumeration on every row removal). Mirrors the
|
|
6417
|
+
// `callbacksRef` pattern in useProcessTasks.
|
|
6418
|
+
const removedItemIdsRef = React__namespace["default"].useRef(removedItemIds);
|
|
6419
|
+
removedItemIdsRef.current = removedItemIds;
|
|
6420
|
+
const hasFolders = hasSelectedFolders(dataItems);
|
|
6421
|
+
// `true` once the selection has been non-empty at any point in this mount.
|
|
6422
|
+
// Sticky on purpose: removing every row empties `dataItems` for a loose-file
|
|
6423
|
+
// selection, and the "no files" message must still show in that manually
|
|
6424
|
+
// -emptied state, while a bare mount with no selection must NOT show it.
|
|
6425
|
+
const hadSelectionRef = React__namespace["default"].useRef(false);
|
|
6426
|
+
if (dataItems.length > 0) {
|
|
6427
|
+
hadSelectionRef.current = true;
|
|
6428
|
+
}
|
|
6429
|
+
const hasSelection = hadSelectionRef.current;
|
|
6430
|
+
// `resolvedItems` (not the raw selection) is what `useAction` turns into
|
|
6431
|
+
// tasks. Keep it in sync with the selection + expansion cache so item
|
|
6432
|
+
// removal (onTaskRemove) stays consistent. For a file-only selection this
|
|
6433
|
+
// fully populates `resolvedItems` on mount (no enumeration needed); for
|
|
6434
|
+
// folders it seeds any already-expanded (cached) files and the enumeration
|
|
6435
|
+
// effect below fills in the rest.
|
|
6436
|
+
React__namespace["default"].useEffect(() => {
|
|
6437
|
+
const prefix = current?.prefix ?? '';
|
|
6438
|
+
// Distinguish a GENUINE selection change (new/changed selection or
|
|
6439
|
+
// location change) from a within-selection row removal or a re-run
|
|
6440
|
+
// triggered solely by a `removedItemIds` update. Only a real selection
|
|
6441
|
+
// change should clear stale pre-dispatch flags and the per-row removal
|
|
6442
|
+
// set — resetting on anything else would resurrect rows the user just
|
|
6443
|
+
// removed. `dataItems` identity is NOT a reliable signal: removing a
|
|
6444
|
+
// LOOSE row rebuilds the array (same selection minus a row), and the
|
|
6445
|
+
// `SET_LOCATION_ITEMS` reducer `.concat`s a NEW reference even when
|
|
6446
|
+
// re-selecting the SAME items. Compare by id set instead: when the new id
|
|
6447
|
+
// set is a SUBSET of the previous one (no NEW id), rows were only removed
|
|
6448
|
+
// (or nothing changed), so removals — which for folder-EXPANDED rows live
|
|
6449
|
+
// ONLY in `removedItemIds` (the reducer no-ops for their ids) — must
|
|
6450
|
+
// carry over. Only a NEW id (or a `current` change) marks a genuine
|
|
6451
|
+
// selection change. Consequence vs. the previous identity compare:
|
|
6452
|
+
// re-selecting the identical id set no longer resets removals, so
|
|
6453
|
+
// previously-removed rows STAY removed instead of re-appearing.
|
|
6454
|
+
const prev = prevSelectionRef.current;
|
|
6455
|
+
const dataItemIds = new Set();
|
|
6456
|
+
let hasNewId = false;
|
|
6457
|
+
for (const item of dataItems) {
|
|
6458
|
+
dataItemIds.add(item.id);
|
|
6459
|
+
if (!prev.dataItemIds.has(item.id)) {
|
|
6460
|
+
hasNewId = true;
|
|
6461
|
+
}
|
|
6462
|
+
}
|
|
6463
|
+
const selectionChanged = prev.current !== current || hasNewId;
|
|
6464
|
+
prevSelectionRef.current = { dataItemIds, current };
|
|
6465
|
+
// On a genuine selection change no prior removals carry over; otherwise
|
|
6466
|
+
// apply the current removal set. (Removing a LOOSE file also mutates
|
|
6467
|
+
// dataItems, but its id set stays a subset of the previous one, so it
|
|
6468
|
+
// correctly does NOT read as a selection change: the reducer prunes the
|
|
6469
|
+
// loose row from dataItems while `removedItemIds` keeps previously-removed
|
|
6470
|
+
// folder-EXPANDED rows hidden — those no-op in the reducer, so dataItems is
|
|
6471
|
+
// unchanged for them and this filter is what removes them.)
|
|
6472
|
+
const effectiveRemovedIds = selectionChanged ? EMPTY_SET : removedItemIds;
|
|
6473
|
+
setResolvedItems(resolveDownloadItems({
|
|
6474
|
+
dataItems,
|
|
6475
|
+
prefix,
|
|
6476
|
+
cache: folderExpansionRef.current,
|
|
6477
|
+
removedIds: effectiveRemovedIds,
|
|
6478
|
+
}));
|
|
6479
|
+
if (selectionChanged) {
|
|
6480
|
+
// Selection changed: clear stale pre-dispatch flags so a prior error
|
|
6481
|
+
// /over-limit result doesn't leak into the new selection.
|
|
6482
|
+
setIsEnumerationError(false);
|
|
6483
|
+
setIsOverFileLimit(false);
|
|
6484
|
+
// Clear per-row removals so a prior selection's removals don't hide items
|
|
6485
|
+
// in the new selection (no-op when already empty to avoid a needless
|
|
6486
|
+
// re-render/effect loop).
|
|
6487
|
+
setRemovedItemIds((prev) => (prev.size === 0 ? prev : new Set()));
|
|
6488
|
+
}
|
|
6489
|
+
}, [dataItems, current, removedItemIds]);
|
|
6490
|
+
// Auto-run folder enumeration on mount and whenever the selection changes.
|
|
6491
|
+
//
|
|
6492
|
+
// WHY ON MOUNT (not gated behind Start): the view renders its rows from
|
|
6493
|
+
// `resolvedItems` -> useAction `tasks`. A FOLDER contributes files only from
|
|
6494
|
+
// the expansion cache, which is empty on mount, so a folder selection would
|
|
6495
|
+
// otherwise render zero rows (and log nothing) until Start was clicked.
|
|
6496
|
+
// Expanding eagerly here — mirroring DeleteView's `initializeFolderCounts`
|
|
6497
|
+
// mount effect — resolves the files so the rows render as soon as the view
|
|
6498
|
+
// opens.
|
|
6499
|
+
//
|
|
6500
|
+
// WHY DISPATCH IS DECOUPLED FROM ENUMERATION: this effect only populates
|
|
6501
|
+
// `resolvedItems`; it MUST NOT auto-start the download. The zip is triggered
|
|
6502
|
+
// solely by the user clicking Start (`onActionStart` -> `handleProcess`). By
|
|
6503
|
+
// the time Start is enabled, `resolvedItems` has already synced into
|
|
6504
|
+
// useAction's `tasksRef`, so the previous "set state + dispatch in one tick"
|
|
6505
|
+
// sequencing hack is no longer required.
|
|
6506
|
+
React__namespace["default"].useEffect(() => {
|
|
6507
|
+
if (!hasFolders || !current) {
|
|
6508
|
+
// A prior in-flight enumeration may have been aborted by this effect's
|
|
6509
|
+
// cleanup (selection change); its catch is now a no-op, so clear the flag
|
|
6510
|
+
// defensively here to ensure `isEnumerating` can't stick true (which would
|
|
6511
|
+
// also trap Exit). React bails on same-value setState, so this is a no-op
|
|
6512
|
+
// in the normal file-only / already-idle paths and can't loop.
|
|
6513
|
+
setIsEnumerating(false);
|
|
6514
|
+
return;
|
|
6515
|
+
}
|
|
6516
|
+
// Only expand folders we haven't already cached (cache is keyed by the
|
|
6517
|
+
// stable folder id). If every selected folder is already cached, the sync
|
|
6518
|
+
// effect above has rebuilt `resolvedItems` from the cache and there's
|
|
6519
|
+
// nothing to enumerate — avoids a spurious enumerating flash and re-runs.
|
|
6520
|
+
const foldersToExpand = dataItems.filter((item) => item.type === 'FOLDER' && !folderExpansionRef.current.has(item.id));
|
|
6521
|
+
if (foldersToExpand.length === 0) {
|
|
6522
|
+
// Same defensive clear as above: an aborted prior run can't reset the flag.
|
|
6523
|
+
setIsEnumerating(false);
|
|
6524
|
+
return;
|
|
6525
|
+
}
|
|
6526
|
+
const config = getConfig(current);
|
|
6527
|
+
const { prefix } = current;
|
|
6528
|
+
const controller = new AbortController();
|
|
6529
|
+
enumAbortRef.current = controller;
|
|
6530
|
+
setIsEnumerationError(false);
|
|
6531
|
+
setIsOverFileLimit(false);
|
|
6532
|
+
setIsEnumerating(true);
|
|
6533
|
+
// Shared running file total for the LARGE_DOWNLOAD_FILE_COUNT cap. Seeded
|
|
6534
|
+
// with the files already in the selection (loose files plus previously
|
|
6535
|
+
// cached folder expansions) so the cap applies to the COMBINED selection,
|
|
6536
|
+
// then passed to every expansion in this run.
|
|
6537
|
+
const fileCounter = {
|
|
6538
|
+
count: dataItems.reduce((count, item) => {
|
|
6539
|
+
if (item.type === 'FILE')
|
|
6540
|
+
return count + 1;
|
|
6541
|
+
return count + (folderExpansionRef.current.get(item.id)?.length ?? 0);
|
|
6542
|
+
}, 0),
|
|
6543
|
+
};
|
|
6544
|
+
const runEnumeration = async () => {
|
|
6545
|
+
try {
|
|
6546
|
+
await Promise.all(foldersToExpand.map(async (folder) => {
|
|
6547
|
+
const expanded = await expandFolderToFiles({
|
|
6548
|
+
folderKey: folder.key,
|
|
6549
|
+
config,
|
|
6550
|
+
locationPrefix: prefix,
|
|
6551
|
+
signal: controller.signal,
|
|
6552
|
+
fileCounter,
|
|
6553
|
+
});
|
|
6554
|
+
folderExpansionRef.current.set(folder.id, expanded);
|
|
6555
|
+
}));
|
|
6556
|
+
// Cancelled mid-flight — `onActionCancel` (or this effect's cleanup on
|
|
6557
|
+
// selection change / unmount) already aborted. Leave state alone to
|
|
6558
|
+
// avoid a setState race with the newer run.
|
|
6559
|
+
if (controller.signal.aborted)
|
|
6560
|
+
return;
|
|
6561
|
+
const resolved = resolveDownloadItems({
|
|
6562
|
+
dataItems,
|
|
6563
|
+
prefix,
|
|
6564
|
+
cache: folderExpansionRef.current,
|
|
6565
|
+
// Apply any per-row removals the user made before enumeration
|
|
6566
|
+
// settled (the sync effect re-filters on later removals via its
|
|
6567
|
+
// removedItemIds dep, but ref-population here doesn't trigger it, so
|
|
6568
|
+
// filter now too). Read through the ref for the latest value.
|
|
6569
|
+
removedIds: removedItemIdsRef.current,
|
|
6570
|
+
});
|
|
6571
|
+
// NOTE: `resolved` may be empty (only empty folders selected). The
|
|
6572
|
+
// empty folders were still cached above, so the derived enumeration
|
|
6573
|
+
// status flips to `'SUCCEEDED'` with `hasFilesToDownload` false — the
|
|
6574
|
+
// view surfaces the "no files" message from that combination. No zip
|
|
6575
|
+
// is started in this case (Start is disabled on an empty ready set).
|
|
6576
|
+
setResolvedItems(resolved);
|
|
6577
|
+
setIsEnumerating(false);
|
|
6578
|
+
}
|
|
6579
|
+
catch (error) {
|
|
6580
|
+
// Abort surfaces here too; distinguish it from real failures.
|
|
6581
|
+
if (controller.signal.aborted) ;
|
|
6582
|
+
else if (error instanceof FileLimitError) {
|
|
6583
|
+
// The combined selection exceeds LARGE_DOWNLOAD_FILE_COUNT. Abort the
|
|
6584
|
+
// sibling expansions still paginating (their result can never be
|
|
6585
|
+
// dispatched) and surface the blocked state. The over-limit folder was
|
|
6586
|
+
// never cached, so `allFoldersReady` stays false and dispatch is
|
|
6587
|
+
// structurally blocked as well.
|
|
6588
|
+
controller.abort();
|
|
6589
|
+
setIsOverFileLimit(true);
|
|
6590
|
+
setIsEnumerating(false);
|
|
6591
|
+
}
|
|
6592
|
+
else {
|
|
6593
|
+
// No dedicated package logger exists here; `console.error` matches the
|
|
6594
|
+
// convention used elsewhere in StorageBrowser (e.g. validateStoreProps,
|
|
6595
|
+
// useAction). AbortError is expected on cancel and handled above, so
|
|
6596
|
+
// only genuine failures reach this branch.
|
|
6597
|
+
// eslint-disable-next-line no-console
|
|
6598
|
+
console.error('Failed to expand folders for download:', error);
|
|
6599
|
+
setIsEnumerationError(true);
|
|
6600
|
+
setIsEnumerating(false);
|
|
6601
|
+
}
|
|
6602
|
+
}
|
|
6603
|
+
};
|
|
6604
|
+
runEnumeration();
|
|
6605
|
+
// Abort the in-flight enumeration when the selection changes or the view
|
|
6606
|
+
// unmounts, so its `list()` loop stops and no setState-after-unmount occurs.
|
|
6607
|
+
return () => {
|
|
6608
|
+
controller.abort();
|
|
6609
|
+
};
|
|
6610
|
+
}, [dataItems, current, hasFolders, getConfig, enumAttempt]);
|
|
6611
|
+
// Readiness gate: Start may only dispatch once EVERY selected folder has been
|
|
6612
|
+
// expanded into the cache. Files are always ready; a FOLDER is ready only when
|
|
6613
|
+
// folderExpansionRef has cached its expanded items. Recomputed each render —
|
|
6614
|
+
// state changes from enumeration (setResolvedItems/setIsEnumerating) trigger the
|
|
6615
|
+
// re-render that flips this true after the ref is populated.
|
|
6616
|
+
const allFoldersReady = dataItems.every((item) => item.type !== 'FOLDER' || folderExpansionRef.current.has(item.id));
|
|
6617
|
+
// Public enumeration status, DERIVED from the internal flags each render
|
|
6618
|
+
// (never stored — `allFoldersReady` is itself derived from dataItems + the
|
|
6619
|
+
// expansion cache, so storing the union would create a second source of
|
|
6620
|
+
// truth). Precedence: an in-flight run owns the status; then the terminal
|
|
6621
|
+
// error/limit outcomes; then readiness. A file-only selection has no folders
|
|
6622
|
+
// to expand, so it is vacuously ready -> `'SUCCEEDED'` immediately on mount.
|
|
6623
|
+
const enumerationStatus = isEnumerating
|
|
6624
|
+
? 'PENDING'
|
|
6625
|
+
: isEnumerationError
|
|
6626
|
+
? 'ERROR'
|
|
6627
|
+
: isOverFileLimit
|
|
6628
|
+
? 'OVER_LIMIT'
|
|
6629
|
+
: allFoldersReady
|
|
6630
|
+
? 'SUCCEEDED'
|
|
6631
|
+
: 'NOT_STARTED';
|
|
5847
6632
|
const [processState, handleProcess] = useAction('download', {
|
|
5848
|
-
items,
|
|
6633
|
+
items: resolvedItems,
|
|
6634
|
+
concurrency: 1,
|
|
5849
6635
|
});
|
|
5850
6636
|
const { isProcessing, isProcessingComplete, statusCounts, tasks } = processState;
|
|
5851
6637
|
const onActionStart = () => {
|
|
5852
6638
|
if (!current)
|
|
5853
6639
|
return;
|
|
6640
|
+
// Enumeration in flight (Start is disabled anyway) — do nothing.
|
|
6641
|
+
if (isEnumerating)
|
|
6642
|
+
return;
|
|
6643
|
+
// Selection exceeds the file cap: dispatching would produce a truncated
|
|
6644
|
+
// zip (silent data loss) and retrying cannot succeed without changing the
|
|
6645
|
+
// selection, so do nothing (Start is disabled in this state anyway).
|
|
6646
|
+
if (isOverFileLimit)
|
|
6647
|
+
return;
|
|
6648
|
+
// RETRY PATH: a prior enumeration was cancelled or failed, so some selected
|
|
6649
|
+
// folders are still uncached. NEVER dispatch an incomplete set (CORE
|
|
6650
|
+
// INVARIANT). Instead re-trigger enumeration for the uncached folders by
|
|
6651
|
+
// bumping enumAttempt; the user retries simply by clicking Start again.
|
|
6652
|
+
// Dispatch happens on a later click, once allFoldersReady is true.
|
|
6653
|
+
if (!allFoldersReady) {
|
|
6654
|
+
setIsEnumerationError(false);
|
|
6655
|
+
setEnumAttempt((n) => n + 1);
|
|
6656
|
+
return;
|
|
6657
|
+
}
|
|
6658
|
+
// Every selected folder is expanded and resolvedItems is synced into
|
|
6659
|
+
// useAction's tasksRef — safe to dispatch the complete set. Download starts
|
|
6660
|
+
// ONLY here (never auto-started on enumeration completion).
|
|
5854
6661
|
handleProcess();
|
|
5855
6662
|
};
|
|
5856
6663
|
const onActionCancel = () => {
|
|
6664
|
+
// Cancel during the (mount) pre-dispatch enumeration phase: abort the
|
|
6665
|
+
// `list()` loop and return to idle without starting a zip.
|
|
6666
|
+
if (isEnumerating) {
|
|
6667
|
+
enumAbortRef.current?.abort();
|
|
6668
|
+
setIsEnumerating(false);
|
|
6669
|
+
return;
|
|
6670
|
+
}
|
|
5857
6671
|
tasks.forEach((task) => {
|
|
5858
6672
|
// Calling cancel on task works only on queued tasks.
|
|
5859
6673
|
// In case of download, all download presigned url open at once
|
|
@@ -5871,11 +6685,33 @@ const useDownloadView = (options) => {
|
|
|
5871
6685
|
_onExit(current);
|
|
5872
6686
|
};
|
|
5873
6687
|
const onTaskRemove = React__namespace["default"].useCallback(({ data }) => {
|
|
6688
|
+
// Track the removal by the item's STABLE id. Folder-EXPANDED file rows
|
|
6689
|
+
// have ids minted in `expandFolderToFiles` (cached in folderExpansionRef)
|
|
6690
|
+
// that never live in `dataItems`, so REMOVE_LOCATION_ITEM alone no-ops for
|
|
6691
|
+
// them — filtering `resolvedItems` by `removedItemIds` is what removes
|
|
6692
|
+
// those rows. For LOOSE selection items the dispatch still prunes the
|
|
6693
|
+
// selection state (and is a harmless no-op for expanded ids).
|
|
6694
|
+
setRemovedItemIds((prev) => {
|
|
6695
|
+
if (prev.has(data.id))
|
|
6696
|
+
return prev;
|
|
6697
|
+
const next = new Set(prev);
|
|
6698
|
+
next.add(data.id);
|
|
6699
|
+
return next;
|
|
6700
|
+
});
|
|
5874
6701
|
locationItemsDispatch({ type: 'REMOVE_LOCATION_ITEM', id: data.id });
|
|
5875
6702
|
}, [locationItemsDispatch]);
|
|
6703
|
+
// Effective (post-removal) download set is empty -> nothing to download. Used
|
|
6704
|
+
// (with a `'SUCCEEDED'` status) to gate Start in a ready/idle state and to
|
|
6705
|
+
// surface the "no files" message — covering both empty folders detected
|
|
6706
|
+
// during enumeration and a manually-emptied row set (see
|
|
6707
|
+
// DownloadViewProvider).
|
|
6708
|
+
const hasFilesToDownload = resolvedItems.length > 0;
|
|
5876
6709
|
return {
|
|
5877
6710
|
isProcessing,
|
|
5878
6711
|
isProcessingComplete,
|
|
6712
|
+
enumerationStatus,
|
|
6713
|
+
hasFilesToDownload,
|
|
6714
|
+
hasSelection,
|
|
5879
6715
|
location,
|
|
5880
6716
|
statusCounts,
|
|
5881
6717
|
tasks,
|
|
@@ -7090,6 +7926,28 @@ function createProvider({ actions, components, config, options, filePreview = {}
|
|
|
7090
7926
|
return Provider;
|
|
7091
7927
|
}
|
|
7092
7928
|
|
|
7929
|
+
const SW_DOWNLOAD_SCOPE = '/amplify-storage-download/';
|
|
7930
|
+
const SW_URL = '/amplify-storage-download/download-sw.js';
|
|
7931
|
+
function useServiceWorkerRegistration() {
|
|
7932
|
+
React.useEffect(() => {
|
|
7933
|
+
if ('serviceWorker' in navigator) {
|
|
7934
|
+
navigator.serviceWorker
|
|
7935
|
+
.register(SW_URL, { scope: SW_DOWNLOAD_SCOPE })
|
|
7936
|
+
.catch((err) => {
|
|
7937
|
+
// Registration failure is non-critical; the blob fallback handles
|
|
7938
|
+
// downloads. We still surface it: a failed registration is a real
|
|
7939
|
+
// (silent otherwise) degradation, and the most common cause is
|
|
7940
|
+
// forgetting the copy-serviceworker setup step so the SW file isn't
|
|
7941
|
+
// served from the app's public directory.
|
|
7942
|
+
// eslint-disable-next-line no-console
|
|
7943
|
+
console.warn('[StorageBrowser] Download service worker registration failed; ' +
|
|
7944
|
+
'falling back to in-memory blob downloads. Ensure the service ' +
|
|
7945
|
+
'worker file is served (see the copy-serviceworker setup step):', err);
|
|
7946
|
+
});
|
|
7947
|
+
}
|
|
7948
|
+
}, []);
|
|
7949
|
+
}
|
|
7950
|
+
|
|
7093
7951
|
/**
|
|
7094
7952
|
* Handles default `StorageBrowser` behavior:
|
|
7095
7953
|
* - render `LocationsView` on init
|
|
@@ -7097,6 +7955,7 @@ function createProvider({ actions, components, config, options, filePreview = {}
|
|
|
7097
7955
|
* - render `ActionView` on action selection
|
|
7098
7956
|
*/
|
|
7099
7957
|
function StorageBrowserDefault() {
|
|
7958
|
+
useServiceWorkerRegistration();
|
|
7100
7959
|
const { primary } = useViews();
|
|
7101
7960
|
const { LocationActionView, LocationDetailView, LocationsView } = primary;
|
|
7102
7961
|
const [{ actionType, location }] = useStore();
|