@remit/ui 0.0.82 → 0.0.84

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.
@@ -1,12 +1,5 @@
1
- import {
2
- Fragment,
3
- type ReactNode,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- } from "react";
9
- import { isAbortError } from "../lib/abort.js";
1
+ import { Fragment, type ReactNode, useMemo, useState } from "react";
2
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
10
3
  import { Button } from "./button.js";
11
4
  import {
12
5
  AddChipButton,
@@ -21,7 +14,6 @@ import {
21
14
  commitBlockedReason,
22
15
  commitLabel,
23
16
  type FilterRule,
24
- type FolderOption,
25
17
  type LabelOption,
26
18
  type MatchOperator,
27
19
  matchJoinWord,
@@ -32,6 +24,7 @@ import {
32
24
  type RuleMatchMode,
33
25
  type RuleScope,
34
26
  } from "./filter-rule.js";
27
+ import { FolderTreePicker } from "./folder-tree-picker.js";
35
28
  import { Input } from "./input.js";
36
29
  import { LabelChip } from "./label-chip.js";
37
30
  import { SegmentedControl } from "./segmented-control.js";
@@ -47,7 +40,10 @@ export interface ClauseEditState {
47
40
 
48
41
  export interface FilterRuleEditorProps {
49
42
  rule: FilterRule;
50
- folders: FolderOption[];
43
+ /** Destinations as the app has them — labelled, and pathed by the provider. */
44
+ folders: readonly FolderTreeNode[];
45
+ /** The provider's hierarchy separator, which the destination tree nests on. */
46
+ delimiter?: string;
51
47
  /** The account's labels the apply-label action can target (issue #26). */
52
48
  labels?: LabelOption[];
53
49
  preview: PreviewCount;
@@ -113,16 +109,19 @@ export interface FilterRuleEditorProps {
113
109
  onChangeMatchOperator?: (operator: MatchOperator) => void;
114
110
  onChangeMove?: (mailboxId: string) => void;
115
111
  /**
116
- * Create a new destination folder from within the editor. Given a folder name
117
- * and an abort signal, resolves to the created folder once the mail server
118
- * confirms it. The editor aborts the signal on unmount or cancel. When absent,
119
- * the "New folder…" option is not offered — the editor stays data-agnostic, so
120
- * stories and consumers without wiring render unchanged.
112
+ * Create a new destination folder from within the editor, inside the folder
113
+ * the tree is looking at. Creating a folder is an IMAP mutation, so this
114
+ * resolves only once the mail server confirms it
115
+ * (docs/architecture/imap-mutations.md); the picker holds the wait, states a
116
+ * failure where it happened, and aborts the signal on unmount or cancel, so
117
+ * the editor can never commit a filter against a folder that does not exist.
118
+ * Absent renders no create affordance.
121
119
  */
122
120
  onCreateFolder?: (
123
121
  name: string,
122
+ parentPath: string,
124
123
  signal?: AbortSignal,
125
- ) => Promise<FolderOption>;
124
+ ) => Promise<FolderTreeNode>;
126
125
  onChangeLabel?: (labelId: string) => void;
127
126
  /**
128
127
  * Create a new label from within the editor (issue #26). Given a label
@@ -153,47 +152,43 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
153
152
  { value: "until", label: "Until a date" },
154
153
  ];
155
154
 
156
- const CREATE_FOLDER_VALUE = "__filter_create_folder__";
157
155
  const CREATE_LABEL_VALUE = "__filter_create_label__";
158
156
 
159
157
  /**
160
- * The move-to destination select, plus an inline "New folder…" affordance when
161
- * the consumer wires `onCreateFolder`. Selecting the create option reveals a
162
- * name field; on resolve the new folder is added to the local option set (so it
163
- * is selectable even before the caller's folder list refetches) and picked as
164
- * the destination. Without `onCreateFolder` this is the bare select.
158
+ * The move-to destination: the same browsable folder tree every other picker
159
+ * uses, opened from a line that states where matches go now. A folder is
160
+ * labelled as the app labels it, nested where the provider nests it, and a new
161
+ * one is made wherever the tree is looking.
162
+ *
163
+ * Tapping a folder both picks it and opens it, so the rule is re-pointed on an
164
+ * explicit confirmation rather than on the way past — otherwise looking inside
165
+ * a folder on the way to a nested one would rewrite the filter with no undo.
165
166
  *
166
- * The destination is a dependent write: the filter this editor commits binds to
167
- * the folder, so `onCreateFolder` resolves only once the folder is confirmed on
168
- * the mail server, not when the create is merely queued. The pending state holds
169
- * "Creating folder…" for that whole wait, and a create that fails or never
170
- * confirms rejects with its own message here — the folder is never selected, so
171
- * the caller cannot commit a filter against a folder that does not exist.
167
+ * The destination is a dependent write the filter binds to the folder — so a
168
+ * created folder is offered only once the mail server has confirmed it. It is
169
+ * held here as well as returned, so it is pickable before the caller's folder
170
+ * list has refetched.
172
171
  */
173
172
  function MoveDestinationField({
174
173
  folders,
175
174
  value,
175
+ delimiter = "/",
176
176
  onChangeMove,
177
177
  onCreateFolder,
178
178
  }: {
179
- folders: FolderOption[];
180
- value: string;
179
+ folders: readonly FolderTreeNode[];
180
+ value?: string;
181
+ delimiter?: string;
181
182
  onChangeMove?: (mailboxId: string) => void;
182
183
  onCreateFolder?: (
183
184
  name: string,
185
+ parentPath: string,
184
186
  signal?: AbortSignal,
185
- ) => Promise<FolderOption>;
187
+ ) => Promise<FolderTreeNode>;
186
188
  }) {
187
- const [creating, setCreating] = useState(false);
188
- const [name, setName] = useState("");
189
- const [pending, setPending] = useState(false);
190
- const [error, setError] = useState<string>();
191
- const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
192
- // The create waits for the mail server to confirm the folder; abort it on
193
- // unmount or cancel so a late confirmation never binds the destination after
194
- // the editor is gone or the sub-form dismissed.
195
- const createAbort = useRef<AbortController | null>(null);
196
- useEffect(() => () => createAbort.current?.abort(), []);
189
+ const [browsing, setBrowsing] = useState(false);
190
+ const [picked, setPicked] = useState<string>();
191
+ const [createdFolders, setCreatedFolders] = useState<FolderTreeNode[]>([]);
197
192
 
198
193
  const options = useMemo(() => {
199
194
  const known = new Set(folders.map((folder) => folder.id));
@@ -203,127 +198,114 @@ function MoveDestinationField({
203
198
  ];
204
199
  }, [folders, createdFolders]);
205
200
 
206
- const handleSelectChange = (next: string) => {
207
- if (next === CREATE_FOLDER_VALUE) {
208
- setError(undefined);
209
- setCreating(true);
210
- return;
211
- }
212
- onChangeMove?.(next);
213
- };
214
-
215
- const submit = () => {
216
- if (!onCreateFolder) return;
217
- const trimmed = name.trim();
218
- if (trimmed === "") return;
219
- setPending(true);
220
- setError(undefined);
221
- createAbort.current?.abort();
222
- const controller = new AbortController();
223
- createAbort.current = controller;
224
- onCreateFolder(trimmed, controller.signal)
225
- .then((folder) => {
226
- setCreatedFolders((prev) =>
227
- prev.some((entry) => entry.id === folder.id)
228
- ? prev
229
- : [...prev, folder],
230
- );
231
- onChangeMove?.(folder.id);
232
- setCreating(false);
233
- setName("");
234
- setPending(false);
201
+ const createFolder = useMemo(() => {
202
+ if (!onCreateFolder) return undefined;
203
+ return async (name: string, parentPath: string, signal?: AbortSignal) => {
204
+ const created = await onCreateFolder(name, parentPath, signal);
205
+ setCreatedFolders((prev) =>
206
+ prev.some((entry) => entry.id === created.id)
207
+ ? prev
208
+ : [...prev, created],
209
+ );
210
+ return created;
211
+ };
212
+ }, [onCreateFolder]);
213
+
214
+ const chosen = options.find((folder) => folder.id === value);
215
+ const pending = options.find((folder) => folder.id === picked);
216
+
217
+ /** Two folders can share a leaf name, so a destination reads as its trail. */
218
+ const trail = (folder: FolderTreeNode): string => {
219
+ const segments = folder.path.split(delimiter);
220
+ return segments
221
+ .map((segment, index) => {
222
+ const path = segments.slice(0, index + 1).join(delimiter);
223
+ return options.find((option) => option.path === path)?.label ?? segment;
235
224
  })
236
- .catch((error: unknown) => {
237
- if (isAbortError(error)) return;
238
- setError(
239
- error instanceof Error
240
- ? error.message
241
- : "Couldn't create that folder. Please try again.",
242
- );
243
- setPending(false);
244
- });
225
+ .join(" / ");
245
226
  };
246
227
 
247
- const cancel = () => {
248
- createAbort.current?.abort();
249
- setCreating(false);
250
- setName("");
251
- setError(undefined);
252
- setPending(false);
228
+ const close = () => {
229
+ setBrowsing(false);
230
+ setPicked(undefined);
253
231
  };
254
232
 
255
- return (
256
- <div className="space-y-2">
257
- <Select
258
- aria-label="Destination folder"
259
- value={value}
260
- onChange={(event) => handleSelectChange(event.target.value)}
261
- >
262
- <option value="">Choose a folder…</option>
263
- {options.map((folder) => (
264
- <option key={folder.id} value={folder.id}>
265
- {folder.label}
266
- </option>
267
- ))}
268
- {onCreateFolder && (
269
- <option value={CREATE_FOLDER_VALUE}>+ New folder…</option>
270
- )}
271
- </Select>
272
- {creating && (
273
- <div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
274
- <Input
275
- value={name}
276
- onChange={(event) => setName(event.target.value)}
277
- placeholder="Folder name"
278
- aria-label="New folder name"
279
- disabled={pending}
280
- autoFocus
281
- onKeyDown={(event) => {
282
- if (event.key === "Enter") {
283
- event.preventDefault();
284
- submit();
285
- }
286
- if (event.key === "Escape") {
287
- event.preventDefault();
288
- cancel();
289
- }
233
+ if (!browsing) {
234
+ return (
235
+ <div className="rounded-md border border-line bg-surface-sunken p-3">
236
+ <p className="text-sm font-medium text-fg">
237
+ {chosen ? trail(chosen) : "No folder yet"}
238
+ </p>
239
+ <div className="mt-1 flex flex-wrap items-center gap-3">
240
+ <Button
241
+ variant="ghost"
242
+ size="sm"
243
+ className="px-0"
244
+ onClick={() => {
245
+ setPicked(value);
246
+ setBrowsing(true);
290
247
  }}
291
- />
292
- {error && (
293
- <p className="text-2xs text-danger" role="alert">
294
- {error}
295
- </p>
296
- )}
297
- <div className="flex gap-2">
298
- <Button
299
- variant="primary"
300
- size="sm"
301
- onClick={submit}
302
- disabled={pending || name.trim() === ""}
303
- >
304
- {pending ? "Creating folder…" : "Create folder"}
305
- </Button>
248
+ >
249
+ Choose a folder
250
+ </Button>
251
+ {chosen && (
306
252
  <Button
307
253
  variant="ghost"
308
254
  size="sm"
309
- onClick={cancel}
310
- disabled={pending}
255
+ className="px-0"
256
+ onClick={() => onChangeMove?.("")}
311
257
  >
312
- Cancel
258
+ Don't move matches
313
259
  </Button>
314
- </div>
260
+ )}
315
261
  </div>
262
+ </div>
263
+ );
264
+ }
265
+
266
+ return (
267
+ <div className="space-y-2">
268
+ <div className="flex h-72 min-h-0 overflow-hidden rounded-md border border-line bg-surface">
269
+ <FolderTreePicker
270
+ folders={options}
271
+ selectedId={picked}
272
+ delimiter={delimiter}
273
+ onSelect={setPicked}
274
+ onCreateFolder={createFolder}
275
+ onCancel={close}
276
+ labels={{
277
+ createPending: "Waiting for the mail server to confirm the folder…",
278
+ }}
279
+ />
280
+ </div>
281
+ {pending ? (
282
+ <Button
283
+ variant="primary"
284
+ onClick={() => {
285
+ onChangeMove?.(pending.id);
286
+ close();
287
+ }}
288
+ className="w-full"
289
+ >
290
+ <span className="truncate">{`Move matches to ${trail(pending)}`}</span>
291
+ </Button>
292
+ ) : (
293
+ <p className="text-2xs text-fg-subtle">
294
+ Tap a folder to open it, or make a new one where you want it.
295
+ </p>
316
296
  )}
297
+ <Button variant="ghost" size="sm" onClick={close} className="w-full">
298
+ Cancel
299
+ </Button>
317
300
  </div>
318
301
  );
319
302
  }
320
303
 
321
304
  /**
322
305
  * The apply-label select, plus an inline "New label…" affordance when the
323
- * consumer wires `onCreateLabel` (issue #26). Mirrors `MoveDestinationField`:
324
- * selecting the create option reveals a name field, and the created label is
325
- * added to the local option set and picked immediately, before the caller's
326
- * label list refetches.
306
+ * consumer wires `onCreateLabel` (issue #26). Selecting the create option
307
+ * reveals a name field, and the created label is added to the local option set
308
+ * and picked immediately, before the caller's label list refetches.
327
309
  */
328
310
  function LabelDestinationField({
329
311
  labels,
@@ -468,6 +450,7 @@ function LabelDestinationField({
468
450
  export function FilterRuleEditor({
469
451
  rule,
470
452
  folders,
453
+ delimiter,
471
454
  labels = [],
472
455
  preview,
473
456
  notice,
@@ -622,7 +605,8 @@ export function FilterRuleEditor({
622
605
  <p className="text-xs font-medium text-fg-muted">Move matches to</p>
623
606
  <MoveDestinationField
624
607
  folders={folders}
625
- value={rule.moveMailboxId ?? ""}
608
+ value={rule.moveMailboxId}
609
+ delimiter={delimiter}
626
610
  onChangeMove={onChangeMove}
627
611
  onCreateFolder={onCreateFolder}
628
612
  />
@@ -20,7 +20,6 @@ import {
20
20
  demoSenderFallbackRule,
21
21
  demoVocabularyRule,
22
22
  type FilterRule,
23
- type FolderOption,
24
23
  type LabelOption,
25
24
  matchJoinWord,
26
25
  matchOperatorLabel,
@@ -34,14 +33,15 @@ import {
34
33
  FilterRuleEditor,
35
34
  type FilterRuleEditorProps,
36
35
  } from "./filter-rule-editor.js";
36
+ import type { FolderTreeNode } from "./folder-tree-picker.js";
37
37
 
38
38
  /** SSR splits interpolations with comment markers; sentences read across them. */
39
39
  const render = (element: Parameters<typeof renderToString>[0]) =>
40
40
  renderToString(element).replaceAll("<!-- -->", "");
41
41
 
42
- const FOLDERS: FolderOption[] = [
43
- { id: "mbx-inbox", label: "Inbox" },
44
- { id: "mbx-archive", label: "Archive" },
42
+ const FOLDERS: FolderTreeNode[] = [
43
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
44
+ { id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
45
45
  ];
46
46
 
47
47
  const LABELS: LabelOption[] = [
@@ -7,6 +7,7 @@
7
7
  * Naming tracks the RFC: rule, clause, widen, scope.
8
8
  */
9
9
 
10
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
10
11
  import type { Suggestion } from "./suggest-list.js";
11
12
 
12
13
  /**
@@ -105,11 +106,6 @@ export interface FilterRule {
105
106
  name?: string;
106
107
  }
107
108
 
108
- export interface FolderOption {
109
- id: string;
110
- label: string;
111
- }
112
-
113
109
  export interface LabelOption {
114
110
  id: string;
115
111
  name: string;
@@ -313,12 +309,22 @@ export function commitBlockedReason(
313
309
  return previewSettledReason(preview);
314
310
  }
315
311
 
316
- export const demoFolders: FolderOption[] = [
317
- { id: "mbx-inbox", label: "Inbox" },
318
- { id: "mbx-archive", label: "Archive" },
319
- { id: "mbx-receipts", label: "Receipts" },
320
- { id: "mbx-travel", label: "Travel" },
321
- { id: "mbx-junk", label: "Junk" },
312
+ // Two folders named Receipts at different depths, and a Trash labelled by its
313
+ // role rather than its provider leaf: what a flat list of leaf names cannot
314
+ // tell apart.
315
+ export const demoFolders: FolderTreeNode[] = [
316
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
317
+ { id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
318
+ { id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
319
+ { id: "mbx-receipts-2026", label: "2026", path: "INBOX/Receipts/2026" },
320
+ { id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
321
+ {
322
+ id: "mbx-travel-receipts",
323
+ label: "Receipts",
324
+ path: "INBOX/Travel/Receipts",
325
+ },
326
+ { id: "mbx-junk", label: "Junk", path: "INBOX/Junk" },
327
+ { id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
322
328
  ];
323
329
 
324
330
  export const demoLabels: LabelOption[] = [
@@ -146,7 +146,12 @@ export const FolderTreePicker = ({
146
146
  }: FolderTreePickerProps) => {
147
147
  const text = { ...defaultLabels, ...labels };
148
148
  const [query, setQuery] = useState("");
149
- const [opened, setOpened] = useState<ReadonlySet<string>>(new Set());
149
+ // A destination chosen before the picker opened is out of reach behind its
150
+ // ancestors, so the branch holding it starts open and the choice is on screen.
151
+ const [opened, setOpened] = useState<ReadonlySet<string>>(() => {
152
+ const selected = folders.find((folder) => folder.id === selectedId);
153
+ return new Set(selected ? folderAncestors(selected.path, delimiter) : []);
154
+ });
150
155
  const [draft, setDraft] = useState<Draft | null>(null);
151
156
  const [draftName, setDraftName] = useState("");
152
157
  const [draftError, setDraftError] = useState<string>();
@@ -582,6 +582,26 @@ describe("RunStepBody", () => {
582
582
  assert.match(html, /Nothing has changed\./);
583
583
  });
584
584
 
585
+ // A retry that could not be started is not a pass that never ran (#552): the
586
+ // pass that did run keeps its counts and its bar.
587
+ it("keeps a finished pass's counts when its retry could not be started", () => {
588
+ const html = renderToString(
589
+ createElement(RunStepBody, {
590
+ ...runProps,
591
+ state: "backApplyRestartFailed",
592
+ scope: "standing",
593
+ matched: 1284,
594
+ applied: 1200,
595
+ failedCount: 84,
596
+ }),
597
+ );
598
+ assert.match(html, /1200 of 1284 moved/);
599
+ assert.match(html, /rejected 84/);
600
+ assert.match(html, /role="progressbar"/);
601
+ assert.doesNotMatch(html, /Nothing has changed/);
602
+ assert.doesNotMatch(html, /never started/);
603
+ });
604
+
585
605
  // A poll that could not be read is not a run that never started (#526): the
586
606
  // screen keeps the counts it has and says what it cannot see.
587
607
  it("keeps a run that is going when its progress could not be read", () => {
package/src/index.ts CHANGED
@@ -170,7 +170,6 @@ export {
170
170
  demoSubjectPrefillRule,
171
171
  demoVocabularyRule,
172
172
  type FilterRule,
173
- type FolderOption,
174
173
  hasActiveWiden,
175
174
  type LabelOption,
176
175
  type MatchOperator,
@@ -477,6 +477,7 @@ describe("runCopy", () => {
477
477
  "backApplyComplete",
478
478
  "backApplyFailed",
479
479
  "backApplyStartFailed",
480
+ "backApplyRestartFailed",
480
481
  "statusUnknown",
481
482
  "filterSaved",
482
483
  "runStopped",
@@ -540,6 +541,26 @@ describe("runCopy", () => {
540
541
  assert.equal(started.showProgress, false);
541
542
  });
542
543
 
544
+ it("keeps the counts of the pass that ran when its retry could not be started", () => {
545
+ // #552: the retry is a second pass over the same mail, so a retry that
546
+ // failed leaves the first pass's ending exactly where it was.
547
+ const once = outcome("backApplyRestartFailed", "once");
548
+ assert.equal(once.title, "The retry didn't start");
549
+ assert.match(once.detail, /10 of 12 moved/);
550
+ assert.match(once.detail, /rejected 2/);
551
+ assert.match(once.detail, /Check your connection/);
552
+ assert.doesNotMatch(once.detail, /Nothing has changed/);
553
+ assert.equal(once.tone, "warning");
554
+ assert.equal(once.retryLabel, "Retry 2");
555
+ assert.equal(once.showProgress, true);
556
+
557
+ const standing = outcome("backApplyRestartFailed", "standing");
558
+ assert.match(standing.title, /Rule saved/);
559
+ assert.doesNotMatch(standing.detail, /never started/);
560
+ assert.match(standing.detail, /keeps working on new mail/);
561
+ assert.equal(standing.retryLabel, "Retry 2");
562
+ });
563
+
543
564
  it("keeps a run that is going when its progress could not be read", () => {
544
565
  // A poll that failed says nothing about the job behind it (#526), so the
545
566
  // screen never claims the action never started, and the way out of it is a
@@ -301,6 +301,7 @@ export type RunState =
301
301
  | "backApplyComplete"
302
302
  | "backApplyFailed"
303
303
  | "backApplyStartFailed"
304
+ | "backApplyRestartFailed"
304
305
  | "statusUnknown"
305
306
  | "filterSaved"
306
307
  | "runStopped"
@@ -386,6 +387,7 @@ export const runCopy = ({
386
387
  state === "backApplyRunning" ||
387
388
  state === "backApplyComplete" ||
388
389
  state === "backApplyFailed" ||
390
+ state === "backApplyRestartFailed" ||
389
391
  state === "statusUnknown" ||
390
392
  state === "runStopped",
391
393
  failureListLabel: `Not ${done}`,
@@ -463,6 +465,22 @@ export const runCopy = ({
463
465
  retryLabel: `Retry ${failed}`,
464
466
  };
465
467
  }
468
+ if (state === "backApplyRestartFailed") {
469
+ return {
470
+ ...shared,
471
+ title: standing
472
+ ? "Rule saved — the retry didn't start"
473
+ : "The retry didn't start",
474
+ detail: `${applied} of ${matched} ${done} · the mail server rejected ${failed}, which are still where they were.${
475
+ standing
476
+ ? " The rule itself is saved and keeps working on new mail."
477
+ : ""
478
+ } Check your connection and try again.`,
479
+ tone: "warning",
480
+ dismissLabel: "Close",
481
+ retryLabel: `Retry ${failed}`,
482
+ };
483
+ }
466
484
  if (state === "backApplyStartFailed") {
467
485
  return {
468
486
  ...shared,