@aws-amplify/ui-react-storage 3.17.2 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/copy-serviceworker.js +36 -0
- package/dist/browser.js +23 -23
- package/dist/{createStorageBrowser-CtfBkr62.js → createStorageBrowser-S_PgkSVv.js} +1123 -264
- package/dist/download-sw.js +70 -0
- package/dist/esm/components/StorageBrowser/actions/configs/defaults.mjs +3 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/composedDownloadHandler.mjs +2 -2
- package/dist/esm/components/StorageBrowser/actions/handlers/download.mjs +1 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/utils.mjs +23 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/zipdownload.mjs +342 -118
- package/dist/esm/components/StorageBrowser/createStorageBrowser/StorageBrowserDefault.mjs +3 -1
- package/dist/esm/components/StorageBrowser/createStorageBrowser/createStorageBrowser.mjs +1 -1
- package/dist/esm/components/StorageBrowser/displayText/libraries/en/downloadView.mjs +4 -0
- package/dist/esm/components/StorageBrowser/service-worker/useServiceWorkerRegistration.mjs +25 -0
- package/dist/esm/components/StorageBrowser/useAction/useHandler.mjs +6 -2
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.mjs +60 -8
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.mjs +376 -5
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/utils.mjs +172 -0
- package/dist/esm/components/StorageBrowser/views/context/actionViews.mjs +1 -0
- package/dist/esm/components/StorageBrowser/views/context/primaryViews.mjs +1 -1
- package/dist/esm/version.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/styles.css +144 -114
- package/dist/types/components/StorageBrowser/actions/handlers/download.d.ts +15 -0
- package/dist/types/components/StorageBrowser/actions/handlers/utils.d.ts +12 -0
- package/dist/types/components/StorageBrowser/actions/handlers/zipdownload.d.ts +2 -2
- package/dist/types/components/StorageBrowser/actions/index.d.ts +1 -1
- package/dist/types/components/StorageBrowser/displayText/types.d.ts +21 -0
- package/dist/types/components/StorageBrowser/service-worker/download-sw.d.ts +2 -0
- package/dist/types/components/StorageBrowser/service-worker/useServiceWorkerRegistration.d.ts +2 -0
- package/dist/types/components/StorageBrowser/useAction/types.d.ts +1 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/types.d.ts +40 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/utils.d.ts +99 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/index.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/package.json +12 -8
|
@@ -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
|
-
{
|
|
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,
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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 ||
|
|
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,
|
package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.mjs
CHANGED
|
@@ -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 '
|
|
11
|
-
import '../../../configuration/context.mjs';
|
|
17
|
+
import { useGetActionInput } from '../../../configuration/context.mjs';
|
|
12
18
|
import '../../../configuration/paginationContext.mjs';
|
|
13
|
-
import '
|
|
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 {
|
|
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,
|