@remit/web-client 0.0.111 → 0.0.113
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/package.json +1 -1
- package/src/components/mail/MessageList.tsx +2 -8
- package/src/components/mail/SwipeableMessageRow.render.test.ts +110 -0
- package/src/components/mail/SwipeableMessageRow.tsx +13 -27
- package/src/hooks/escalated-run-lifetime.render.test.ts +3 -1
- package/src/hooks/useEscalatedActions.ts +41 -35
- package/src/lib/bulk-actions.test.ts +3 -61
- package/src/lib/bulk-actions.ts +8 -47
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.113",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -45,11 +45,7 @@ import {
|
|
|
45
45
|
escalatedStatusLabel,
|
|
46
46
|
escalationActionLabel,
|
|
47
47
|
} from "@/lib/escalation-label";
|
|
48
|
-
import {
|
|
49
|
-
formatDeleteToTrashTitle,
|
|
50
|
-
formatEmailDate,
|
|
51
|
-
formatNumber,
|
|
52
|
-
} from "@/lib/format";
|
|
48
|
+
import { formatDeleteToTrashTitle, formatEmailDate } from "@/lib/format";
|
|
53
49
|
import { tabStopId } from "@/lib/list-focus";
|
|
54
50
|
import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
55
51
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
@@ -1099,9 +1095,7 @@ export const MessageList = ({
|
|
|
1099
1095
|
escalation.progress?.total ?? selectionCount,
|
|
1100
1096
|
)
|
|
1101
1097
|
: escalation.phase.kind === "counting"
|
|
1102
|
-
?
|
|
1103
|
-
? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
|
|
1104
|
-
: `Counting… ${formatNumber(escalation.phase.countSoFar)} so far`
|
|
1098
|
+
? "Counting matches…"
|
|
1105
1099
|
: escalation.phase.kind === "escalated"
|
|
1106
1100
|
? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
|
|
1107
1101
|
: undefined;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The touch row is not an anchor (#116).
|
|
3
|
+
*
|
|
4
|
+
* An `<a href>` is what the OS long-press callout and the link context menu
|
|
5
|
+
* fire on, and they raced the row's own long-press-to-select gesture. Nothing
|
|
6
|
+
* an anchor buys — middle-click, open in new tab, copy link address — exists
|
|
7
|
+
* without a mouse, and the swipe row is the touch row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { afterEach, describe, it } from "node:test";
|
|
12
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
13
|
+
import {
|
|
14
|
+
type AnyRouter,
|
|
15
|
+
createMemoryHistory,
|
|
16
|
+
createRootRoute,
|
|
17
|
+
createRoute,
|
|
18
|
+
createRouter,
|
|
19
|
+
RouterContextProvider,
|
|
20
|
+
} from "@tanstack/react-router";
|
|
21
|
+
import { createElement } from "react";
|
|
22
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
23
|
+
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
24
|
+
|
|
25
|
+
const PHONE_WIDTH = 390;
|
|
26
|
+
|
|
27
|
+
let harness: DomHarness | undefined;
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
harness?.close();
|
|
31
|
+
harness = undefined;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const thread = {
|
|
35
|
+
threadMessageId: "tm-1",
|
|
36
|
+
threadId: "th-1",
|
|
37
|
+
messageId: "msg-1",
|
|
38
|
+
accountId: "acc-1",
|
|
39
|
+
accountConfigId: "acc-1",
|
|
40
|
+
mailboxId: "mbx-1",
|
|
41
|
+
subject: "Q3 planning notes",
|
|
42
|
+
fromName: "Alex Rivera",
|
|
43
|
+
fromEmail: "alex@example.com",
|
|
44
|
+
snippet: "Notes from the planning session.",
|
|
45
|
+
sentDate: 0,
|
|
46
|
+
isRead: false,
|
|
47
|
+
hasAttachment: false,
|
|
48
|
+
hasStars: false,
|
|
49
|
+
star: "None",
|
|
50
|
+
isDeleted: false,
|
|
51
|
+
senderTrust: "unknown",
|
|
52
|
+
createdAt: 0,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
} as unknown as RemitImapThreadMessageResponse;
|
|
55
|
+
|
|
56
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
57
|
+
// `window`.
|
|
58
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
59
|
+
|
|
60
|
+
const rootRoute = createRootRoute();
|
|
61
|
+
const mailRoute = createRoute({
|
|
62
|
+
getParentRoute: () => rootRoute,
|
|
63
|
+
path: "/mail/$mailboxId",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const testRouter = (): AnyRouter =>
|
|
67
|
+
createRouter({
|
|
68
|
+
routeTree: rootRoute.addChildren([mailRoute]),
|
|
69
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
70
|
+
}) as unknown as AnyRouter;
|
|
71
|
+
|
|
72
|
+
const renderRow = (): DomHarness => {
|
|
73
|
+
const created = createDomHarness({ viewportWidth: PHONE_WIDTH });
|
|
74
|
+
created.render(
|
|
75
|
+
createElement(RouterContextProvider, {
|
|
76
|
+
router: testRouter(),
|
|
77
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
78
|
+
children: createElement(SwipeableMessageRow, {
|
|
79
|
+
thread,
|
|
80
|
+
mailboxId: "mbx-1",
|
|
81
|
+
isSelected: false,
|
|
82
|
+
isChecked: false,
|
|
83
|
+
onToggleCheck: () => undefined,
|
|
84
|
+
onRowSelect: () => false,
|
|
85
|
+
isMultiSelectMode: false,
|
|
86
|
+
onLongPress: () => undefined,
|
|
87
|
+
isDesktop: false,
|
|
88
|
+
onDelete: () => undefined,
|
|
89
|
+
onToggleRead: () => undefined,
|
|
90
|
+
}),
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
return created;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
describe("SwipeableMessageRow — the touch row's open affordance", () => {
|
|
97
|
+
it("renders no anchor at all", () => {
|
|
98
|
+
harness = renderRow();
|
|
99
|
+
|
|
100
|
+
assert.equal(harness.queryAll("a").length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("opens through a button carrying the row marker", () => {
|
|
104
|
+
harness = renderRow();
|
|
105
|
+
|
|
106
|
+
const row = harness.query("button[data-message-row]");
|
|
107
|
+
assert.ok(row, "the row's open affordance is not a button");
|
|
108
|
+
assert.match(row.textContent ?? "", /Q3 planning notes/);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -6,12 +6,11 @@ import {
|
|
|
6
6
|
type SwipePeek,
|
|
7
7
|
type ThreadRowData,
|
|
8
8
|
} from "@remit/ui";
|
|
9
|
-
import {
|
|
9
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
10
10
|
import { useCallback, useState } from "react";
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
14
|
-
import { useModifierSelect } from "./useModifierSelect";
|
|
15
14
|
|
|
16
15
|
interface MailboxLinkSearch {
|
|
17
16
|
selectedMessageId?: string;
|
|
@@ -78,6 +77,7 @@ export const SwipeableMessageRow = ({
|
|
|
78
77
|
density,
|
|
79
78
|
}: SwipeableMessageRowProps) => {
|
|
80
79
|
const [peek, setPeek] = useState<SwipePeek>("none");
|
|
80
|
+
const navigate = useNavigate();
|
|
81
81
|
|
|
82
82
|
const handleAct = useCallback(
|
|
83
83
|
(side: "leading" | "trailing") => {
|
|
@@ -100,10 +100,16 @@ export const SwipeableMessageRow = ({
|
|
|
100
100
|
onToggleCheck(thread.messageId);
|
|
101
101
|
}, [onToggleCheck, thread.messageId]);
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
103
|
+
const handleOpen = useCallback(() => {
|
|
104
|
+
navigate({
|
|
105
|
+
to: "/mail/$mailboxId",
|
|
106
|
+
params: { mailboxId },
|
|
107
|
+
search: (prev: MailboxLinkSearch) => ({
|
|
108
|
+
...prev,
|
|
109
|
+
selectedMessageId: thread.messageId,
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
}, [navigate, mailboxId, thread.messageId]);
|
|
107
113
|
|
|
108
114
|
if (isDesktop || isMultiSelectMode) {
|
|
109
115
|
return (
|
|
@@ -135,28 +141,8 @@ export const SwipeableMessageRow = ({
|
|
|
135
141
|
onPeek={setPeek}
|
|
136
142
|
onToggleCheck={handleToggleCheck}
|
|
137
143
|
onLongPress={handleLongPress}
|
|
138
|
-
onOpen={
|
|
144
|
+
onOpen={handleOpen}
|
|
139
145
|
onAct={handleAct}
|
|
140
|
-
linkComponent={({ onOpenClick, children, ...rowProps }) => (
|
|
141
|
-
<Link
|
|
142
|
-
{...rowProps}
|
|
143
|
-
to="/mail/$mailboxId"
|
|
144
|
-
params={{ mailboxId }}
|
|
145
|
-
search={(prev: MailboxLinkSearch) => ({
|
|
146
|
-
...prev,
|
|
147
|
-
selectedMessageId: thread.messageId,
|
|
148
|
-
})}
|
|
149
|
-
data-message-row
|
|
150
|
-
onMouseDown={modifierSelect.onMouseDown}
|
|
151
|
-
onContextMenu={modifierSelect.onContextMenu}
|
|
152
|
-
onClick={(e) => {
|
|
153
|
-
if (modifierSelect.claimClick(e)) return;
|
|
154
|
-
onOpenClick(e);
|
|
155
|
-
}}
|
|
156
|
-
>
|
|
157
|
-
{children}
|
|
158
|
-
</Link>
|
|
159
|
-
)}
|
|
160
146
|
/>
|
|
161
147
|
);
|
|
162
148
|
};
|
|
@@ -42,10 +42,12 @@ interface MailServer {
|
|
|
42
42
|
/**
|
|
43
43
|
* A search that pages `TOTAL` ids at the hook's own page size, keyed by the
|
|
44
44
|
* query, so a run started against one predicate is distinguishable from a run
|
|
45
|
-
* that followed the list onto a later one.
|
|
45
|
+
* that followed the list onto a later one. A count-only request is answered
|
|
46
|
+
* with the whole match set in one response, as the server answers it.
|
|
46
47
|
*/
|
|
47
48
|
const searchPage = (url: URL): unknown => {
|
|
48
49
|
const query = url.searchParams.get("query") ?? "";
|
|
50
|
+
if (url.searchParams.get("results") === "false") return { count: TOTAL };
|
|
49
51
|
const served = Number(url.searchParams.get("continuationToken") ?? "0");
|
|
50
52
|
const size = Math.min(PAGE_SIZE, Math.max(TOTAL - served, 0));
|
|
51
53
|
return {
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
type ApplyBatch,
|
|
19
19
|
type BulkActionProgress,
|
|
20
20
|
type BulkRunOutcome,
|
|
21
|
-
countMatches,
|
|
22
21
|
type FetchIdsPage,
|
|
23
22
|
honestProgress,
|
|
24
23
|
runChunkedAction,
|
|
@@ -53,16 +52,14 @@ export type EscalatedAction =
|
|
|
53
52
|
| { kind: "move"; destinationMailboxId: string }
|
|
54
53
|
| { kind: "markRead" };
|
|
55
54
|
|
|
56
|
-
/** Page size for
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* doesn't have that constraint but reuses the same page size rather than
|
|
60
|
-
* adding a second one to reason about. */
|
|
55
|
+
/** Page size for the execution loop. Set to the write side's own 100-id cap so
|
|
56
|
+
* an execution page IS a write chunk — no in-memory accumulation step between
|
|
57
|
+
* reading ids and sending them. */
|
|
61
58
|
const PAGE_SIZE = 100;
|
|
62
59
|
|
|
63
60
|
export type EscalationPhase =
|
|
64
61
|
| { kind: "idle" }
|
|
65
|
-
| { kind: "counting"
|
|
62
|
+
| { kind: "counting" }
|
|
66
63
|
| { kind: "escalated"; total: number };
|
|
67
64
|
|
|
68
65
|
interface UseEscalatedActionsOptions {
|
|
@@ -80,12 +77,13 @@ interface UseEscalatedActionsOptions {
|
|
|
80
77
|
|
|
81
78
|
export interface UseEscalatedActionsResult {
|
|
82
79
|
phase: EscalationPhase;
|
|
83
|
-
/**
|
|
80
|
+
/** Ask the server how many messages the predicate matches, and switch the
|
|
81
|
+
* selection to that predicate once it answers. */
|
|
84
82
|
escalate: () => void;
|
|
85
83
|
/**
|
|
86
|
-
* Stop whatever's running —
|
|
87
|
-
*
|
|
88
|
-
*
|
|
84
|
+
* Stop whatever's running — the count or an action — at the next boundary.
|
|
85
|
+
* A no-op when nothing is running. The only thing that ends a run in
|
|
86
|
+
* flight: leaving the selection, the wizard or the search does not.
|
|
89
87
|
*/
|
|
90
88
|
stop: () => void;
|
|
91
89
|
/**
|
|
@@ -181,11 +179,22 @@ export const useEscalatedActions = ({
|
|
|
181
179
|
[mailboxId],
|
|
182
180
|
);
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
/**
|
|
183
|
+
* How many messages the predicate matches, straight from the server that
|
|
184
|
+
* resolves it (#509). One count-only request: `limit` is a page size and has
|
|
185
|
+
* no bearing on the answer, so nothing is paged to arrive at it.
|
|
186
|
+
*/
|
|
187
|
+
const fetchMatchCount = useCallback(async (): Promise<number> => {
|
|
188
|
+
const { data } = await threadOperationsSearchThreads({
|
|
189
|
+
path: { mailboxId },
|
|
190
|
+
query: { ...searchQueryRef.current, count: true, results: false },
|
|
191
|
+
throwOnError: true,
|
|
192
|
+
});
|
|
193
|
+
if (data.count === undefined) {
|
|
194
|
+
throw new Error("the search returned no count for the selection");
|
|
195
|
+
}
|
|
196
|
+
return data.count;
|
|
197
|
+
}, [mailboxId]);
|
|
189
198
|
|
|
190
199
|
const applyBatchFor = useCallback(
|
|
191
200
|
(action: EscalatedAction): ApplyBatch =>
|
|
@@ -235,30 +244,27 @@ export const useEscalatedActions = ({
|
|
|
235
244
|
|
|
236
245
|
const escalate = useCallback(() => {
|
|
237
246
|
cancelRef.current = false;
|
|
238
|
-
setPhase({ kind: "counting"
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
247
|
+
setPhase({ kind: "counting" });
|
|
248
|
+
fetchMatchCount().then(
|
|
249
|
+
(total) => {
|
|
250
|
+
if (cancelRef.current) {
|
|
251
|
+
setPhase({ kind: "idle" });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
setPhase({ kind: "escalated", total });
|
|
255
|
+
},
|
|
256
|
+
(error: unknown) => {
|
|
245
257
|
pushError(
|
|
246
258
|
buildMutationErrorBanner(
|
|
247
259
|
"Couldn't count matching messages",
|
|
248
260
|
"The count didn't finish.",
|
|
249
|
-
|
|
261
|
+
error,
|
|
250
262
|
),
|
|
251
263
|
);
|
|
252
264
|
setPhase({ kind: "idle" });
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
setPhase({ kind: "idle" });
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
setPhase({ kind: "escalated", total: result.total });
|
|
260
|
-
});
|
|
261
|
-
}, [fetchIdsPage, pushError]);
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
}, [fetchMatchCount, pushError]);
|
|
262
268
|
|
|
263
269
|
const stop = useCallback(() => {
|
|
264
270
|
cancelRef.current = true;
|
|
@@ -279,8 +285,8 @@ export const useEscalatedActions = ({
|
|
|
279
285
|
runningRef.current = true;
|
|
280
286
|
setRunningAction(action);
|
|
281
287
|
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
282
|
-
// predicate can match more by the time the run
|
|
283
|
-
//
|
|
288
|
+
// predicate can match more by the time the run pages it than the count
|
|
289
|
+
// saw, and the bar must never show more done than out of.
|
|
284
290
|
const onProgress = (next: BulkActionProgress) =>
|
|
285
291
|
setProgress(honestProgress(next));
|
|
286
292
|
const applyBatch = applyBatchFor(action);
|
|
@@ -3,7 +3,6 @@ import { describe, test } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
BULK_ACTION_CHUNK_SIZE,
|
|
5
5
|
chunkIds,
|
|
6
|
-
countMatches,
|
|
7
6
|
type FetchIdsPageResult,
|
|
8
7
|
honestProgress,
|
|
9
8
|
runChunkedAction,
|
|
@@ -297,67 +296,10 @@ describe("runPredicateAction", () => {
|
|
|
297
296
|
});
|
|
298
297
|
});
|
|
299
298
|
|
|
300
|
-
describe("countMatches", () => {
|
|
301
|
-
const neverCancelled = () => false;
|
|
302
|
-
|
|
303
|
-
test("counts across every page until the token is exhausted", async () => {
|
|
304
|
-
let call = 0;
|
|
305
|
-
const pages: FetchIdsPageResult[] = [
|
|
306
|
-
{ ids: ids(500, "a"), continuationToken: "t1" },
|
|
307
|
-
{ ids: ids(500, "b"), continuationToken: "t2" },
|
|
308
|
-
{ ids: ids(412, "c") },
|
|
309
|
-
];
|
|
310
|
-
const fetch = async () => pages[call++];
|
|
311
|
-
const outcome = await countMatches(fetch, () => undefined, neverCancelled);
|
|
312
|
-
assert.deepEqual(outcome, { total: 1412, cancelled: false });
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
test("reports a running total after each page", async () => {
|
|
316
|
-
let call = 0;
|
|
317
|
-
const pages: FetchIdsPageResult[] = [
|
|
318
|
-
{ ids: ids(500), continuationToken: "t1" },
|
|
319
|
-
{ ids: ids(300) },
|
|
320
|
-
];
|
|
321
|
-
const fetch = async () => pages[call++];
|
|
322
|
-
const progressCalls: number[] = [];
|
|
323
|
-
await countMatches(fetch, (n) => progressCalls.push(n), neverCancelled);
|
|
324
|
-
assert.deepEqual(progressCalls, [500, 800]);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
test("cancelling mid-count stops paging and reports what it had so far", async () => {
|
|
328
|
-
let call = 0;
|
|
329
|
-
let cancelled = false;
|
|
330
|
-
const fetch = async (): Promise<FetchIdsPageResult> => {
|
|
331
|
-
call++;
|
|
332
|
-
return { ids: ids(500), continuationToken: "more" };
|
|
333
|
-
};
|
|
334
|
-
const outcome = await countMatches(
|
|
335
|
-
fetch,
|
|
336
|
-
() => {
|
|
337
|
-
cancelled = true;
|
|
338
|
-
},
|
|
339
|
-
() => cancelled,
|
|
340
|
-
);
|
|
341
|
-
assert.equal(outcome.cancelled, true);
|
|
342
|
-
assert.equal(call, 1);
|
|
343
|
-
assert.equal(outcome.total, 500);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
test("an infra failure while paging stops the count and reports the error", async () => {
|
|
347
|
-
const boom = new Error("network blip");
|
|
348
|
-
const fetch = async (): Promise<FetchIdsPageResult> => {
|
|
349
|
-
throw boom;
|
|
350
|
-
};
|
|
351
|
-
const outcome = await countMatches(fetch, () => undefined, neverCancelled);
|
|
352
|
-
assert.equal(outcome.error, boom);
|
|
353
|
-
assert.equal(outcome.total, 0);
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
299
|
describe("honestProgress", () => {
|
|
358
|
-
// Regression for #109:
|
|
359
|
-
//
|
|
360
|
-
//
|
|
300
|
+
// Regression for #109: the count and `runPredicateAction` resolve the same
|
|
301
|
+
// predicate independently, so the delete can outrun the frozen `total` it
|
|
302
|
+
// started with when the result set grows in between.
|
|
361
303
|
test("leaves an on-track progress reading untouched", () => {
|
|
362
304
|
assert.deepEqual(honestProgress({ done: 50, total: 100 }), {
|
|
363
305
|
done: 50,
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -214,14 +214,14 @@ export const runPredicateAction = async (
|
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
216
|
* Corrects a progress reading so its `total` can never read as less than
|
|
217
|
-
* `done` (#109). `runPredicateAction`'s `total` is
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
217
|
+
* `done` (#109). `runPredicateAction`'s `total` is the count the server gave
|
|
218
|
+
* before the run resolved the same predicate a second, independent time; if
|
|
219
|
+
* more matches arrived in between, `done` can overtake it mid-run and a raw
|
|
220
|
+
* "Deleting 1,340 of 1,284" would follow. Widening the denominator to match
|
|
221
|
+
* keeps the bar's ratio sane (never past 100%) without claiming the original
|
|
222
|
+
* count was exact — the honest fix is admitting the reading grew, not taking a
|
|
223
|
+
* second count to reconcile it (the result set is live; a count taken any
|
|
224
|
+
* later is no less stale than the first).
|
|
225
225
|
*/
|
|
226
226
|
export const honestProgress = (
|
|
227
227
|
progress: BulkActionProgress,
|
|
@@ -230,45 +230,6 @@ export const honestProgress = (
|
|
|
230
230
|
total: Math.max(progress.total, progress.done),
|
|
231
231
|
});
|
|
232
232
|
|
|
233
|
-
export interface CountMatchesResult {
|
|
234
|
-
total: number;
|
|
235
|
-
cancelled: boolean;
|
|
236
|
-
error?: unknown;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* Pages the full predicate result set to find its exact total — the only way,
|
|
241
|
-
* short of a server-side total (out of scope, see issue #92), since search has
|
|
242
|
-
* no total-count field beyond a small capped-window estimate. Reports a
|
|
243
|
-
* running count via `onProgress` so a long count reads as progressing rather
|
|
244
|
-
* than hung, and checks `isCancelled` between pages so Stop takes effect
|
|
245
|
-
* within one page's latency.
|
|
246
|
-
*/
|
|
247
|
-
export const countMatches = async (
|
|
248
|
-
fetchIdsPage: FetchIdsPage,
|
|
249
|
-
onProgress: (countSoFar: number) => void,
|
|
250
|
-
isCancelled: () => boolean,
|
|
251
|
-
): Promise<CountMatchesResult> => {
|
|
252
|
-
let total = 0;
|
|
253
|
-
let token: string | undefined;
|
|
254
|
-
|
|
255
|
-
do {
|
|
256
|
-
if (isCancelled()) {
|
|
257
|
-
return { total, cancelled: true };
|
|
258
|
-
}
|
|
259
|
-
const fetched = await attempt(fetchIdsPage(token));
|
|
260
|
-
if (!fetched.ok) {
|
|
261
|
-
return { total, cancelled: false, error: fetched.error };
|
|
262
|
-
}
|
|
263
|
-
const page = fetched.value;
|
|
264
|
-
total += page.ids.length;
|
|
265
|
-
onProgress(total);
|
|
266
|
-
token = page.continuationToken;
|
|
267
|
-
} while (token);
|
|
268
|
-
|
|
269
|
-
return { total, cancelled: false };
|
|
270
|
-
};
|
|
271
|
-
|
|
272
233
|
export interface BulkRunOutcome {
|
|
273
234
|
done: number;
|
|
274
235
|
failedIds: string[];
|