@remit/web-client 0.0.98 → 0.0.100

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.98",
3
+ "version": "0.0.100",
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": {
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * A run states how it ended, to whoever is still there to read it (#521). The
9
+ * run screen invites the user to close it and keep the run going, which leaves
10
+ * the run with no screen of its own — so the list says what it reached, whether
11
+ * that was everything or a hundred out of three thousand.
12
+ *
13
+ * The list wires the virtualizer, routing and several data hooks together, so
14
+ * — as with this package's other component-level rules (see
15
+ * `MessageList.selection.test.ts`) — the wiring is read off the source. The
16
+ * sentences themselves are unit-tested in `../../lib/bulk-action-copy.test.ts`,
17
+ * and which runs reach this seam in `SelectionWizardHost.run-exit.test.ts`.
18
+ */
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
22
+
23
+ const reportBody = source.match(
24
+ /const reportRunOutcome = useCallback\(([\s\S]*?)\n\t\t\[pushError\],/,
25
+ )?.[1];
26
+
27
+ describe("reporting how a run ended", () => {
28
+ it("hands the wizard somewhere to report an ending it can no longer show", () => {
29
+ assert.ok(reportBody, "the list reports no run outcome");
30
+ const host = source.match(/<SelectionWizardHost[\s\S]*?\/>/)?.[0] ?? "";
31
+ assert.match(host, /onRunEnded=\{reportRunOutcome\}/);
32
+ });
33
+
34
+ it("names both endings: what it covered, and what it stopped short of", () => {
35
+ assert.match(reportBody ?? "", /bulkActionStoppedTitle\(outcome\.done\)/);
36
+ assert.match(
37
+ reportBody ?? "",
38
+ /bulkActionCompletionText\(kind, outcome\.done\)/,
39
+ );
40
+ });
41
+
42
+ it("raises a stopped run as a warning rather than a passing note", () => {
43
+ assert.match(reportBody ?? "", /severity: "warning"/);
44
+ });
45
+ });
46
+
47
+ describe("the run the wizard leaves behind", () => {
48
+ it("is stoppable from the wizard, which is not the same as closing it", () => {
49
+ assert.match(source, /stop: escalation\.stop,/);
50
+ });
51
+
52
+ it("keeps reporting on the bar from the moment it starts", () => {
53
+ assert.match(
54
+ source,
55
+ /escalation\.progress\?\.total \?\? selectionCount/,
56
+ "a run with no batch finished yet has no denominator to show",
57
+ );
58
+ });
59
+ });
@@ -14,6 +14,7 @@ import { Search } from "lucide-react";
14
14
  import type { RefObject } from "react";
15
15
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
16
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
17
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
17
18
  import { formatErrorMessage } from "@/components/ui/ErrorState";
18
19
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";
19
20
  import {
@@ -32,10 +33,14 @@ import {
32
33
  } from "@/hooks/useSelection";
33
34
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
34
35
  import {
36
+ type BulkActionKind,
35
37
  bulkActionCompletionText,
36
38
  bulkActionProgressLabel,
37
39
  bulkActionProgressTone,
40
+ bulkActionStoppedDetail,
41
+ bulkActionStoppedTitle,
38
42
  } from "@/lib/bulk-action-copy";
43
+ import type { BulkRunOutcome } from "@/lib/bulk-actions";
39
44
  import {
40
45
  describeSearchScope,
41
46
  escalatedStatusLabel,
@@ -241,6 +246,7 @@ export const MessageList = ({
241
246
  const isSearching = !!searchQuery?.trim();
242
247
  const listHeaderChrome = useListHeaderChrome();
243
248
  const { labels } = useLabelList(accountId);
249
+ const { pushError } = useErrorBanners();
244
250
 
245
251
  // Roving focus cursor (#429): the keyboard "where am I" pointer, distinct
246
252
  // from the open thread (`selectedMessageId` in the URL). j/k move this
@@ -336,6 +342,31 @@ export const MessageList = ({
336
342
  searchQuery: searchPredicate ?? {},
337
343
  });
338
344
 
345
+ // How a run ended, for the user who is no longer looking at the run screen.
346
+ // Closing the wizard mid-run is a movement the screen invites, and the run
347
+ // keeps going past it — so the list is what states the ending, whether the
348
+ // run covered everything or stopped short of it. The wizard calls this only
349
+ // once the user has left it, so an ending is never said twice.
350
+ const reportRunOutcome = useCallback(
351
+ (kind: BulkActionKind, matched: number, outcome: BulkRunOutcome) => {
352
+ // A run stopped by a thrown batch is already banner-ed where it threw.
353
+ if (outcome.error !== undefined) return;
354
+ if (outcome.cancelled) {
355
+ pushError({
356
+ severity: "warning",
357
+ title: bulkActionStoppedTitle(outcome.done),
358
+ detail: bulkActionStoppedDetail(kind, outcome.done, matched),
359
+ });
360
+ return;
361
+ }
362
+ pushError({
363
+ severity: "info",
364
+ title: bulkActionCompletionText(kind, outcome.done),
365
+ });
366
+ },
367
+ [pushError],
368
+ );
369
+
339
370
  // The one way selection mode ends (#115): cancel, a completed delete or
340
371
  // move, a plain click that collapses a range, switching mailboxes, the back
341
372
  // gesture. Nothing calls the selection hook's `clearSelection` directly, so
@@ -625,9 +656,7 @@ export const MessageList = ({
625
656
 
626
657
  // The escalated selection as the wizard walks it (#508): the words the bar
627
658
  // already names the predicate with, the count it was escalated to, and the
628
- // chunked runner that re-resolves it. The wizard's review screen stands in
629
- // front of that runner and its run screen drives it, so nothing here reports
630
- // an outcome of its own.
659
+ // chunked runner that re-resolves it.
631
660
  const escalatedSelection: EscalatedSelection | undefined =
632
661
  escalation.phase.kind === "escalated"
633
662
  ? {
@@ -635,6 +664,7 @@ export const MessageList = ({
635
664
  total: escalation.phase.total,
636
665
  searchQuery: searchPredicate ?? {},
637
666
  run: (action: EscalatedAction) => escalation.runAction(action),
667
+ stop: escalation.stop,
638
668
  }
639
669
  : undefined;
640
670
 
@@ -1062,11 +1092,15 @@ export const MessageList = ({
1062
1092
  ? escalation.phase.total
1063
1093
  : selectedCount;
1064
1094
 
1095
+ // A run reports on the bar from the moment it starts, not from its first
1096
+ // finished batch: the wizard invites the user back here mid-run, and a run
1097
+ // with no reading yet is still a run — it is counted against what it was
1098
+ // started with until it has covered something of its own.
1065
1099
  const selectionStatusLabel = escalation.runningAction
1066
1100
  ? bulkActionProgressLabel(
1067
1101
  escalation.runningAction.kind,
1068
1102
  escalation.progress?.done ?? 0,
1069
- escalation.progress?.total ?? 0,
1103
+ escalation.progress?.total ?? selectionCount,
1070
1104
  )
1071
1105
  : escalation.phase.kind === "counting"
1072
1106
  ? escalation.phase.countSoFar >= 5000
@@ -1088,14 +1122,13 @@ export const MessageList = ({
1088
1122
  }
1089
1123
  : undefined;
1090
1124
 
1091
- const selectionProgress =
1092
- escalation.runningAction && escalation.progress
1093
- ? {
1094
- value: escalation.progress.done,
1095
- max: escalation.progress.total,
1096
- tone: bulkActionProgressTone(escalation.runningAction.kind),
1097
- }
1098
- : undefined;
1125
+ const selectionProgress = escalation.runningAction
1126
+ ? {
1127
+ value: escalation.progress?.done ?? 0,
1128
+ max: escalation.progress?.total ?? selectionCount,
1129
+ tone: bulkActionProgressTone(escalation.runningAction.kind),
1130
+ }
1131
+ : undefined;
1099
1132
 
1100
1133
  // At most one escalation notice at a time, ranked by how actionable it is:
1101
1134
  // an in-progress counting/escalated state and its own action always wins;
@@ -1325,6 +1358,7 @@ export const MessageList = ({
1325
1358
  escalated={escalatedSelection}
1326
1359
  escalatedProgress={escalation.progress}
1327
1360
  onFinished={exitSelection}
1361
+ onRunEnded={reportRunOutcome}
1328
1362
  />
1329
1363
  </>
1330
1364
  );
@@ -15,9 +15,9 @@ import { Drawer } from "vaul";
15
15
  import { ErrorState } from "@/components/ui/ErrorState";
16
16
  import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
17
17
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
18
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
18
19
  import { useIsDesktop } from "@/hooks/useMediaQuery";
19
- import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
20
- import { buildMoveTargets } from "@/lib/move-targets";
20
+ import { buildMoveOptions } from "@/lib/move-options";
21
21
  import { cn } from "@/lib/utils";
22
22
 
23
23
  interface MoveToTriggerProps {
@@ -71,15 +71,7 @@ export const MoveToTrigger = ({
71
71
  const triggerLabel = label ?? "Move to folder";
72
72
  const popoverId = useId();
73
73
  const { t } = useTranslation("mail", { useSuspense: false });
74
- // `labelForMailbox` expects a translator with a positional `(key,
75
- // fallback)` shape; i18next's `t` treats the second argument as an options
76
- // object — passing it raw breaks fallback behavior. Wrap it the same way
77
- // the sidebar adapter does, memoized so it's a stable `useMemo` dependency.
78
- const translator = useCallback(
79
- (key: string, fallback: string): string =>
80
- t(key, { defaultValue: fallback }),
81
- [t],
82
- );
74
+ const translator = useFolderLabelTranslator();
83
75
 
84
76
  const {
85
77
  data: mailboxesResponse,
@@ -98,28 +90,21 @@ export const MoveToTrigger = ({
98
90
  const folderAppointments = useFolderAppointments(accountId);
99
91
  const { createFolder } = useCreateMailbox(accountId);
100
92
 
101
- const options = useMemo<MoveMailboxOption[]>(() => {
102
- const targets = buildMoveTargets(
103
- mailboxesResponse?.items ?? [],
104
- folderAppointments,
105
- );
106
- const roleMap = buildMailboxRoleMap(folderAppointments);
107
- return targets.map((mailbox) => ({
108
- id: mailbox.mailboxId,
109
- label: labelForMailbox(
110
- mailbox,
111
- roleMap.get(mailbox.mailboxId),
93
+ const options = useMemo<MoveMailboxOption[]>(
94
+ () =>
95
+ buildMoveOptions({
96
+ mailboxes: mailboxesResponse?.items ?? [],
97
+ folderAppointments,
98
+ currentMailboxId,
112
99
  translator,
113
- ),
114
- searchValue: mailbox.fullPath,
115
- isCurrent: mailbox.mailboxId === currentMailboxId,
116
- }));
117
- }, [
118
- mailboxesResponse?.items,
119
- folderAppointments,
120
- currentMailboxId,
121
- translator,
122
- ]);
100
+ }),
101
+ [
102
+ mailboxesResponse?.items,
103
+ folderAppointments,
104
+ currentMailboxId,
105
+ translator,
106
+ ],
107
+ );
123
108
 
124
109
  const handleSelect = useCallback(
125
110
  (destinationMailboxId: string) => {
@@ -0,0 +1,83 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * Every way off the run screen is the same movement, and none of them ends the
9
+ * run the screen promised would keep going (#521). Ending it is a control of
10
+ * its own, and whichever way the user leaves, the run reports where it got to.
11
+ *
12
+ * The host wires routing, history and several data hooks together, so — as with
13
+ * this package's other component-level rules (see `MessageList.selection.test.ts`)
14
+ * — the wiring is read off the source. What the exits then do is proven where it
15
+ * can be run: `../../hooks/escalated-run-lifetime.render.test.ts` for the run's
16
+ * lifetime, and `@remit/ui`'s `selection-wizard.render.test.ts` for the controls.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
21
+
22
+ describe("leaving the run screen", () => {
23
+ it("routes the header's X and back arrow through the same dismiss the footer uses", () => {
24
+ assert.match(source, /onExit=\{current === "run" \? dismiss : cancel\}/);
25
+ });
26
+
27
+ it("routes hardware back through that dismiss too", () => {
28
+ assert.match(
29
+ source,
30
+ /if \(action !== "BACK"\) return false;\s*\n\s*dismiss\(\);/,
31
+ );
32
+ });
33
+
34
+ it("leaves the run alone on the way out", () => {
35
+ const dismissBody = source.match(
36
+ /const dismiss = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
37
+ )?.[1];
38
+ assert.ok(dismissBody, "the run screen has no dismiss");
39
+ assert.doesNotMatch(dismissBody, /stop/i);
40
+ });
41
+ });
42
+
43
+ describe("ending the run", () => {
44
+ it("is a control of its own, offered only while a run is in flight", () => {
45
+ assert.match(source, /onCancelRun: runInFlight \? stopRun : undefined/);
46
+ assert.match(
47
+ source,
48
+ /const runInFlight =\s*bulkRun !== undefined && bulkRun\.outcome === undefined;/,
49
+ );
50
+ });
51
+
52
+ it("stops whichever runner is paging — the list's or the wizard's own", () => {
53
+ const stopBody = source.match(
54
+ /const stopRun = useCallback\(\(\) => \{([\s\S]*?)\}, \[/,
55
+ )?.[1];
56
+ assert.ok(stopBody, "there is no way to stop a run");
57
+ assert.match(stopBody, /escalated\.stop\(\)/);
58
+ assert.match(stopBody, /stopBulk\(\)/);
59
+ });
60
+ });
61
+
62
+ /**
63
+ * Both runners report, not just the predicate one: a ticked select-all runs on
64
+ * the wizard's own runner, and the same screen makes the same promise over it.
65
+ */
66
+ describe("stating how a run ended once the screen is gone", () => {
67
+ it("marks the walk-away where it happens rather than reading it back off the URL", () => {
68
+ assert.match(source, /walkedAway\.current = true;\s*\n\s*closeWizard\(/);
69
+ });
70
+
71
+ for (const runner of ["runBulk", "runEscalated"]) {
72
+ it(`reports the run ${runner} drove`, () => {
73
+ const body =
74
+ source.match(
75
+ new RegExp(
76
+ `const ${runner} = useCallback\\(([\\s\\S]*?)\\n\\t\\}, \\[`,
77
+ ),
78
+ )?.[1] ?? "";
79
+ assert.match(body, /if \(walkedAway\.current\)/);
80
+ assert.match(body, /onRunEnded\?\.\(action\.kind, /);
81
+ });
82
+ }
83
+ });
@@ -41,16 +41,16 @@ import {
41
41
  useEscalatedActions,
42
42
  } from "@/hooks/useEscalatedActions";
43
43
  import { useCreateFilter } from "@/hooks/useFilters";
44
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
44
45
  import { useMatchSample, useSearchMatchSample } from "@/hooks/useMatchSample";
45
46
  import { useOrganizeJob } from "@/hooks/useOrganizeJob";
46
47
  import { useOrganizeWiden } from "@/hooks/useOrganizeWiden";
47
48
  import { useRulePreview } from "@/hooks/useRulePreview";
48
49
  import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
49
50
  import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
50
- import { getMailboxDisplayName } from "@/lib/folder-roles";
51
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
52
  import { useMailContext } from "@/lib/mail-context";
53
- import { buildMoveTargets } from "@/lib/move-targets";
53
+ import { buildMoveOptions } from "@/lib/move-options";
54
54
  import {
55
55
  buildWizardDraft,
56
56
  canBackApplyDraft,
@@ -106,9 +106,12 @@ export interface EscalatedSelection {
106
106
  /**
107
107
  * Runs one verb over the whole predicate, paging it server-side. The chunked
108
108
  * runner the list already owns: the run screen drives it, and the review
109
- * screen is what now stands in front of it.
109
+ * screen is what now stands in front of it. The run belongs to the list, so
110
+ * it survives the wizard closing over it — as the run screen promises.
110
111
  */
111
112
  run: (action: EscalatedAction) => Promise<BulkRunOutcome>;
113
+ /** Ends that run at the next page boundary, which leaving the wizard does not. */
114
+ stop: () => void;
112
115
  }
113
116
 
114
117
  export interface SelectionWizardHostProps {
@@ -133,6 +136,17 @@ export interface SelectionWizardHostProps {
133
136
  escalatedProgress?: BulkActionProgress;
134
137
  /** The wizard is done with the selection, and the list drops it. */
135
138
  onFinished: () => void;
139
+ /**
140
+ * How a run ended, once the screen that was reporting on it is gone. The run
141
+ * screen invites the user to close it and keep the run going, so the surface
142
+ * that outlives the wizard is what states the ending. Called only for a run
143
+ * the user walked away from; a run screen still up reports in place.
144
+ */
145
+ onRunEnded?: (
146
+ kind: EscalatedAction["kind"],
147
+ matched: number,
148
+ outcome: BulkRunOutcome,
149
+ ) => void;
136
150
  }
137
151
 
138
152
  /**
@@ -248,6 +262,7 @@ function SelectionWizardSession({
248
262
  escalatedProgress,
249
263
  searchConversion,
250
264
  onFinished,
265
+ onRunEnded,
251
266
  step,
252
267
  goToStep,
253
268
  goBack,
@@ -296,6 +311,11 @@ function SelectionWizardSession({
296
311
  // cleared, so a failed start can be retried.
297
312
  const [backApplyDraft, setBackApplyDraft] = useState<OrganizeDraft>();
298
313
  const commitSent = useRef(false);
314
+ // The user left the run screen while it was still reporting. Held here rather
315
+ // than read back off the URL: the rewind the exit starts lands a frame or two
316
+ // later, and an outcome arriving in between would find a screen that is on
317
+ // its way out and say nothing.
318
+ const walkedAway = useRef(false);
299
319
 
300
320
  const messageIds = useMemo(
301
321
  () => selection.map((message) => message.id),
@@ -367,17 +387,16 @@ function SelectionWizardSession({
367
387
  staleTime: Number.POSITIVE_INFINITY,
368
388
  });
369
389
  const folderAppointments = useFolderAppointments(accountId);
390
+ const translator = useFolderLabelTranslator();
370
391
  const mailboxes = useMemo<MoveMailboxOption[]>(
371
392
  () =>
372
- buildMoveTargets(mailboxesData?.items ?? [], folderAppointments).map(
373
- (mailbox) => ({
374
- id: mailbox.mailboxId,
375
- label: getMailboxDisplayName(mailbox.fullPath),
376
- searchValue: mailbox.fullPath,
377
- isCurrent: mailbox.mailboxId === mailboxId,
378
- }),
379
- ),
380
- [mailboxesData?.items, folderAppointments, mailboxId],
393
+ buildMoveOptions({
394
+ mailboxes: mailboxesData?.items ?? [],
395
+ folderAppointments,
396
+ currentMailboxId: mailboxId,
397
+ translator,
398
+ }),
399
+ [mailboxesData?.items, folderAppointments, mailboxId, translator],
381
400
  );
382
401
  const { createFolder } = useCreateMailbox(accountId);
383
402
 
@@ -575,8 +594,9 @@ function SelectionWizardSession({
575
594
  setBulkRun({ matched: ids.length });
576
595
  const outcome = await runAction(action, [...ids]);
577
596
  setBulkRun({ matched: ids.length, outcome });
597
+ if (walkedAway.current) onRunEnded?.(action.kind, ids.length, outcome);
578
598
  },
579
- [verb, named.moveMailboxId, junkMailboxId, runAction],
599
+ [verb, named.moveMailboxId, junkMailboxId, runAction, onRunEnded],
580
600
  );
581
601
 
582
602
  // The escalated predicate, run by the chunked runner the list already owns.
@@ -595,7 +615,10 @@ function SelectionWizardSession({
595
615
  setBulkRun({ matched: escalated.total });
596
616
  const outcome = await escalated.run(action);
597
617
  setBulkRun({ matched: escalated.total, outcome });
598
- }, [escalated, verb, named.moveMailboxId, junkMailboxId]);
618
+ if (walkedAway.current) {
619
+ onRunEnded?.(action.kind, escalated.total, outcome);
620
+ }
621
+ }, [escalated, verb, named.moveMailboxId, junkMailboxId, onRunEnded]);
599
622
 
600
623
  const { start: startJob } = organizeJob;
601
624
  const { createFilterAsync } = createFilter;
@@ -815,12 +838,28 @@ function SelectionWizardSession({
815
838
  }, [closeWizard, steps, current]);
816
839
 
817
840
  // The run screen's way out. The action has happened, so the rows it was
818
- // aimed at are no longer a selection waiting to be acted on.
841
+ // aimed at are no longer a selection waiting to be acted on. It leaves the
842
+ // run alone: the screen says so in as many words, and stopping it is a
843
+ // control of its own.
819
844
  const dismiss = useCallback(() => {
845
+ walkedAway.current = true;
820
846
  closeWizard(steps, current);
821
847
  onFinished();
822
848
  }, [closeWizard, steps, current, onFinished]);
823
849
 
850
+ // Ends the run rather than leaving it. The escalated predicate is run by the
851
+ // list's runner and a bounded selection by this wizard's own, so the stop
852
+ // follows whichever one is paging.
853
+ const { stop: stopBulk } = bulk;
854
+ const runInFlight = bulkRun !== undefined && bulkRun.outcome === undefined;
855
+ const stopRun = useCallback(() => {
856
+ if (escalated) {
857
+ escalated.stop();
858
+ return;
859
+ }
860
+ stopBulk();
861
+ }, [escalated, stopBulk]);
862
+
824
863
  // Hardware back on the run screen leaves the wizard, the movement the header
825
864
  // arrow and the footer already make there (`backExits`). Run is an ordinary
826
865
  // pushed entry, so without this the button pops back to Review, where the
@@ -868,7 +907,10 @@ function SelectionWizardSession({
868
907
  blockedReason={blockedReason}
869
908
  nudged={nudgedStep === current}
870
909
  onBack={goBack}
871
- onExit={cancel}
910
+ // Every exit from the run screen is the same movement: the header's X,
911
+ // the header arrow, the footer and hardware Back all leave a run that
912
+ // has already been sent, and all of them drop the selection with it.
913
+ onExit={current === "run" ? dismiss : cancel}
872
914
  onContinue={advance}
873
915
  onCommit={commit}
874
916
  match={{
@@ -945,6 +987,7 @@ function SelectionWizardSession({
945
987
  failures: run.failures,
946
988
  onRetry: retry,
947
989
  onDismiss: dismiss,
990
+ onCancelRun: runInFlight ? stopRun : undefined,
948
991
  }}
949
992
  />
950
993
  );
@@ -14,13 +14,12 @@ import {
14
14
  useRef,
15
15
  useState,
16
16
  } from "react";
17
- import { useTranslation } from "react-i18next";
18
17
  import {
19
18
  useFolderAppointments,
20
19
  useInboxMailbox,
21
20
  } from "@/hooks/useArchiveMailbox";
22
- import { buildMailboxRoleMap, labelForMailbox } from "@/lib/folder-roles";
23
- import { buildMoveTargets } from "@/lib/move-targets";
21
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
22
+ import { buildMoveOptions } from "@/lib/move-options";
24
23
  import {
25
24
  recordRescueCandidatesSurfaced,
26
25
  recordRescueCommitted,
@@ -51,12 +50,7 @@ export function SpamRescue({
51
50
  }: SpamRescueProps) {
52
51
  const [open, setOpen] = useState(false);
53
52
  const telemetry = useTelemetry();
54
- const { t } = useTranslation("mail", { useSuspense: false });
55
- const translator = useCallback(
56
- (key: string, fallback: string): string =>
57
- t(key, { defaultValue: fallback }),
58
- [t],
59
- );
53
+ const translator = useFolderLabelTranslator();
60
54
 
61
55
  const { inboxMailboxId } = useInboxMailbox(accountId);
62
56
  const folderAppointments = useFolderAppointments(accountId);
@@ -66,28 +60,21 @@ export function SpamRescue({
66
60
  staleTime: Infinity,
67
61
  });
68
62
 
69
- const folders = useMemo<MoveMailboxOption[]>(() => {
70
- const targets = buildMoveTargets(
71
- mailboxesResponse?.items ?? [],
72
- folderAppointments,
73
- );
74
- const roleMap = buildMailboxRoleMap(folderAppointments);
75
- return targets.map((mailbox) => ({
76
- id: mailbox.mailboxId,
77
- label: labelForMailbox(
78
- mailbox,
79
- roleMap.get(mailbox.mailboxId),
63
+ const folders = useMemo<MoveMailboxOption[]>(
64
+ () =>
65
+ buildMoveOptions({
66
+ mailboxes: mailboxesResponse?.items ?? [],
67
+ folderAppointments,
68
+ currentMailboxId,
80
69
  translator,
81
- ),
82
- searchValue: mailbox.fullPath,
83
- isCurrent: mailbox.mailboxId === currentMailboxId,
84
- }));
85
- }, [
86
- mailboxesResponse?.items,
87
- folderAppointments,
88
- currentMailboxId,
89
- translator,
90
- ]);
70
+ }),
71
+ [
72
+ mailboxesResponse?.items,
73
+ folderAppointments,
74
+ currentMailboxId,
75
+ translator,
76
+ ],
77
+ );
91
78
 
92
79
  const defaultDestinationId = useMemo(() => {
93
80
  if (inboxMailboxId) return inboxMailboxId;
@@ -13,13 +13,10 @@ import { AlertTriangle, FolderInput, Loader2, Trash2, X } from "lucide-react";
13
13
  import { useCallback, useEffect, useMemo, useState } from "react";
14
14
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
15
15
  import { useDeleteFolder } from "@/hooks/useDeleteFolder";
16
- import {
17
- excludeFolder,
18
- initialStage,
19
- moveProgressLabel,
20
- } from "@/lib/delete-folder";
16
+ import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
17
+ import { initialStage, moveProgressLabel } from "@/lib/delete-folder";
21
18
  import { getMailboxDisplayName } from "@/lib/folder-roles";
22
- import { buildMoveTargets } from "@/lib/move-targets";
19
+ import { buildMoveOptions } from "@/lib/move-options";
23
20
 
24
21
  interface DeleteFolderDialogProps {
25
22
  open: boolean;
@@ -61,6 +58,7 @@ export function DeleteFolderDialog({
61
58
  initialStage(folder.messageCount),
62
59
  );
63
60
  const { createFolder } = useCreateMailbox(accountId);
61
+ const translator = useFolderLabelTranslator();
64
62
  const {
65
63
  phase,
66
64
  progress,
@@ -90,15 +88,13 @@ export function DeleteFolderDialog({
90
88
 
91
89
  const destinations = useMemo<MoveMailboxOption[]>(
92
90
  () =>
93
- excludeFolder(
94
- buildMoveTargets(mailboxes, appointments),
95
- folder.mailboxId,
96
- ).map((mailbox) => ({
97
- id: mailbox.mailboxId,
98
- label: getMailboxDisplayName(mailbox.fullPath),
99
- searchValue: mailbox.fullPath,
100
- })),
101
- [mailboxes, appointments, folder.mailboxId],
91
+ buildMoveOptions({
92
+ mailboxes,
93
+ folderAppointments: appointments,
94
+ excludeMailboxId: folder.mailboxId,
95
+ translator,
96
+ }),
97
+ [mailboxes, appointments, folder.mailboxId, translator],
102
98
  );
103
99
 
104
100
  const handleCreateFolder = async (
@@ -0,0 +1,265 @@
1
+ /**
2
+ * A run outlives the selection it came from (#521). The run screen tells the
3
+ * user "This keeps running if you close the wizard", and closing the wizard
4
+ * drops the escalated selection on the way out — so leaving the selection and
5
+ * ending the run have to be two things.
6
+ *
7
+ * These mount the real hook against the real fetch seam and count the batches
8
+ * that actually left, so what is pinned is the run's own lifetime rather than
9
+ * the wording of whichever screen started it. Every run is parked at a page
10
+ * boundary before the test acts on it: that is where the run reads whether it
11
+ * has been told to stop, and so the only place the difference shows.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, beforeEach, describe, it } from "node:test";
16
+ import { act, createElement } from "react";
17
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
18
+ import {
19
+ type EscalationSearchQuery,
20
+ type UseEscalatedActionsResult,
21
+ useEscalatedActions,
22
+ } from "./useEscalatedActions";
23
+
24
+ const INBOX = "mbx-inbox";
25
+ const PAGE_SIZE = 100;
26
+ /** Three full pages and a short one — page boundaries to park a run at. */
27
+ const TOTAL = PAGE_SIZE * 3 + 12;
28
+
29
+ let harness: DomHarness | undefined;
30
+ let server: MailServer | undefined;
31
+
32
+ interface MailServer {
33
+ /** Every message id a delete call carried, in the order they were sent. */
34
+ deleted: () => string[];
35
+ /** Deletes queued at the boundary rather than answered. */
36
+ held: () => number;
37
+ /** Answer everything held, and stop holding. */
38
+ release: () => void;
39
+ restore: () => void;
40
+ }
41
+
42
+ /**
43
+ * A search that pages `TOTAL` ids at the hook's own page size, keyed by the
44
+ * query, so a run started against one predicate is distinguishable from a run
45
+ * that followed the list onto a later one.
46
+ */
47
+ const searchPage = (url: URL): unknown => {
48
+ const query = url.searchParams.get("query") ?? "";
49
+ const served = Number(url.searchParams.get("continuationToken") ?? "0");
50
+ const size = Math.min(PAGE_SIZE, Math.max(TOTAL - served, 0));
51
+ return {
52
+ items: Array.from({ length: size }, (_, i) => ({
53
+ messageId: `${query}-${served + i}`,
54
+ })),
55
+ continuationToken:
56
+ served + size < TOTAL ? String(served + size) : undefined,
57
+ };
58
+ };
59
+
60
+ /**
61
+ * Answers the search and the bulk delete, holding every delete after the first
62
+ * so a run can be caught mid-flight with real progress behind it.
63
+ */
64
+ const startMailServer = (): MailServer => {
65
+ const original = globalThis.fetch;
66
+ const deleted: string[] = [];
67
+ let waiting: Array<() => void> = [];
68
+ let holding = true;
69
+ let served = 0;
70
+
71
+ globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
72
+ const request = input instanceof Request ? input : undefined;
73
+ const raw = request ? await request.clone().text() : undefined;
74
+ const url = new URL(request ? request.url : String(input), "http://x");
75
+
76
+ if (url.pathname.endsWith("/threads/search")) {
77
+ return Response.json(searchPage(url));
78
+ }
79
+
80
+ const body = raw
81
+ ? (JSON.parse(raw) as { messageIds: string[] })
82
+ : undefined;
83
+ served += 1;
84
+ if (holding && served > 1) {
85
+ await new Promise<void>((resolve) => waiting.push(resolve));
86
+ }
87
+ deleted.push(...(body?.messageIds ?? []));
88
+ return Response.json({ successCount: 0, failureCount: 0 });
89
+ }) as typeof globalThis.fetch;
90
+
91
+ return {
92
+ deleted: () => deleted,
93
+ held: () => waiting.length,
94
+ release: () => {
95
+ holding = false;
96
+ const queued = waiting;
97
+ waiting = [];
98
+ for (const resolve of queued) resolve();
99
+ },
100
+ restore: () => {
101
+ globalThis.fetch = original;
102
+ },
103
+ };
104
+ };
105
+
106
+ const mountEscalation = (
107
+ initialQuery: EscalationSearchQuery,
108
+ ): {
109
+ hook: () => UseEscalatedActionsResult;
110
+ setQuery: (next: EscalationSearchQuery) => void;
111
+ } => {
112
+ let value: UseEscalatedActionsResult | undefined;
113
+ const Probe = ({ search }: { search: EscalationSearchQuery }) => {
114
+ value = useEscalatedActions({
115
+ mailboxId: INBOX,
116
+ enabled: true,
117
+ predicateKey: JSON.stringify(search),
118
+ searchQuery: search,
119
+ });
120
+ return null;
121
+ };
122
+ harness = createDomHarness();
123
+ harness.renderApp(createElement(Probe, { search: initialQuery }));
124
+ return {
125
+ hook: () => {
126
+ if (!value) throw new Error("hook did not render");
127
+ return value;
128
+ },
129
+ setQuery: (next) => {
130
+ harness?.renderApp(createElement(Probe, { search: next }));
131
+ },
132
+ };
133
+ };
134
+
135
+ /** A press: the state it sets belongs to the same commit React would batch. */
136
+ const press = <T>(action: () => T): T => {
137
+ let result: T | undefined;
138
+ act(() => {
139
+ result = action();
140
+ });
141
+ return result as T;
142
+ };
143
+
144
+ /** Enough turns for a sequential page-and-apply run to reach its end. */
145
+ const settle = async (): Promise<void> => {
146
+ if (!harness) throw new Error("nothing mounted");
147
+ for (let round = 0; round < 40; round += 1) await harness.flush();
148
+ };
149
+
150
+ /** Park the run at the page boundary the second batch is waiting on. */
151
+ const parkAtBoundary = async (): Promise<void> => {
152
+ if (!harness || !server) throw new Error("nothing mounted");
153
+ for (let round = 0; round < 20; round += 1) {
154
+ if (server.held() > 0) return;
155
+ await harness.flush();
156
+ }
157
+ throw new Error("the run never reached a page boundary");
158
+ };
159
+
160
+ const escalateTo = async (
161
+ hook: () => UseEscalatedActionsResult,
162
+ ): Promise<void> => {
163
+ press(() => hook().escalate());
164
+ await settle();
165
+ assert.deepEqual(hook().phase, { kind: "escalated", total: TOTAL });
166
+ };
167
+
168
+ beforeEach(() => {
169
+ server = startMailServer();
170
+ });
171
+
172
+ afterEach(() => {
173
+ harness?.close();
174
+ harness = undefined;
175
+ server?.release();
176
+ server?.restore();
177
+ server = undefined;
178
+ });
179
+
180
+ describe("an escalated run the user walks away from", () => {
181
+ it("keeps paging after the selection it came from is dropped", async () => {
182
+ const { hook } = mountEscalation({ query: "npm" });
183
+ await escalateTo(hook);
184
+
185
+ const run = press(() => hook().runAction({ kind: "delete" }));
186
+ await parkAtBoundary();
187
+ // What closing the wizard does on the way out: the list drops the escalated
188
+ // selection. The run is not the selection.
189
+ press(() => hook().clear());
190
+ press(() => server?.release());
191
+ await settle();
192
+ const outcome = await run;
193
+
194
+ assert.equal(outcome.cancelled, false);
195
+ assert.equal(outcome.done, TOTAL);
196
+ assert.equal(server?.deleted().length, TOTAL);
197
+ });
198
+
199
+ it("keeps paging the predicate it was started against when the search moves on", async () => {
200
+ const { hook, setQuery } = mountEscalation({ query: "npm" });
201
+ await escalateTo(hook);
202
+
203
+ const run = press(() => hook().runAction({ kind: "delete" }));
204
+ await parkAtBoundary();
205
+ setQuery({ query: "invoices" });
206
+ press(() => server?.release());
207
+ await settle();
208
+ const outcome = await run;
209
+
210
+ assert.equal(outcome.done, TOTAL);
211
+ assert.deepEqual(
212
+ (server?.deleted() ?? []).filter((id) => !id.startsWith("npm-")),
213
+ [],
214
+ "the run followed the query typed after it started",
215
+ );
216
+ });
217
+
218
+ it("returns the phase to idle only once the run it owns has ended", async () => {
219
+ const { hook } = mountEscalation({ query: "npm" });
220
+ await escalateTo(hook);
221
+
222
+ const run = press(() => hook().runAction({ kind: "delete" }));
223
+ await parkAtBoundary();
224
+ press(() => hook().clear());
225
+ assert.equal(hook().isRunning, true);
226
+
227
+ press(() => server?.release());
228
+ await settle();
229
+ await run;
230
+
231
+ assert.deepEqual(hook().phase, { kind: "idle" });
232
+ assert.equal(hook().isRunning, false);
233
+ });
234
+ });
235
+
236
+ describe("stopping the run, which is a different press", () => {
237
+ it("ends it at the next page boundary and says what it reached", async () => {
238
+ const { hook } = mountEscalation({ query: "npm" });
239
+ await escalateTo(hook);
240
+
241
+ const run = press(() => hook().runAction({ kind: "delete" }));
242
+ await parkAtBoundary();
243
+ press(() => hook().stop());
244
+ press(() => server?.release());
245
+ await settle();
246
+ const outcome = await run;
247
+
248
+ assert.equal(outcome.cancelled, true);
249
+ assert.ok(outcome.done > 0, "the batches already sent still count");
250
+ assert.ok(outcome.done < TOTAL, "the run carried on past the stop");
251
+ assert.equal(server?.deleted().length, outcome.done);
252
+ });
253
+ });
254
+
255
+ describe("clearing an escalated selection with nothing running", () => {
256
+ it("drops it back to bounded", async () => {
257
+ const { hook } = mountEscalation({ query: "npm" });
258
+ await escalateTo(hook);
259
+
260
+ press(() => hook().clear());
261
+ await settle();
262
+
263
+ assert.deepEqual(hook().phase, { kind: "idle" });
264
+ });
265
+ });
@@ -82,10 +82,18 @@ export interface UseEscalatedActionsResult {
82
82
  phase: EscalationPhase;
83
83
  /** Begin paging the predicate's full match set to find its total. */
84
84
  escalate: () => void;
85
- /** Stop whatever's running — counting or an action — at the next page
86
- * boundary. A no-op when nothing is running. */
85
+ /**
86
+ * Stop whatever's running counting or an action — at the next page
87
+ * boundary. A no-op when nothing is running. The only thing that ends a run
88
+ * in flight: leaving the selection, the wizard or the search does not.
89
+ */
87
90
  stop: () => void;
88
- /** Drop an escalated selection back to bounded without confirming anything. */
91
+ /**
92
+ * Drop an escalated selection back to bounded without confirming anything.
93
+ * A run in flight owns the phase and holds it until it ends, so this is a
94
+ * no-op then — the selection the user is leaving and the run they started
95
+ * from it are two different things.
96
+ */
89
97
  clear: () => void;
90
98
  /** True while a chunked run (bounded->100 ids, or the escalated predicate)
91
99
  * is in flight. */
@@ -136,40 +144,49 @@ export const useEscalatedActions = ({
136
144
  undefined,
137
145
  );
138
146
  const cancelRef = useRef(false);
147
+ // True from the moment a run starts until its outcome is in hand. A run is
148
+ // mail already leaving the mailbox, so nothing that merely changes what the
149
+ // list is showing gets to end it — only `stop`.
150
+ const runningRef = useRef(false);
139
151
  const queryClient = useQueryClient();
140
152
  const { pushError } = useErrorBanners();
141
153
 
142
154
  // A different search (or leaving search/desktop) makes any in-flight
143
155
  // escalation meaningless — it would otherwise keep counting or offering to
144
- // act on a predicate the visible list no longer reflects.
145
- // biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only the reset itself is unconditional, not a value read from either.
156
+ // act on a predicate the visible list no longer reflects. The selection goes;
157
+ // a run already going does not. It pages the predicate it was started
158
+ // against and reports what it reached, wherever the list moved on to.
159
+ // biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
146
160
  useEffect(() => {
147
- cancelRef.current = true;
161
+ if (!runningRef.current) cancelRef.current = true;
148
162
  setPhase({ kind: "idle" });
149
163
  }, [enabled, predicateKey]);
150
164
 
151
165
  const searchQueryRef = useRef(searchQuery);
152
166
  searchQueryRef.current = searchQuery;
153
167
 
154
- const fetchIdsPage = useCallback<FetchIdsPage>(
155
- async (continuationToken) => {
156
- const { data } = await threadOperationsSearchThreads({
157
- path: { mailboxId },
158
- query: {
159
- ...searchQueryRef.current,
160
- continuationToken,
161
- limit: PAGE_SIZE,
162
- },
163
- throwOnError: true,
164
- });
165
- return {
166
- ids: (data.items ?? []).map((item) => item.messageId),
167
- continuationToken: data.continuationToken,
168
- };
169
- },
168
+ const fetchPagesOf = useCallback(
169
+ (query: EscalationSearchQuery): FetchIdsPage =>
170
+ async (continuationToken) => {
171
+ const { data } = await threadOperationsSearchThreads({
172
+ path: { mailboxId },
173
+ query: { ...query, continuationToken, limit: PAGE_SIZE },
174
+ throwOnError: true,
175
+ });
176
+ return {
177
+ ids: (data.items ?? []).map((item) => item.messageId),
178
+ continuationToken: data.continuationToken,
179
+ };
180
+ },
170
181
  [mailboxId],
171
182
  );
172
183
 
184
+ const fetchIdsPage = useCallback<FetchIdsPage>(
185
+ (continuationToken) =>
186
+ fetchPagesOf(searchQueryRef.current)(continuationToken),
187
+ [fetchPagesOf],
188
+ );
189
+
173
190
  const applyBatchFor = useCallback(
174
191
  (action: EscalatedAction): ApplyBatch =>
175
192
  async (ids: string[]) => {
@@ -248,6 +265,7 @@ export const useEscalatedActions = ({
248
265
  }, []);
249
266
 
250
267
  const clear = useCallback(() => {
268
+ if (runningRef.current) return;
251
269
  cancelRef.current = true;
252
270
  setPhase({ kind: "idle" });
253
271
  }, []);
@@ -258,6 +276,7 @@ export const useEscalatedActions = ({
258
276
  ids?: string[],
259
277
  ): Promise<BulkRunOutcome> => {
260
278
  cancelRef.current = false;
279
+ runningRef.current = true;
261
280
  setRunningAction(action);
262
281
  // `honestProgress` widens `total` if `done` overtakes it (#109) — the
263
282
  // predicate can match more by the time the run re-pages it than
@@ -265,22 +284,34 @@ export const useEscalatedActions = ({
265
284
  const onProgress = (next: BulkActionProgress) =>
266
285
  setProgress(honestProgress(next));
267
286
  const applyBatch = applyBatchFor(action);
287
+ // The predicate as it read when the run was confirmed. The run outlives
288
+ // the screen that started it, so reading the live query on every page
289
+ // would let a search typed afterwards redirect what is being deleted.
290
+ const runPages = fetchPagesOf(searchQueryRef.current);
268
291
 
269
- const outcome =
270
- ids !== undefined
271
- ? await runChunkedAction(
272
- ids,
273
- applyBatch,
274
- onProgress,
275
- () => cancelRef.current,
276
- )
277
- : await runPredicateAction(
278
- fetchIdsPage,
279
- phase.kind === "escalated" ? phase.total : 0,
280
- applyBatch,
281
- onProgress,
282
- () => cancelRef.current,
283
- );
292
+ // The one invariant nothing may lose: while `runningRef` is up, both
293
+ // `clear` and the reset effect stand down, so a run that never marked
294
+ // itself finished would leave an escalated selection nobody can leave.
295
+ let outcome: BulkRunOutcome;
296
+ try {
297
+ outcome =
298
+ ids !== undefined
299
+ ? await runChunkedAction(
300
+ ids,
301
+ applyBatch,
302
+ onProgress,
303
+ () => cancelRef.current,
304
+ )
305
+ : await runPredicateAction(
306
+ runPages,
307
+ phase.kind === "escalated" ? phase.total : 0,
308
+ applyBatch,
309
+ onProgress,
310
+ () => cancelRef.current,
311
+ );
312
+ } finally {
313
+ runningRef.current = false;
314
+ }
284
315
 
285
316
  setRunningAction(undefined);
286
317
  setProgress(undefined);
@@ -300,7 +331,7 @@ export const useEscalatedActions = ({
300
331
  }
301
332
  return outcome;
302
333
  },
303
- [applyBatchFor, fetchIdsPage, phase, pushError, invalidateAfterRun],
334
+ [applyBatchFor, fetchPagesOf, phase, pushError, invalidateAfterRun],
304
335
  );
305
336
 
306
337
  return {
@@ -0,0 +1,19 @@
1
+ import { useCallback } from "react";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ /**
5
+ * `labelForMailbox` expects a positional `(key, fallback)` translator; i18next's
6
+ * `t` reads its second argument as an options object, which drops the fallback.
7
+ * Memoized so callers can hold it as a `useMemo` dependency.
8
+ */
9
+ export const useFolderLabelTranslator = (): ((
10
+ key: string,
11
+ fallback: string,
12
+ ) => string) => {
13
+ const { t } = useTranslation("mail", { useSuspense: false });
14
+ return useCallback(
15
+ (key: string, fallback: string): string =>
16
+ t(key, { defaultValue: fallback }),
17
+ [t],
18
+ );
19
+ };
@@ -7,6 +7,8 @@ import {
7
7
  bulkActionFailureTitle,
8
8
  bulkActionProgressLabel,
9
9
  bulkActionProgressTone,
10
+ bulkActionStoppedDetail,
11
+ bulkActionStoppedTitle,
10
12
  } from "./bulk-action-copy.js";
11
13
 
12
14
  const kinds: BulkActionKind[] = ["delete", "move", "markRead"];
@@ -65,6 +67,27 @@ describe("bulkActionFailureTitle", () => {
65
67
  });
66
68
  });
67
69
 
70
+ describe("a run that stopped short of its match", () => {
71
+ test("states what it reached and that the rest is untouched", () => {
72
+ assert.equal(bulkActionStoppedTitle(1200), "Stopped after 1,200");
73
+ assert.equal(
74
+ bulkActionStoppedDetail("delete", 1200, 3412),
75
+ "1,200 of 3,412 moved to Trash. Nothing was sent for the rest, so they are untouched.",
76
+ );
77
+ assert.equal(
78
+ bulkActionStoppedDetail("markRead", 1200, 3412),
79
+ "1,200 of 3,412 marked as read. Nothing was sent for the rest, so they are untouched.",
80
+ );
81
+ });
82
+
83
+ test("never reads as a rejection — nothing was sent", () => {
84
+ assert.doesNotMatch(
85
+ bulkActionStoppedDetail("move", 1, 2),
86
+ /rejected|couldn't/i,
87
+ );
88
+ });
89
+ });
90
+
68
91
  describe("every action carries its own wording", () => {
69
92
  test("no two actions share a sentence", () => {
70
93
  const sentences: Array<(kind: BulkActionKind) => string> = [
@@ -73,6 +96,7 @@ describe("every action carries its own wording", () => {
73
96
  (kind) => bulkActionFailureTitle(kind, 5),
74
97
  bulkActionFailureDetail,
75
98
  (kind) => bulkActionProgressLabel(kind, 1, 2),
99
+ (kind) => bulkActionStoppedDetail(kind, 1, 2),
76
100
  ];
77
101
  for (const render of sentences) {
78
102
  assert.equal(new Set(kinds.map(render)).size, kinds.length);
@@ -59,6 +59,22 @@ export const bulkActionCompletionText = (
59
59
  ): string =>
60
60
  `${formatNumber(done)} ${pastTense[kind]}. Your mail server is still catching up.`;
61
61
 
62
+ /**
63
+ * Shown when a run ended before it covered what it was started against. The
64
+ * remainder was never sent, so the mail is where it was and only the user can
65
+ * decide to run it again — which is why this is stated rather than left to a
66
+ * list that quietly stops changing.
67
+ */
68
+ export const bulkActionStoppedTitle = (done: number): string =>
69
+ `Stopped after ${formatNumber(done)}`;
70
+
71
+ export const bulkActionStoppedDetail = (
72
+ kind: BulkActionKind,
73
+ done: number,
74
+ total: number,
75
+ ): string =>
76
+ `${formatNumber(done)} of ${formatNumber(total)} ${pastTense[kind]}. Nothing was sent for the rest, so they are untouched.`;
77
+
62
78
  /** Error-banner title for a run stopped by an infrastructure failure. */
63
79
  export const bulkActionFailureTitle = (
64
80
  kind: BulkActionKind,
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert";
2
+ import { readFileSync } from "node:fs";
3
+ import { describe, test } from "node:test";
4
+ import { fileURLToPath } from "node:url";
5
+ import type {
6
+ RemitImapFolderAppointment,
7
+ RemitImapMailboxResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
9
+ import { buildMoveOptions } from "./move-options.js";
10
+
11
+ const englishBundle = JSON.parse(
12
+ readFileSync(
13
+ fileURLToPath(
14
+ new URL("../../public/locales/en/mail.json", import.meta.url),
15
+ ),
16
+ "utf8",
17
+ ),
18
+ ) as { sidebar: Record<string, string> };
19
+
20
+ // The picker is handed the same shipped English bundle the app loads, so the
21
+ // expected labels are the product's own, not strings restated by the test.
22
+ const translate = (key: string, fallback: string): string =>
23
+ englishBundle.sidebar[key.replace("sidebar.", "")] ?? fallback;
24
+
25
+ const make = (
26
+ overrides: Partial<RemitImapMailboxResponse> & {
27
+ mailboxId: string;
28
+ fullPath: string;
29
+ },
30
+ ): RemitImapMailboxResponse =>
31
+ ({
32
+ accountId: "acct-1",
33
+ namespaceType: "personal",
34
+ namespacePrefix: "",
35
+ hierarchyDelimiter: "/",
36
+ messageCount: 0,
37
+ unseenCount: 0,
38
+ deletedCount: 0,
39
+ createdAt: 0,
40
+ updatedAt: 0,
41
+ ...overrides,
42
+ }) as RemitImapMailboxResponse;
43
+
44
+ const appoint = (
45
+ role: RemitImapFolderAppointment["role"],
46
+ mailboxId: string,
47
+ ): RemitImapFolderAppointment => ({ role, mailboxId });
48
+
49
+ const applePaths = [
50
+ make({ mailboxId: "mb-inbox", fullPath: "INBOX" }),
51
+ make({ mailboxId: "mb-trash", fullPath: "INBOX/Deleted Messages" }),
52
+ make({ mailboxId: "mb-junk", fullPath: "INBOX/Junk" }),
53
+ make({ mailboxId: "mb-receipts", fullPath: "INBOX/Receipts" }),
54
+ ];
55
+
56
+ const appleAppointments = [
57
+ appoint("Inbox", "mb-inbox"),
58
+ appoint("Trash", "mb-trash"),
59
+ appoint("Junk", "mb-junk"),
60
+ ];
61
+
62
+ const labelOf = (
63
+ options: ReturnType<typeof buildMoveOptions>,
64
+ id: string,
65
+ ): string | undefined => options.find((option) => option.id === id)?.label;
66
+
67
+ describe("buildMoveOptions", () => {
68
+ test("labels appointed folders by role, not by the provider's leaf", () => {
69
+ const options = buildMoveOptions({
70
+ mailboxes: applePaths,
71
+ folderAppointments: appleAppointments,
72
+ translator: translate,
73
+ });
74
+ assert.equal(labelOf(options, "mb-inbox"), "Inbox");
75
+ assert.equal(labelOf(options, "mb-trash"), "Trash");
76
+ assert.equal(labelOf(options, "mb-junk"), "Spam");
77
+ });
78
+
79
+ test("a displayNameOverride wins over the role label", () => {
80
+ const options = buildMoveOptions({
81
+ mailboxes: [
82
+ make({
83
+ mailboxId: "mb-trash",
84
+ fullPath: "INBOX/Deleted Messages",
85
+ displayNameOverride: "Bin",
86
+ }),
87
+ ],
88
+ folderAppointments: [appoint("Trash", "mb-trash")],
89
+ translator: translate,
90
+ });
91
+ assert.equal(labelOf(options, "mb-trash"), "Bin");
92
+ });
93
+
94
+ test("an unappointed folder keeps its own name", () => {
95
+ const options = buildMoveOptions({
96
+ mailboxes: applePaths,
97
+ folderAppointments: appleAppointments,
98
+ translator: translate,
99
+ });
100
+ assert.equal(labelOf(options, "mb-receipts"), "Receipts");
101
+ });
102
+
103
+ test("the full provider path stays searchable", () => {
104
+ const options = buildMoveOptions({
105
+ mailboxes: applePaths,
106
+ folderAppointments: appleAppointments,
107
+ translator: translate,
108
+ });
109
+ assert.equal(
110
+ options.find((option) => option.id === "mb-trash")?.searchValue,
111
+ "INBOX/Deleted Messages",
112
+ );
113
+ });
114
+
115
+ test("the current mailbox is marked and nothing else is", () => {
116
+ const options = buildMoveOptions({
117
+ mailboxes: applePaths,
118
+ folderAppointments: appleAppointments,
119
+ currentMailboxId: "mb-junk",
120
+ translator: translate,
121
+ });
122
+ assert.deepStrictEqual(
123
+ options.filter((option) => option.isCurrent).map((option) => option.id),
124
+ ["mb-junk"],
125
+ );
126
+ });
127
+
128
+ test("an excluded mailbox is not offered as a destination", () => {
129
+ const options = buildMoveOptions({
130
+ mailboxes: applePaths,
131
+ folderAppointments: appleAppointments,
132
+ excludeMailboxId: "mb-receipts",
133
+ translator: translate,
134
+ });
135
+ assert.equal(
136
+ options.some((option) => option.id === "mb-receipts"),
137
+ false,
138
+ );
139
+ });
140
+ });
@@ -0,0 +1,46 @@
1
+ import type {
2
+ RemitImapFolderAppointment,
3
+ RemitImapMailboxResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { MoveMailboxOption } from "@remit/ui";
6
+ import { excludeFolder } from "./delete-folder.js";
7
+ import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
8
+ import { buildMoveTargets } from "./move-targets.js";
9
+
10
+ type Translator = (key: string, fallback: string) => string;
11
+
12
+ interface MoveOptionsInput {
13
+ mailboxes: readonly RemitImapMailboxResponse[];
14
+ folderAppointments: readonly RemitImapFolderAppointment[];
15
+ /** Rendered as a non-selectable row, so the user sees where mail lives now. */
16
+ currentMailboxId?: string;
17
+ /** Dropped entirely — the folder being deleted is not its own destination. */
18
+ excludeMailboxId?: string;
19
+ translator?: Translator;
20
+ }
21
+
22
+ /**
23
+ * The single shaping of a mailbox list into Move-to picker options. Labels
24
+ * follow the account's role appointments (RFC 032, #976) and any
25
+ * `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
26
+ * provider's leaf; the full path stays searchable.
27
+ */
28
+ export const buildMoveOptions = ({
29
+ mailboxes,
30
+ folderAppointments,
31
+ currentMailboxId,
32
+ excludeMailboxId,
33
+ translator,
34
+ }: MoveOptionsInput): MoveMailboxOption[] => {
35
+ const targets = buildMoveTargets(mailboxes, folderAppointments);
36
+ const roleMap = buildMailboxRoleMap(folderAppointments);
37
+ const destinations = excludeMailboxId
38
+ ? excludeFolder(targets, excludeMailboxId)
39
+ : targets;
40
+ return destinations.map((mailbox) => ({
41
+ id: mailbox.mailboxId,
42
+ label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
43
+ searchValue: mailbox.fullPath,
44
+ isCurrent: mailbox.mailboxId === currentMailboxId,
45
+ }));
46
+ };