@remit/ui 0.0.138 → 0.0.140
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/folder-manager.tsx +1 -1
- package/src/components/folder-role.tsx +5 -1
- package/src/components/folder-row.render.test.ts +14 -0
- package/src/components/folder-row.tsx +15 -0
- package/src/components/folder-tree-picker.render.test.ts +13 -1
- package/src/components/folder-tree-picker.tsx +9 -4
- package/src/components/role-appointment-list.render.test.ts +200 -8
- package/src/components/role-appointment-list.stories.tsx +132 -25
- package/src/components/role-appointment-list.tsx +132 -26
- package/src/components/role-appointment-prompt.render.test.ts +240 -0
- package/src/components/role-appointment-prompt.stories.tsx +172 -0
- package/src/components/role-appointment-prompt.tsx +309 -0
- package/src/components/select.tsx +11 -2
- package/src/index.ts +12 -0
- package/src/lib/folder-tree.test.ts +19 -0
- package/src/lib/folder-tree.ts +14 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { AlertTriangle, Loader2 } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import type { FolderTreeNode } from "../lib/folder-tree.js";
|
|
4
|
+
import { Banner } from "./banner.js";
|
|
5
|
+
import { Button } from "./button.js";
|
|
6
|
+
import { Dialog } from "./dialog.js";
|
|
7
|
+
import {
|
|
8
|
+
FolderTreePicker,
|
|
9
|
+
type FolderTreePickerProps,
|
|
10
|
+
} from "./folder-tree-picker.js";
|
|
11
|
+
|
|
12
|
+
/** Why the action was refused. Never re-derived from live state while open. */
|
|
13
|
+
export type PromptReason = "none" | "stale" | "unconfirmed";
|
|
14
|
+
|
|
15
|
+
/** What the confirm completes. Decides the verb and the count in every label. */
|
|
16
|
+
export type PromptAction =
|
|
17
|
+
| { kind: "delete"; count: number }
|
|
18
|
+
| { kind: "emptyTrash" };
|
|
19
|
+
|
|
20
|
+
export type PromptPhase =
|
|
21
|
+
| { kind: "choosing" }
|
|
22
|
+
| { kind: "appointing" }
|
|
23
|
+
| { kind: "acting" }
|
|
24
|
+
| { kind: "appoint-failed"; cause: "generic" | "mailbox-pending" };
|
|
25
|
+
|
|
26
|
+
export interface RoleAppointmentPromptCopy {
|
|
27
|
+
title: string;
|
|
28
|
+
description: string;
|
|
29
|
+
pickerPrompt: string;
|
|
30
|
+
confirmLabel: string;
|
|
31
|
+
cancelLabel: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RoleAppointmentPromptCopyContext {
|
|
35
|
+
/** `unconfirmed`: the folder reader guessed from its name. */
|
|
36
|
+
trashFolderLabel?: string;
|
|
37
|
+
/** `stale`: the folder that vanished from the mail server. */
|
|
38
|
+
staleFolderLabel?: string;
|
|
39
|
+
/** The chosen folder's message count, so the confirm re-words with it. */
|
|
40
|
+
selectedCount?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const plural = (count: number): string =>
|
|
44
|
+
count === 1 ? "message" : "messages";
|
|
45
|
+
|
|
46
|
+
const quantified = (count: number): string =>
|
|
47
|
+
`${count.toLocaleString()} ${plural(count)}`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What the confirm completes. Empty Trash names a count only where the user is
|
|
51
|
+
* confirming a guess — that is the framing whose confirm expunges, and the
|
|
52
|
+
* count moves with the row they picked.
|
|
53
|
+
*/
|
|
54
|
+
const actionSuffix = (
|
|
55
|
+
action: PromptAction,
|
|
56
|
+
selectedCount: number | undefined,
|
|
57
|
+
): string => {
|
|
58
|
+
if (action.kind === "delete") return `delete ${quantified(action.count)}`;
|
|
59
|
+
if (selectedCount === undefined) return "empty it";
|
|
60
|
+
return `empty ${quantified(selectedCount)}`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** "Nothing changed", in the tense of the verb that was refused. */
|
|
64
|
+
const nothingHappened = (action: PromptAction): string =>
|
|
65
|
+
action.kind === "delete"
|
|
66
|
+
? "Nothing has been deleted."
|
|
67
|
+
: "Nothing has been emptied.";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every word the prompt says, as a pure function of the refusal — mirroring
|
|
71
|
+
* `deleteConfirmationCopy`, so the stories render what the app renders and each
|
|
72
|
+
* branch is testable without a DOM.
|
|
73
|
+
*/
|
|
74
|
+
export const roleAppointmentPromptCopy = (
|
|
75
|
+
reason: PromptReason,
|
|
76
|
+
action: PromptAction,
|
|
77
|
+
context: RoleAppointmentPromptCopyContext = {},
|
|
78
|
+
): RoleAppointmentPromptCopy => {
|
|
79
|
+
const { trashFolderLabel, staleFolderLabel, selectedCount } = context;
|
|
80
|
+
const stopped = nothingHappened(action);
|
|
81
|
+
|
|
82
|
+
if (reason === "none") {
|
|
83
|
+
return {
|
|
84
|
+
title: "No folder is set as Trash",
|
|
85
|
+
description: `${stopped} reader files deleted mail in the folder you set as Trash, and this account has none.`,
|
|
86
|
+
pickerPrompt: "Which folder does this account use for deleted mail?",
|
|
87
|
+
confirmLabel: `Set as Trash and ${actionSuffix(action, undefined)}`,
|
|
88
|
+
cancelLabel: "Cancel",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (reason === "stale") {
|
|
93
|
+
return {
|
|
94
|
+
title: "The Trash folder you chose is gone",
|
|
95
|
+
description: staleFolderLabel
|
|
96
|
+
? `${stopped} You set ${staleFolderLabel} as this account's Trash and it is no longer on the mail server — another mail app may have renamed or removed it.`
|
|
97
|
+
: `${stopped} The folder you set as this account's Trash is no longer on the mail server — another mail app may have renamed or removed it.`,
|
|
98
|
+
pickerPrompt: "Which folder should reader use instead?",
|
|
99
|
+
confirmLabel: `Set as Trash and ${actionSuffix(action, undefined)}`,
|
|
100
|
+
cancelLabel: "Cancel",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const irreversible =
|
|
105
|
+
"Emptying a folder erases everything in it from the mail server, and that cannot be restored.";
|
|
106
|
+
return {
|
|
107
|
+
title: "Confirm this account's Trash folder",
|
|
108
|
+
description: trashFolderLabel
|
|
109
|
+
? `${stopped} reader files this account's deleted mail in ${trashFolderLabel} because of its name — you never chose it, and the mail server doesn't mark it as Trash. ${irreversible}`
|
|
110
|
+
: `${stopped} reader files this account's deleted mail in a folder it matched by name — you never chose it, and the mail server doesn't mark it as Trash. ${irreversible}`,
|
|
111
|
+
pickerPrompt: trashFolderLabel
|
|
112
|
+
? `Confirm ${trashFolderLabel}, or pick the folder this account really uses.`
|
|
113
|
+
: "Pick the folder this account really uses for deleted mail.",
|
|
114
|
+
confirmLabel: `Set as Trash and ${actionSuffix(action, selectedCount)}`,
|
|
115
|
+
cancelLabel: "Cancel",
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
interface PendingCopy {
|
|
120
|
+
headline: string;
|
|
121
|
+
status: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const pendingCopy = (
|
|
125
|
+
phase: PromptPhase,
|
|
126
|
+
action: PromptAction,
|
|
127
|
+
folderLabel: string,
|
|
128
|
+
): PendingCopy => {
|
|
129
|
+
if (phase.kind === "appointing") {
|
|
130
|
+
return { headline: "Setting the Trash folder…", status: folderLabel };
|
|
131
|
+
}
|
|
132
|
+
if (action.kind === "delete") {
|
|
133
|
+
return {
|
|
134
|
+
headline: `Deleting ${quantified(action.count)}…`,
|
|
135
|
+
status: `Moving them to ${folderLabel}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
headline: `Emptying ${folderLabel}…`,
|
|
140
|
+
status: "This can't be undone.",
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const failureCopy = (
|
|
145
|
+
cause: "generic" | "mailbox-pending",
|
|
146
|
+
action: PromptAction,
|
|
147
|
+
folderLabel: string,
|
|
148
|
+
): string =>
|
|
149
|
+
cause === "mailbox-pending"
|
|
150
|
+
? `${folderLabel} is still being created on the mail server. Wait for it to finish, then try again.`
|
|
151
|
+
: `Couldn't set that folder as Trash. ${nothingHappened(action)} Please try again.`;
|
|
152
|
+
|
|
153
|
+
export interface RoleAppointmentPromptProps {
|
|
154
|
+
open: boolean;
|
|
155
|
+
reason: PromptReason;
|
|
156
|
+
action: PromptAction;
|
|
157
|
+
/** The account's real folders, with counts — a real Trash is told from an empty look-alike by its count. */
|
|
158
|
+
folders: readonly FolderTreeNode[];
|
|
159
|
+
delimiter: string;
|
|
160
|
+
/** `unconfirmed`: the folder reader guessed from its name. */
|
|
161
|
+
trashFolderLabel?: string;
|
|
162
|
+
/** `stale`: the folder that vanished from the mail server. */
|
|
163
|
+
staleFolderLabel?: string;
|
|
164
|
+
/** Only on instances holding more than one account. */
|
|
165
|
+
accountEmail?: string;
|
|
166
|
+
phase: PromptPhase;
|
|
167
|
+
selectedId?: string;
|
|
168
|
+
onSelect: (mailboxId: string) => void;
|
|
169
|
+
/** Wired for `none` only: a repair must not make a second empty Trash. */
|
|
170
|
+
onCreateFolder?: FolderTreePickerProps["onCreateFolder"];
|
|
171
|
+
onConfirm: (mailboxId: string) => void;
|
|
172
|
+
onCancel: () => void;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The refused action's own dialog: it says what stopped and that nothing
|
|
177
|
+
* changed, offers the account's real folders with their counts, and completes
|
|
178
|
+
* the original action from the same confirm that appoints the folder.
|
|
179
|
+
*
|
|
180
|
+
* Controlled — the app owns both writes. The confirm mounts only once a folder
|
|
181
|
+
* is chosen, so a confirm is never on screen disabled; Cancel is always there,
|
|
182
|
+
* so there is always a way out that changes nothing.
|
|
183
|
+
*/
|
|
184
|
+
export const RoleAppointmentPrompt = ({
|
|
185
|
+
open,
|
|
186
|
+
reason,
|
|
187
|
+
action,
|
|
188
|
+
folders,
|
|
189
|
+
delimiter,
|
|
190
|
+
trashFolderLabel,
|
|
191
|
+
staleFolderLabel,
|
|
192
|
+
accountEmail,
|
|
193
|
+
phase,
|
|
194
|
+
selectedId,
|
|
195
|
+
onSelect,
|
|
196
|
+
onCreateFolder,
|
|
197
|
+
onConfirm,
|
|
198
|
+
onCancel,
|
|
199
|
+
}: RoleAppointmentPromptProps) => {
|
|
200
|
+
const pendingRef = useRef<HTMLDivElement>(null);
|
|
201
|
+
const pending = phase.kind === "appointing" || phase.kind === "acting";
|
|
202
|
+
|
|
203
|
+
// Escape must not unmount a dialog whose write has already left. Dialog
|
|
204
|
+
// yields Escape to `[data-escape-owner]`, which only holds while the focus
|
|
205
|
+
// is inside it — so the pending body takes the focus the footer gave up.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!pending) return;
|
|
208
|
+
pendingRef.current?.focus();
|
|
209
|
+
}, [pending]);
|
|
210
|
+
|
|
211
|
+
const selected = folders.find((folder) => folder.id === selectedId);
|
|
212
|
+
const copy = roleAppointmentPromptCopy(reason, action, {
|
|
213
|
+
trashFolderLabel,
|
|
214
|
+
staleFolderLabel,
|
|
215
|
+
selectedCount: selected?.messageCount,
|
|
216
|
+
});
|
|
217
|
+
const expunges = action.kind === "emptyTrash";
|
|
218
|
+
|
|
219
|
+
if (!open) return null;
|
|
220
|
+
|
|
221
|
+
if (pending) {
|
|
222
|
+
const words = pendingCopy(phase, action, selected?.label ?? "");
|
|
223
|
+
return (
|
|
224
|
+
<Dialog open={open} onClose={() => {}} title={copy.title}>
|
|
225
|
+
<div
|
|
226
|
+
ref={pendingRef}
|
|
227
|
+
data-escape-owner
|
|
228
|
+
tabIndex={-1}
|
|
229
|
+
className="flex flex-col items-center gap-3 px-5 py-10 text-center outline-none"
|
|
230
|
+
>
|
|
231
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
232
|
+
<p className="text-sm font-medium text-fg">{words.headline}</p>
|
|
233
|
+
<p className="text-xs text-fg-muted" role="status" aria-live="polite">
|
|
234
|
+
{words.status}
|
|
235
|
+
</p>
|
|
236
|
+
</div>
|
|
237
|
+
</Dialog>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<Dialog open={open} onClose={onCancel} title={copy.title}>
|
|
243
|
+
{/* No header dismiss: the footer's Cancel is the way out, which leaves
|
|
244
|
+
the picker's filter field as the dialog's first focusable — initial
|
|
245
|
+
focus belongs there, never on a confirm that erases mail. */}
|
|
246
|
+
<header className="flex items-center gap-2 border-b border-line px-5 py-3">
|
|
247
|
+
<AlertTriangle
|
|
248
|
+
className={`size-4 shrink-0 ${expunges ? "text-danger" : "text-warning"}`}
|
|
249
|
+
/>
|
|
250
|
+
<span className="flex-1 text-sm font-semibold text-fg">
|
|
251
|
+
{copy.title}
|
|
252
|
+
</span>
|
|
253
|
+
</header>
|
|
254
|
+
<div className="flex max-h-[70vh] flex-col">
|
|
255
|
+
<div className="space-y-1 px-5 py-4">
|
|
256
|
+
<p className="text-sm text-fg-muted">{copy.description}</p>
|
|
257
|
+
{accountEmail && (
|
|
258
|
+
<p className="text-xs text-fg-subtle">{accountEmail}</p>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
{phase.kind === "appoint-failed" && (
|
|
262
|
+
<div className="px-5 pb-3">
|
|
263
|
+
<Banner tone="danger" variant="soft">
|
|
264
|
+
{failureCopy(
|
|
265
|
+
phase.cause,
|
|
266
|
+
action,
|
|
267
|
+
selected?.label ?? "That folder",
|
|
268
|
+
)}
|
|
269
|
+
</Banner>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
<p className="px-5 pb-2 text-sm text-fg-muted">{copy.pickerPrompt}</p>
|
|
273
|
+
<div className="flex h-[26rem] min-h-0 flex-1 overflow-hidden">
|
|
274
|
+
<FolderTreePicker
|
|
275
|
+
folders={folders}
|
|
276
|
+
selectedId={selectedId}
|
|
277
|
+
delimiter={delimiter}
|
|
278
|
+
onSelect={onSelect}
|
|
279
|
+
onCreateFolder={reason === "none" ? onCreateFolder : undefined}
|
|
280
|
+
onCancel={onCancel}
|
|
281
|
+
labels={{
|
|
282
|
+
treeAriaLabel: "Folders on this account",
|
|
283
|
+
optionLabel: (folder) =>
|
|
284
|
+
`Set ${folder.label}${
|
|
285
|
+
folder.messageCount === undefined
|
|
286
|
+
? ""
|
|
287
|
+
: `, ${folder.messageCount} messages`
|
|
288
|
+
}, as Trash`,
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<footer className="flex items-center justify-end gap-2 border-t border-line px-5 py-3">
|
|
294
|
+
<Button variant="secondary" size="sm" onClick={onCancel}>
|
|
295
|
+
{copy.cancelLabel}
|
|
296
|
+
</Button>
|
|
297
|
+
{selected && (
|
|
298
|
+
<Button
|
|
299
|
+
variant={expunges ? "danger" : "primary"}
|
|
300
|
+
size="sm"
|
|
301
|
+
onClick={() => onConfirm(selected.id)}
|
|
302
|
+
>
|
|
303
|
+
<span className="truncate">{copy.confirmLabel}</span>
|
|
304
|
+
</Button>
|
|
305
|
+
)}
|
|
306
|
+
</footer>
|
|
307
|
+
</Dialog>
|
|
308
|
+
);
|
|
309
|
+
};
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
import { ChevronDown } from "lucide-react";
|
|
2
|
-
import type { ReactNode, SelectHTMLAttributes } from "react";
|
|
2
|
+
import type { ReactNode, Ref, SelectHTMLAttributes } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
4
|
|
|
5
5
|
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
6
6
|
/** Optional leading icon (lucide-react element). */
|
|
7
7
|
icon?: ReactNode;
|
|
8
|
+
/** So a surface blocked on this choice can put the cursor on it. */
|
|
9
|
+
ref?: Ref<HTMLSelectElement>;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Native select styled to match Input: same height, hairline border,
|
|
12
14
|
* sunken surface, focus ring. Options render in <option> children.
|
|
13
15
|
*/
|
|
14
|
-
export function Select({
|
|
16
|
+
export function Select({
|
|
17
|
+
icon,
|
|
18
|
+
className,
|
|
19
|
+
children,
|
|
20
|
+
ref,
|
|
21
|
+
...props
|
|
22
|
+
}: SelectProps) {
|
|
15
23
|
return (
|
|
16
24
|
<div
|
|
17
25
|
className={cn(
|
|
@@ -22,6 +30,7 @@ export function Select({ icon, className, children, ...props }: SelectProps) {
|
|
|
22
30
|
>
|
|
23
31
|
{icon && <span className="shrink-0 text-fg-subtle">{icon}</span>}
|
|
24
32
|
<select
|
|
33
|
+
ref={ref}
|
|
25
34
|
className="min-w-0 flex-1 appearance-none bg-transparent py-0 pr-8 text-fg outline-none"
|
|
26
35
|
{...props}
|
|
27
36
|
>
|
package/src/index.ts
CHANGED
|
@@ -582,9 +582,20 @@ export {
|
|
|
582
582
|
export {
|
|
583
583
|
APPOINTABLE_ROLES,
|
|
584
584
|
type CandidateFolder,
|
|
585
|
+
type RoleAppointment,
|
|
585
586
|
RoleAppointmentList,
|
|
586
587
|
type RoleAppointmentListProps,
|
|
588
|
+
type RoleAppointmentSource,
|
|
587
589
|
} from "./components/role-appointment-list.js";
|
|
590
|
+
export {
|
|
591
|
+
type PromptAction,
|
|
592
|
+
type PromptPhase,
|
|
593
|
+
type PromptReason,
|
|
594
|
+
RoleAppointmentPrompt,
|
|
595
|
+
type RoleAppointmentPromptCopy,
|
|
596
|
+
type RoleAppointmentPromptProps,
|
|
597
|
+
roleAppointmentPromptCopy,
|
|
598
|
+
} from "./components/role-appointment-prompt.js";
|
|
588
599
|
export {
|
|
589
600
|
type RowAction,
|
|
590
601
|
RowActions,
|
|
@@ -840,6 +851,7 @@ export {
|
|
|
840
851
|
filterFolderTree,
|
|
841
852
|
folderAncestors,
|
|
842
853
|
folderDepth,
|
|
854
|
+
folderLeaf,
|
|
843
855
|
folderParent,
|
|
844
856
|
matchesQuery,
|
|
845
857
|
orderFolderNodes,
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
filterFolderTree,
|
|
7
7
|
folderAncestors,
|
|
8
8
|
folderDepth,
|
|
9
|
+
folderLeaf,
|
|
9
10
|
folderParent,
|
|
10
11
|
matchesQuery,
|
|
11
12
|
orderFolderNodes,
|
|
@@ -279,3 +280,21 @@ describe("create rows", () => {
|
|
|
279
280
|
]);
|
|
280
281
|
});
|
|
281
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("folderLeaf", () => {
|
|
285
|
+
it("takes the leaf under a dot-delimited namespace", () => {
|
|
286
|
+
assert.equal(folderLeaf("INBOX.Projects.Q3", "."), "Q3");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("takes the leaf under a slash-delimited namespace", () => {
|
|
290
|
+
assert.equal(folderLeaf("INBOX/Sent Messages", "/"), "Sent Messages");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("keeps the whole name when the server reports no delimiter", () => {
|
|
294
|
+
assert.equal(folderLeaf("Projects/Q3", ""), "Projects/Q3");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps a top-level name that carries no delimiter", () => {
|
|
298
|
+
assert.equal(folderLeaf("INBOX", "."), "INBOX");
|
|
299
|
+
});
|
|
300
|
+
});
|
package/src/lib/folder-tree.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface FolderTreeNode {
|
|
|
14
14
|
* rendered as a marker rather than a disabled control.
|
|
15
15
|
*/
|
|
16
16
|
isCurrent?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* How much mail the folder holds. A folder named `Trash` may be an empty
|
|
19
|
+
* look-alike beside the real one, and the count is the only thing that tells
|
|
20
|
+
* them apart. Absent means the surface has no count, not zero.
|
|
21
|
+
*/
|
|
22
|
+
messageCount?: number;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export interface FolderTreeRow {
|
|
@@ -32,6 +38,14 @@ export type FolderTreeDisplayRow =
|
|
|
32
38
|
| { kind: "folder"; row: FolderTreeRow; index: number }
|
|
33
39
|
| { kind: "create"; parent: FolderTreeNode; depth: number };
|
|
34
40
|
|
|
41
|
+
// A server that reports no delimiter has a flat namespace, so the path is its
|
|
42
|
+
// own leaf; splitting on "" would return single characters.
|
|
43
|
+
export const folderLeaf = (path: string, delimiter: string): string => {
|
|
44
|
+
if (delimiter.length === 0) return path;
|
|
45
|
+
const parts = path.split(delimiter);
|
|
46
|
+
return parts[parts.length - 1] || path;
|
|
47
|
+
};
|
|
48
|
+
|
|
35
49
|
export const folderParent = (path: string, delimiter: string): string => {
|
|
36
50
|
const cut = path.lastIndexOf(delimiter);
|
|
37
51
|
return cut === -1 ? "" : path.slice(0, cut);
|