@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
@@ -27,11 +27,11 @@ import 'aws-amplify';
27
27
  import '@zip.js/zip.js';
28
28
  import 'aws-amplify/storage';
29
29
  import { DownloadView } from '../views/LocationActionView/DownloadView/DownloadView.mjs';
30
+ import '../actions/configs/context.mjs';
30
31
  import { LocationActionView } from '../views/LocationActionView/LocationActionView.mjs';
31
32
  import { UploadView } from '../views/LocationActionView/UploadView/UploadView.mjs';
32
33
  import '../fileItems/context.mjs';
33
34
  import { LocationDetailView } from '../views/LocationDetailView/LocationDetailView.mjs';
34
- import '../actions/configs/context.mjs';
35
35
  import '../filePreview/context.mjs';
36
36
  import { LocationsView } from '../views/LocationsView/LocationsView.mjs';
37
37
  import { useView } from '../views/useView.mjs';
@@ -21,6 +21,10 @@ const DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT = {
21
21
  };
22
22
  },
23
23
  tableColumnProgressHeader: 'Progress',
24
+ enumeratingMessage: 'Listing folder contents…',
25
+ enumerationErrorMessage: 'Failed to list folder contents. Click Download to try again.',
26
+ noFilesMessage: 'The selected folders contain no files to download.',
27
+ tooManyFilesMessage: 'The selection exceeds the maximum of 5000 files for a single download. Download folders in smaller batches.',
24
28
  };
25
29
 
26
30
  export { DEFAULT_DOWNLOAD_VIEW_DISPLAY_TEXT };
@@ -0,0 +1,25 @@
1
+ import { useEffect } from 'react';
2
+
3
+ const SW_DOWNLOAD_SCOPE = '/amplify-storage-download/';
4
+ const SW_URL = '/amplify-storage-download/download-sw.js';
5
+ function useServiceWorkerRegistration() {
6
+ useEffect(() => {
7
+ if ('serviceWorker' in navigator) {
8
+ navigator.serviceWorker
9
+ .register(SW_URL, { scope: SW_DOWNLOAD_SCOPE })
10
+ .catch((err) => {
11
+ // Registration failure is non-critical; the blob fallback handles
12
+ // downloads. We still surface it: a failed registration is a real
13
+ // (silent otherwise) degradation, and the most common cause is
14
+ // forgetting the copy-serviceworker setup step so the SW file isn't
15
+ // served from the app's public directory.
16
+ // eslint-disable-next-line no-console
17
+ console.warn('[StorageBrowser] Download service worker registration failed; ' +
18
+ 'falling back to in-memory blob downloads. Ensure the service ' +
19
+ 'worker file is served (see the copy-serviceworker setup step):', err);
20
+ });
21
+ }
22
+ }, []);
23
+ }
24
+
25
+ export { SW_DOWNLOAD_SCOPE, useServiceWorkerRegistration };
@@ -24,9 +24,13 @@ function useHandler(handler, options) {
24
24
  ...(hasData
25
25
  ? { data: input.data, all: [input.data] }
26
26
  : // if no `data` provided, provide `concurrency` to `options`
27
- { options: { concurrency: DEFAULT_ACTION_CONCURRENCY } }),
27
+ {
28
+ options: {
29
+ concurrency: options?.concurrency ?? DEFAULT_ACTION_CONCURRENCY,
30
+ },
31
+ }),
28
32
  });
29
- }, [getConfig, handleProcessing, reset]);
33
+ }, [getConfig, handleProcessing, reset, options?.concurrency]);
30
34
  if (isOptionsWithItems(options)) {
31
35
  return [{ ...rest, isProcessing, reset, tasks }, handleDispatch];
32
36
  }
@@ -16,11 +16,51 @@ import { DOWNLOAD_TABLE_RESOLVERS, DOWNLOAD_TABLE_KEYS } from '../../utils/table
16
16
 
