@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.
Files changed (35) hide show
  1. package/bin/copy-serviceworker.js +36 -0
  2. package/dist/browser.js +23 -23
  3. package/dist/{createStorageBrowser-CtfBkr62.js → createStorageBrowser-S_PgkSVv.js} +1123 -264
  4. package/dist/download-sw.js +70 -0
  5. package/dist/esm/components/StorageBrowser/actions/configs/defaults.mjs +3 -1
  6. package/dist/esm/components/StorageBrowser/actions/handlers/composedDownloadHandler.mjs +2 -2
  7. package/dist/esm/components/StorageBrowser/actions/handlers/download.mjs +1 -1
  8. package/dist/esm/components/StorageBrowser/actions/handlers/utils.mjs +23 -1
  9. package/dist/esm/components/StorageBrowser/actions/handlers/zipdownload.mjs +342 -118
  10. package/dist/esm/components/StorageBrowser/createStorageBrowser/StorageBrowserDefault.mjs +3 -1
  11. package/dist/esm/components/StorageBrowser/createStorageBrowser/createStorageBrowser.mjs +1 -1
  12. package/dist/esm/components/StorageBrowser/displayText/libraries/en/downloadView.mjs +4 -0
  13. package/dist/esm/components/StorageBrowser/service-worker/useServiceWorkerRegistration.mjs +25 -0
  14. package/dist/esm/components/StorageBrowser/useAction/useHandler.mjs +6 -2
  15. package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.mjs +60 -8
  16. package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.mjs +376 -5
  17. package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/utils.mjs +172 -0
  18. package/dist/esm/components/StorageBrowser/views/context/actionViews.mjs +1 -0
  19. package/dist/esm/components/StorageBrowser/views/context/primaryViews.mjs +1 -1
  20. package/dist/esm/version.mjs +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/styles.css +144 -114
  23. package/dist/types/components/StorageBrowser/actions/handlers/download.d.ts +15 -0
  24. package/dist/types/components/StorageBrowser/actions/handlers/utils.d.ts +12 -0
  25. package/dist/types/components/StorageBrowser/actions/handlers/zipdownload.d.ts +2 -2
  26. package/dist/types/components/StorageBrowser/actions/index.d.ts +1 -1
  27. package/dist/types/components/StorageBrowser/displayText/types.d.ts +21 -0
  28. package/dist/types/components/StorageBrowser/service-worker/download-sw.d.ts +2 -0
  29. package/dist/types/components/StorageBrowser/service-worker/useServiceWorkerRegistration.d.ts +2 -0
  30. package/dist/types/components/StorageBrowser/useAction/types.d.ts +1 -0
  31. package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/types.d.ts +40 -0
  32. package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/utils.d.ts +99 -0
  33. package/dist/types/components/StorageBrowser/views/LocationActionView/index.d.ts +1 -1
  34. package/dist/types/version.d.ts +1 -1
  35. 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
- const getProgress = ({ totalBytes, transferredBytes, }) => totalBytes ? transferredBytes / totalBytes : undefined;
212
- const isMultipartUpload = (file) => file.size > MULTIPART_UPLOAD_THRESHOLD_BYTES;
213
-
214
- const toAccessGrantPermission = (permission) => {
215
- let result = '';
216
- permission.forEach((access) => {
217
- if (['read', 'get', 'list'].includes(access)) {
218
- if (!result.includes('READ')) {
219
- result = 'READ' + result;
220
- }
221
- }
222
- if (['write', 'delete'].includes(access)) {
223
- if (!result.includes('WRITE')) {
224
- result += 'WRITE';
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
- getLocationCredentials,
338
- listLocations,
339
- registerAuthListener,
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$1 = ({ config, data }) => {
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
- const zipper = (() => {
511
- let blobWriter = null;
512
- let zipWriter = null;
513
- return {
514
- addFile: async (file, name, options) => {
515
- const { data, signal, onProgress } = options ?? {};
516
- if (!blobWriter) {
517
- blobWriter = new zip_js.BlobWriter('application/zip');
518
- zipWriter = new zip_js.ZipWriter(blobWriter);
519
- }
520
- await zipWriter.add(name, new zip_js.BlobReader(file), {
521
- level: 0,
522
- signal,
523
- onprogress: (progress, total) => {
524
- if (ui.isFunction(onProgress) && data) {
525
- onProgress(progress / total, name, data);
526
- }
527
- return undefined;
528
- },
529
- });
530
- },
531
- getBlobUrl: async () => {
532
- if (!zipWriter) {
533
- throw new Error('no zip');
534
- }
535
- const blob = await zipWriter.close();
536
- zipWriter = null;
537
- blobWriter = null;
538
- return URL.createObjectURL(blob);
539
- },
540
- destroy: () => {
541
- zipWriter = null;
542
- blobWriter = null;
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
- const readBody = async (response, { data, options }) => {
548
- let loading = true;
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
- const reader = response.body.getReader();
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
- loading = false;
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
- else {
559
- chunks.push(value);
560
- received += value.length;
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
- return new Blob(chunks);
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
- const download = async ({ config, data, all, options }, abortController) => {
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: abortController.signal,
583
+ signal: state.batchAbort.signal,
588
584
  });
589
- const blob = await readBody(response, { data, options });
590
- const [filename] = key.split('/').reverse();
591
- await zipper.addFile(blob, filename, {
592
- data,
593
- signal: abortController.signal,
594
- onProgress: (progress, _name, _data) => {
595
- if (ui.isFunction(options?.onProgress)) {
596
- options?.onProgress(_data, progress, progress === 1 ? 'COMPLETE' : 'FINISHING');
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
- const downloadHandler = (() => {
603
- const fileDownloadQueue = new Map();
604
- const handler = ({ config, data, all, options }) => {
605
- const { key } = data;
606
- const [, folder] = key.split('/').reverse();
607
- fileDownloadQueue.set(key, false);
608
- const abortController = new AbortController();
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
- abortController.abort();
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
- return handler;
656
- })();
770
+ };
657
771
 
658
- const composedDownloadHandler = (input) => input.all.length === 1 ? downloadHandler$1(input) : downloadHandler(input);
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 || hasSelectedFolders(selected);
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
- { options: { concurrency: DEFAULT_ACTION_CONCURRENCY } }),
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, onActionExit, onTaskRemove, } = props;
5814
- const message = isProcessingComplete
5815
- ? getActionCompleteMessage({ counts: statusCounts })
5816
- : undefined;
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 || isProcessingComplete,
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 { fileDataItems: items = EMPTY_ITEMS } = locationItems;
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();