17
17
  function DownloadViewProvider({ children, ...props }) {
18
18
  const { DownloadView: displayText } = useDisplayText();
19
- const { actionCancelLabel, actionExitLabel, actionStartLabel, title, statusDisplayCanceledLabel, statusDisplayCompletedLabel, statusDisplayFailedLabel, statusDisplayQueuedLabel, getActionCompleteMessage, } = displayText;
20
- const { isProcessing, isProcessingComplete, statusCounts, tasks: items, onActionCancel, onActionStart, onActionExit, onTaskRemove, } = props;
21
- const message = isProcessingComplete
22
- ? getActionCompleteMessage({ counts: statusCounts })
23
- : undefined;
19
+ const { actionCancelLabel, actionExitLabel, actionStartLabel, title, statusDisplayCanceledLabel, statusDisplayCompletedLabel, statusDisplayFailedLabel, statusDisplayQueuedLabel, getActionCompleteMessage, enumeratingMessage, enumerationErrorMessage, noFilesMessage, tooManyFilesMessage, } = displayText;
20
+ const { isProcessing, isProcessingComplete, enumerationStatus, hasFilesToDownload, hasSelection, statusCounts, tasks: items, onActionCancel, onActionStart, onTaskRemove, onActionExit, } = props;
21
+ const isEnumerationPending = enumerationStatus === 'PENDING';
22
+ const isEnumerationSucceeded = enumerationStatus === 'SUCCEEDED';
23
+ const isOverFileLimit = enumerationStatus === 'OVER_LIMIT';
24
+ // Surface the no-files message when the READY set is empty, covering BOTH
25
+ // empty states with one expression:
26
+ // - enumeration succeeded but found only empty folders, and
27
+ // - the ready set went empty because the user manually removed every row
28
+ // (mirrors the Start-disable gate below).
29
+ // `'SUCCEEDED'` keeps the pending/error/over-limit statuses owning the
30
+ // message via the precedence order; the processing guards keep an active or
31
+ // completed download owning it. `hasSelection` scopes the message to a
32
+ // selection that is or was non-empty, so a bare mount with an empty
33
+ // selection (vacuously ready, nothing to download) shows no message.
34
+ const showNoFiles = hasSelection &&
35
+ isEnumerationSucceeded &&
36
+ !hasFilesToDownload &&
37
+ !isProcessing &&
38
+ !isProcessingComplete;
39
+ // Message precedence (most transient/actionable first):
40
+ // 1. 'PENDING' -> "listing folder contents" (info)
41
+ // 2. 'ERROR' -> failure + retry hint (error)
42
+ // 3. 'OVER_LIMIT' -> selection exceeds the file cap (error)
43
+ // 4. showNoFiles -> empty folders OR manually-emptied set (info)
44
+ // 5. isProcessingComplete-> post-download summary (existing)
45
+ // 6. otherwise -> no message
46
+ // Ordering matters: the enumeration statuses are pre-dispatch and mutually
47
+ // exclusive with a completed download, so an earlier match short-circuits.
48
+ const message = isEnumerationPending
49
+ ? { content: enumeratingMessage, type: 'info' }
50
+ : enumerationStatus === 'ERROR'
51
+ ? { content: enumerationErrorMessage, type: 'error' }
52
+ : isOverFileLimit
53
+ ? { content: tooManyFilesMessage, type: 'error' }
54
+ : showNoFiles
55
+ ? { content: noFilesMessage, type: 'info' }
56
+ : isProcessingComplete
57
+ ? getActionCompleteMessage({ counts: statusCounts })
58
+ : undefined;
59
+ // `'NOT_STARTED'` and `'ERROR'` are the not-ready/partial statuses. They are
60
+ // deliberately NOT added to `isActionStartDisabled`: the no-partial-dispatch
61
+ // invariant is enforced inside the hook's `onActionStart` (guarded dispatch)
62
+ // so the Start button stays CLICKABLE in those statuses and re-clicking
63
+ // Start acts as the enumeration RETRY trigger.
24
64
  const tableData = useResolveTableData(DOWNLOAD_TABLE_KEYS, DOWNLOAD_TABLE_RESOLVERS, {
25
65
  items,
26
66
  props: { displayText, isProcessing, onTaskRemove },
@@ -29,9 +69,21 @@ function DownloadViewProvider({ children, ...props }) {
29
69
  actionCancelLabel,
30
70
  actionExitLabel,
31
71
  actionStartLabel,
32
- isActionCancelDisabled: !isProcessing || isProcessingComplete,
33
- isActionExitDisabled: isProcessing,
34
- isActionStartDisabled: isProcessing || isProcessingComplete,
72
+ isActionCancelDisabled: (!isProcessing || isProcessingComplete) && !isEnumerationPending,
73
+ isActionExitDisabled: isProcessing || isEnumerationPending,
74
+ isActionStartDisabled: isProcessing ||
75
+ isProcessingComplete ||
76
+ isEnumerationPending ||
77
+ // The selection exceeds the file cap: retrying cannot succeed without
78
+ // changing the selection, so Start is hard-disabled (unlike the
79
+ // 'ERROR' status, where Start doubles as the retry trigger).
80
+ isOverFileLimit ||
81
+ // Every row was removed (or the ready set is otherwise empty, e.g.
82
+ // only empty folders were selected): nothing to download. Scoped to
83
+ // `'SUCCEEDED'` so this NEVER disables Start in the
84
+ // 'NOT_STARTED'/'ERROR' statuses, where a clickable Start is the
85
+ // enumeration RETRY trigger (empty resolvedItems is expected there).
86
+ (isEnumerationSucceeded && !hasFilesToDownload),
35
87
  statusDisplayCanceledLabel,
36
88
  statusDisplayCompletedLabel,
37
89
  statusDisplayFailedLabel,
@@ -1,35 +1,384 @@
1
1
  import React__default from 'react';
2
2
  import { isFunction } from '@aws-amplify/ui';
3
+ import '@aws-amplify/storage/internals';
4
+ import { createDownloadItem } from '../../../actions/handlers/utils.mjs';
5
+ import '@zip.js/zip.js';
6
+ import 'aws-amplify/storage';
7
+ import '../../../actions/configs/context.mjs';
8
+ import '../../../actions/configs/defaults.mjs';
3
9
  import { useLocationItems } from '../../../locationItems/context.mjs';
10
+ import { hasSelectedFolders } from '../../../locationItems/utils.mjs';
4
11
  import { useStore } from '../../../store/context.mjs';
5
12
  import '../../../useAction/context.mjs';
6
13
  import { useAction } from '../../../useAction/useAction.mjs';
7
14
  import '@aws-amplify/ui-react-core';
8
15
  import '@aws-amplify/ui-react-core/elements';
9
16
  import '../../../credentials/context.mjs';
10
- import '@aws-amplify/storage/internals';
11
- import '../../../configuration/context.mjs';
17
+ import { useGetActionInput } from '../../../configuration/context.mjs';
12
18
  import '../../../configuration/paginationContext.mjs';
13
- import '../../../actions/configs/defaults.mjs';
19
+ import { resolveArchiveName, expandFolderToFiles, FileLimitError } from './utils.mjs';
14
20
 
15
21
  // assign to constant to ensure referential equality
16
22
  const EMPTY_ITEMS = [];
23
+ // Referentially-stable empty set used as the "no removals" sentinel so a
24
+ // selection change can filter with an empty set without allocating.
25
+ const EMPTY_SET = new Set();
26
+ /**
27
+ * Drops rows the user removed via `onTaskRemove`, keyed by the item's stable
28
+ * id. Needed because folder-EXPANDED file rows carry ids minted during
29
+ * enumeration that never exist in `dataItems`, so the REMOVE_LOCATION_ITEM
30
+ * reducer path no-ops for them — this id filter is what actually removes them.
31
+ */
32
+ const filterRemoved = (items, removedIds) => removedIds.size === 0
33
+ ? items
34
+ : items.filter((item) => !removedIds.has(item.id));
35
+ /**
36
+ * Builds the flat list of download items from the current selection:
37
+ * loose files become download items directly; folders contribute their
38
+ * already-expanded files from `cache` (empty until enumeration runs).
39
+ */
40
+ const buildDownloadItems = (dataItems, prefix, cache) => {
41
+ const items = [];
42
+ for (const item of dataItems) {
43
+ if (item.type === 'FILE') {
44
+ items.push(createDownloadItem(item, prefix));
45
+ }
46
+ else {
47
+ const expanded = cache.get(item.id);
48
+ if (expanded) {
49
+ items.push(...expanded);
50
+ }
51
+ }
52
+ }
53
+ return items;
54
+ };
55
+ /**
56
+ * Resolves the EFFECTIVE download set for the current selection: builds the
57
+ * flat item list, drops user-removed rows, then names the zip archive from
58
+ * the POST-removal set.
59
+ *
60
+ * The archive name is computed here, in the view — NOT in the handler — and
61
+ * stamped uniformly onto every item so the zip handler can read it from
62
+ * `all[0].archiveName`. It MUST be computed after `filterRemoved` so the name
63
+ * reflects the files actually being downloaded (removing rows can shift the
64
+ * common ancestor). A single-folder SELECTION is still named after that
65
+ * folder — the selection (`dataItems`) is unaffected by per-row removal, only
66
+ * the LCA input (file keys) is post-filter; every other shape uses the common
67
+ * ancestor directory of the remaining files (see resolveArchiveName).
68
+ */
69
+ const resolveDownloadItems = ({ dataItems, prefix, cache, removedIds, }) => {
70
+ const items = filterRemoved(buildDownloadItems(dataItems, prefix, cache), removedIds);
71
+ const archiveName = resolveArchiveName(dataItems, items.map((i) => i.key));
72
+ return items.map((i) => ({ ...i, archiveName }));
73
+ };
17
74
  const useDownloadView = (options) => {
18
75
  const { onExit: _onExit } = options ?? {};
19
76
  const [{ location }, storeDispatch] = useStore();
20
77
  const [locationItems, locationItemsDispatch] = useLocationItems();
21
78
  const { current } = location;
22
- const { fileDataItems: items = EMPTY_ITEMS } = locationItems;
79
+ const { dataItems = EMPTY_ITEMS } = locationItems;
80
+ const getConfig = useGetActionInput();
81
+ // Cache of folder id -> expanded download items, mirroring DeleteView's
82
+ // `folderCountsRef`. Survives re-renders so re-expanding is avoided.
83
+ // Intentional per-session cache: entries are keyed by the (stable, per-mount)
84
+ // folder id and are never individually invalidated — the whole ref is
85
+ // discarded when the view unmounts on exit (RESET_LOCATION_ITEMS), which is
86
+ // the only path that changes the underlying location. This matches
87
+ // DeleteView's `folderCountsRef` semantics.
88
+ const folderExpansionRef = React__default.useRef(new Map());
89
+ // AbortController for the in-flight enumeration (cancellable pre-dispatch).
90
+ const enumAbortRef = React__default.useRef(null);
91
+ // Tracks the last selection (id SET of dataItems + current) the sync effect
92
+ // ran for, so it can tell a GENUINE selection change (which resets stale
93
+ // flags and the per-row removal set) apart from a within-selection row
94
+ // removal or a re-run triggered solely by a `removedItemIds` update (which
95
+ // must NOT reset the removals it just applied). The id SET — not the array
96
+ // reference — is stored because the reducer rebuilds the `dataItems` array
97
+ // on a LOOSE-row removal (same selection minus a row), so identity alone
98
+ // can't distinguish removal from re-selection (see the subset check in the
99
+ // sync effect below).
100
+ const prevSelectionRef = React__default.useRef({ dataItemIds: new Set(dataItems.map((item) => item.id)), current });
101
+ const [resolvedItems, setResolvedItems] = React__default.useState([]);
102
+ // `true` while folder selections are being expanded into their files. Drives
103
+ // the `'PENDING'` enumeration status, which disables Start until enumeration
104
+ // settles.
105
+ const [isEnumerating, setIsEnumerating] = React__default.useState(false);
106
+ // `true` when the pre-dispatch enumeration failed for a non-abort reason.
107
+ // Surfaced on the view model so the Start control re-enabling isn't the only
108
+ // (silent) feedback the user gets on failure.
109
+ const [isEnumerationError, setIsEnumerationError] = React__default.useState(false);
110
+ // `true` when the combined expanded file count of the selection exceeded
111
+ // LARGE_DOWNLOAD_FILE_COUNT during enumeration. A truncated zip would be
112
+ // silent data loss, so this state BLOCKS dispatch entirely (same invariant
113
+ // as `allFoldersReady`) and the view surfaces an explanatory message.
114
+ const [isOverFileLimit, setIsOverFileLimit] = React__default.useState(false);
115
+ // Retry counter; bumping re-runs the enumeration effect for still-uncached
116
+ // folders.
117
+ const [enumAttempt, setEnumAttempt] = React__default.useState(0);
118
+ // Stable ids of rows the user removed via `onTaskRemove`. Folder-EXPANDED
119
+ // file rows can't be removed through the locationItems reducer (their ids
120
+ // aren't in `dataItems`, so REMOVE_LOCATION_ITEM no-ops), so we track removals
121
+ // here and filter `resolvedItems` by them. Reset on a GENUINE selection change
122
+ // (a new id or location change — NOT a within-selection row removal) so
123
+ // removals don't leak into a new selection (see the sync effect below).
124
+ const [removedItemIds, setRemovedItemIds] = React__default.useState(() => new Set());
125
+ // Latest-value mirror of `removedItemIds` so the async enumeration closure can
126
+ // read the current removals WITHOUT `removedItemIds` becoming an enumeration
127
+ // dep (which would abort/re-run enumeration on every row removal). Mirrors the
128
+ // `callbacksRef` pattern in useProcessTasks.
129
+ const removedItemIdsRef = React__default.useRef(removedItemIds);
130
+ removedItemIdsRef.current = removedItemIds;
131
+ const hasFolders = hasSelectedFolders(dataItems);
132
+ // `true` once the selection has been non-empty at any point in this mount.
133
+ // Sticky on purpose: removing every row empties `dataItems` for a loose-file
134
+ // selection, and the "no files" message must still show in that manually
135
+ // -emptied state, while a bare mount with no selection must NOT show it.
136
+ const hadSelectionRef = React__default.useRef(false);
137
+ if (dataItems.length > 0) {
138
+ hadSelectionRef.current = true;
139
+ }
140
+ const hasSelection = hadSelectionRef.current;
141
+ // `resolvedItems` (not the raw selection) is what `useAction` turns into
142
+ // tasks. Keep it in sync with the selection + expansion cache so item
143
+ // removal (onTaskRemove) stays consistent. For a file-only selection this
144
+ // fully populates `resolvedItems` on mount (no enumeration needed); for
145
+ // folders it seeds any already-expanded (cached) files and the enumeration
146
+ // effect below fills in the rest.
147
+ React__default.useEffect(() => {
148
+ const prefix = current?.prefix ?? '';
149
+ // Distinguish a GENUINE selection change (new/changed selection or
150
+ // location change) from a within-selection row removal or a re-run
151
+ // triggered solely by a `removedItemIds` update. Only a real selection
152
+ // change should clear stale pre-dispatch flags and the per-row removal
153
+ // set — resetting on anything else would resurrect rows the user just
154
+ // removed. `dataItems` identity is NOT a reliable signal: removing a
155
+ // LOOSE row rebuilds the array (same selection minus a row), and the
156
+ // `SET_LOCATION_ITEMS` reducer `.concat`s a NEW reference even when
157
+ // re-selecting the SAME items. Compare by id set instead: when the new id
158
+ // set is a SUBSET of the previous one (no NEW id), rows were only removed
159
+ // (or nothing changed), so removals — which for folder-EXPANDED rows live
160
+ // ONLY in `removedItemIds` (the reducer no-ops for their ids) — must
161
+ // carry over. Only a NEW id (or a `current` change) marks a genuine
162
+ // selection change. Consequence vs. the previous identity compare:
163
+ // re-selecting the identical id set no longer resets removals, so
164
+ // previously-removed rows STAY removed instead of re-appearing.
165
+ const prev = prevSelectionRef.current;
166
+ const dataItemIds = new Set();
167
+ let hasNewId = false;
168
+ for (const item of dataItems) {
169
+ dataItemIds.add(item.id);
170
+ if (!prev.dataItemIds.has(item.id)) {
171
+ hasNewId = true;
172
+ }
173
+ }
174
+ const selectionChanged = prev.current !== current || hasNewId;
175
+ prevSelectionRef.current = { dataItemIds, current };
176
+ // On a genuine selection change no prior removals carry over; otherwise
177
+ // apply the current removal set. (Removing a LOOSE file also mutates
178
+ // dataItems, but its id set stays a subset of the previous one, so it
179
+ // correctly does NOT read as a selection change: the reducer prunes the
180
+ // loose row from dataItems while `removedItemIds` keeps previously-removed
181
+ // folder-EXPANDED rows hidden — those no-op in the reducer, so dataItems is
182
+ // unchanged for them and this filter is what removes them.)
183
+ const effectiveRemovedIds = selectionChanged ? EMPTY_SET : removedItemIds;
184
+ setResolvedItems(resolveDownloadItems({
185
+ dataItems,
186
+ prefix,
187
+ cache: folderExpansionRef.current,
188
+ removedIds: effectiveRemovedIds,
189
+ }));
190
+ if (selectionChanged) {
191
+ // Selection changed: clear stale pre-dispatch flags so a prior error
192
+ // /over-limit result doesn't leak into the new selection.
193
+ setIsEnumerationError(false);
194
+ setIsOverFileLimit(false);
195
+ // Clear per-row removals so a prior selection's removals don't hide items
196
+ // in the new selection (no-op when already empty to avoid a needless
197
+ // re-render/effect loop).
198
+ setRemovedItemIds((prev) => (prev.size === 0 ? prev : new Set()));
199
+ }
200
+ }, [dataItems, current, removedItemIds]);
201
+ // Auto-run folder enumeration on mount and whenever the selection changes.
202
+ //
203
+ // WHY ON MOUNT (not gated behind Start): the view renders its rows from
204
+ // `resolvedItems` -> useAction `tasks`. A FOLDER contributes files only from
205
+ // the expansion cache, which is empty on mount, so a folder selection would
206
+ // otherwise render zero rows (and log nothing) until Start was clicked.
207
+ // Expanding eagerly here — mirroring DeleteView's `initializeFolderCounts`
208
+ // mount effect — resolves the files so the rows render as soon as the view
209
+ // opens.
210
+ //
211
+ // WHY DISPATCH IS DECOUPLED FROM ENUMERATION: this effect only populates
212
+ // `resolvedItems`; it MUST NOT auto-start the download. The zip is triggered
213
+ // solely by the user clicking Start (`onActionStart` -> `handleProcess`). By
214
+ // the time Start is enabled, `resolvedItems` has already synced into
215
+ // useAction's `tasksRef`, so the previous "set state + dispatch in one tick"
216
+ // sequencing hack is no longer required.
217
+ React__default.useEffect(() => {
218
+ if (!hasFolders || !current) {
219
+ // A prior in-flight enumeration may have been aborted by this effect's
220
+ // cleanup (selection change); its catch is now a no-op, so clear the flag
221
+ // defensively here to ensure `isEnumerating` can't stick true (which would
222
+ // also trap Exit). React bails on same-value setState, so this is a no-op
223
+ // in the normal file-only / already-idle paths and can't loop.
224
+ setIsEnumerating(false);
225
+ return;
226
+ }
227
+ // Only expand folders we haven't already cached (cache is keyed by the
228
+ // stable folder id). If every selected folder is already cached, the sync
229
+ // effect above has rebuilt `resolvedItems` from the cache and there's
230
+ // nothing to enumerate — avoids a spurious enumerating flash and re-runs.
231
+ const foldersToExpand = dataItems.filter((item) => item.type === 'FOLDER' && !folderExpansionRef.current.has(item.id));
232
+ if (foldersToExpand.length === 0) {
233
+ // Same defensive clear as above: an aborted prior run can't reset the flag.
234
+ setIsEnumerating(false);
235
+ return;
236
+ }
237
+ const config = getConfig(current);
238
+ const { prefix } = current;
239
+ const controller = new AbortController();
240
+ enumAbortRef.current = controller;
241
+ setIsEnumerationError(false);
242
+ setIsOverFileLimit(false);
243
+ setIsEnumerating(true);
244
+ // Shared running file total for the LARGE_DOWNLOAD_FILE_COUNT cap. Seeded
245
+ // with the files already in the selection (loose files plus previously
246
+ // cached folder expansions) so the cap applies to the COMBINED selection,
247
+ // then passed to every expansion in this run.
248
+ const fileCounter = {
249
+ count: dataItems.reduce((count, item) => {
250
+ if (item.type === 'FILE')
251
+ return count + 1;
252
+ return count + (folderExpansionRef.current.get(item.id)?.length ?? 0);
253
+ }, 0),
254
+ };
255
+ const runEnumeration = async () => {
256
+ try {
257
+ await Promise.all(foldersToExpand.map(async (folder) => {
258
+ const expanded = await expandFolderToFiles({
259
+ folderKey: folder.key,
260
+ config,
261
+ locationPrefix: prefix,
262
+ signal: controller.signal,
263
+ fileCounter,
264
+ });
265
+ folderExpansionRef.current.set(folder.id, expanded);
266
+ }));
267
+ // Cancelled mid-flight — `onActionCancel` (or this effect's cleanup on
268
+ // selection change / unmount) already aborted. Leave state alone to
269
+ // avoid a setState race with the newer run.
270
+ if (controller.signal.aborted)
271
+ return;
272
+ const resolved = resolveDownloadItems({
273
+ dataItems,
274
+ prefix,
275
+ cache: folderExpansionRef.current,
276
+ // Apply any per-row removals the user made before enumeration
277
+ // settled (the sync effect re-filters on later removals via its
278
+ // removedItemIds dep, but ref-population here doesn't trigger it, so
279
+ // filter now too). Read through the ref for the latest value.
280
+ removedIds: removedItemIdsRef.current,
281
+ });
282
+ // NOTE: `resolved` may be empty (only empty folders selected). The
283
+ // empty folders were still cached above, so the derived enumeration
284
+ // status flips to `'SUCCEEDED'` with `hasFilesToDownload` false — the
285
+ // view surfaces the "no files" message from that combination. No zip
286
+ // is started in this case (Start is disabled on an empty ready set).
287
+ setResolvedItems(resolved);
288
+ setIsEnumerating(false);
289
+ }
290
+ catch (error) {
291
+ // Abort surfaces here too; distinguish it from real failures.
292
+ if (controller.signal.aborted) ;
293
+ else if (error instanceof FileLimitError) {
294
+ // The combined selection exceeds LARGE_DOWNLOAD_FILE_COUNT. Abort the
295
+ // sibling expansions still paginating (their result can never be
296
+ // dispatched) and surface the blocked state. The over-limit folder was
297
+ // never cached, so `allFoldersReady` stays false and dispatch is
298
+ // structurally blocked as well.
299
+ controller.abort();
300
+ setIsOverFileLimit(true);
301
+ setIsEnumerating(false);
302
+ }
303
+ else {
304
+ // No dedicated package logger exists here; `console.error` matches the
305
+ // convention used elsewhere in StorageBrowser (e.g. validateStoreProps,
306
+ // useAction). AbortError is expected on cancel and handled above, so
307
+ // only genuine failures reach this branch.
308
+ // eslint-disable-next-line no-console
309
+ console.error('Failed to expand folders for download:', error);
310
+ setIsEnumerationError(true);
311
+ setIsEnumerating(false);
312
+ }
313
+ }
314
+ };
315
+ runEnumeration();
316
+ // Abort the in-flight enumeration when the selection changes or the view
317
+ // unmounts, so its `list()` loop stops and no setState-after-unmount occurs.
318
+ return () => {
319
+ controller.abort();
320
+ };
321
+ }, [dataItems, current, hasFolders, getConfig, enumAttempt]);
322
+ // Readiness gate: Start may only dispatch once EVERY selected folder has been
323
+ // expanded into the cache. Files are always ready; a FOLDER is ready only when
324
+ // folderExpansionRef has cached its expanded items. Recomputed each render —
325
+ // state changes from enumeration (setResolvedItems/setIsEnumerating) trigger the
326
+ // re-render that flips this true after the ref is populated.
327
+ const allFoldersReady = dataItems.every((item) => item.type !== 'FOLDER' || folderExpansionRef.current.has(item.id));
328
+ // Public enumeration status, DERIVED from the internal flags each render
329
+ // (never stored — `allFoldersReady` is itself derived from dataItems + the
330
+ // expansion cache, so storing the union would create a second source of
331
+ // truth). Precedence: an in-flight run owns the status; then the terminal
332
+ // error/limit outcomes; then readiness. A file-only selection has no folders
333
+ // to expand, so it is vacuously ready -> `'SUCCEEDED'` immediately on mount.
334
+ const enumerationStatus = isEnumerating
335
+ ? 'PENDING'
336
+ : isEnumerationError
337
+ ? 'ERROR'
338
+ : isOverFileLimit
339
+ ? 'OVER_LIMIT'
340
+ : allFoldersReady
341
+ ? 'SUCCEEDED'
342
+ : 'NOT_STARTED';
23
343
  const [processState, handleProcess] = useAction('download', {
24
- items,
344
+ items: resolvedItems,
345
+ concurrency: 1,
25
346
  });
26
347
  const { isProcessing, isProcessingComplete, statusCounts, tasks } = processState;
27
348
  const onActionStart = () => {
28
349
  if (!current)
29
350
  return;
351
+ // Enumeration in flight (Start is disabled anyway) — do nothing.
352
+ if (isEnumerating)
353
+ return;
354
+ // Selection exceeds the file cap: dispatching would produce a truncated
355
+ // zip (silent data loss) and retrying cannot succeed without changing the
356
+ // selection, so do nothing (Start is disabled in this state anyway).
357
+ if (isOverFileLimit)
358
+ return;
359
+ // RETRY PATH: a prior enumeration was cancelled or failed, so some selected
360
+ // folders are still uncached. NEVER dispatch an incomplete set (CORE
361
+ // INVARIANT). Instead re-trigger enumeration for the uncached folders by
362
+ // bumping enumAttempt; the user retries simply by clicking Start again.
363
+ // Dispatch happens on a later click, once allFoldersReady is true.
364
+ if (!allFoldersReady) {
365
+ setIsEnumerationError(false);
366
+ setEnumAttempt((n) => n + 1);
367
+ return;
368
+ }
369
+ // Every selected folder is expanded and resolvedItems is synced into
370
+ // useAction's tasksRef — safe to dispatch the complete set. Download starts
371
+ // ONLY here (never auto-started on enumeration completion).
30
372
  handleProcess();
31
373
  };
32
374
  const onActionCancel = () => {
375
+ // Cancel during the (mount) pre-dispatch enumeration phase: abort the
376
+ // `list()` loop and return to idle without starting a zip.
377
+ if (isEnumerating) {
378
+ enumAbortRef.current?.abort();
379
+ setIsEnumerating(false);
380
+ return;
381
+ }
33
382
  tasks.forEach((task) => {
34
383
  // Calling cancel on task works only on queued tasks.
35
384
  // In case of download, all download presigned url open at once
@@ -47,11 +396,33 @@ const useDownloadView = (options) => {
47
396
  _onExit(current);
48
397
  };
49
398
  const onTaskRemove = React__default.useCallback(({ data }) => {
399
+ // Track the removal by the item's STABLE id. Folder-EXPANDED file rows
400
+ // have ids minted in `expandFolderToFiles` (cached in folderExpansionRef)
401
+ // that never live in `dataItems`, so REMOVE_LOCATION_ITEM alone no-ops for
402
+ // them — filtering `resolvedItems` by `removedItemIds` is what removes
403
+ // those rows. For LOOSE selection items the dispatch still prunes the
404
+ // selection state (and is a harmless no-op for expanded ids).
405
+ setRemovedItemIds((prev) => {
406
+ if (prev.has(data.id))
407
+ return prev;
408
+ const next = new Set(prev);
409
+ next.add(data.id);
410
+ return next;
411
+ });
50
412
  locationItemsDispatch({ type: 'REMOVE_LOCATION_ITEM', id: data.id });
51
413
  }, [locationItemsDispatch]);
414
+ // Effective (post-removal) download set is empty -> nothing to download. Used
415
+ // (with a `'SUCCEEDED'` status) to gate Start in a ready/idle state and to
416
+ // surface the "no files" message — covering both empty folders detected
417
+ // during enumeration and a manually-emptied row set (see
418
+ // DownloadViewProvider).
419
+ const hasFilesToDownload = resolvedItems.length > 0;
52
420
  return {
53
421
  isProcessing,
54
422
  isProcessingComplete,
423
+ enumerationStatus,
424
+ hasFilesToDownload,
425
+ hasSelection,
55
426
  location,
56
427
  statusCounts,
57
428
  tasks,