@parallelworks/ui 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ import { FiX } from "../../icons/fi/FiX.js";
3
3
  import { ImSpinner5 } from "../../icons/im/ImSpinner5.js";
4
4
  import { useChatConfig } from "../core/config.js";
5
5
  import { formatFileSize } from "../utils.js";
6
+ import Markdown from "../ui/Markdown.js";
7
+ import { fencePaste } from "../core/pasteLanguage.js";
6
8
  import { splitPastes } from "../core/pastes.js";
7
9
  import cx from "classnames";
8
10
  import { Fragment } from "react";
@@ -14,7 +16,7 @@ function PasteFileCard({ id, lines, bytes, saving = false, error, onRemove }) {
14
16
  const detail = error ?? (saving ? t.saving : `${t.lines(lines)} · ${formatFileSize(bytes)}`);
15
17
  return /* @__PURE__ */ jsxs("div", {
16
18
  "data-testid": "paste-file-card",
17
- className: cx("inline-flex max-w-full items-center gap-2.5 rounded-xl border px-3 py-2 align-top whitespace-normal", "bg-(--theme-muted-panel-bg)", error ? "border-red-500" : "theme-border"),
19
+ className: cx("inline-flex max-w-full items-center gap-2.5 rounded-xl px-3 py-2 align-top whitespace-normal", "bg-(--theme-panel-bg)", error ? "border border-red-500" : "chat-hairline"),
18
20
  children: [
19
21
  /* @__PURE__ */ jsx(FiFile, { className: "h-5 w-5 flex-shrink-0 theme-muted-text" }),
20
22
  /* @__PURE__ */ jsxs("div", {
@@ -39,14 +41,27 @@ function PasteFileCard({ id, lines, bytes, saving = false, error, onRemove }) {
39
41
  ]
40
42
  });
41
43
  }
42
- /** A user message as typed: an inline paste as its text, one sent as its file as a card. */
44
+ /** A user message as typed: an inline paste as a code block in the language
45
+ * it reads as, one sent as its file as a card. The blank lines the composer puts around a
46
+ * paste would stack on the block's own spacing, so they are dropped. */
43
47
  function UserMessageText({ content, pastes }) {
44
48
  if (!pastes?.length) return /* @__PURE__ */ jsx(Fragment$1, { children: content });
45
- return /* @__PURE__ */ jsx(Fragment$1, { children: splitPastes(content, pastes).map((segment, i) => {
46
- if (typeof segment === "string") return /* @__PURE__ */ jsx(Fragment, { children: segment }, i);
47
- if (segment.inline && segment.text !== void 0) return /* @__PURE__ */ jsx(Fragment, { children: segment.text }, i);
49
+ const segments = splitPastes(content, pastes);
50
+ const shows = (seg) => typeof seg !== "string" || seg.trim() !== "";
51
+ const blockSpacing = (i) => cx(segments.slice(0, i).some(shows) ? "mt-2.5" : "mt-0", segments.slice(i + 1).some(shows) ? "mb-2.5" : "mb-0");
52
+ return /* @__PURE__ */ jsx(Fragment$1, { children: segments.map((segment, i) => {
53
+ if (typeof segment === "string") {
54
+ let text = segment;
55
+ if (i > 0) text = text.replace(/^\n+/, "");
56
+ if (i < segments.length - 1) text = text.replace(/\n+$/, "");
57
+ return text ? /* @__PURE__ */ jsx(Fragment, { children: text }, i) : null;
58
+ }
59
+ if (segment.inline && segment.text !== void 0) return /* @__PURE__ */ jsx("span", {
60
+ className: cx("chat-paste-code block", blockSpacing(i)),
61
+ children: /* @__PURE__ */ jsx(Markdown, { children: fencePaste(segment.text) })
62
+ }, i);
48
63
  return /* @__PURE__ */ jsx("span", {
49
- className: "my-1 block",
64
+ className: cx("flex [&>*]:flex-1", blockSpacing(i)),
50
65
  children: /* @__PURE__ */ jsx(PasteFileCard, {
51
66
  id: segment.id,
52
67
  lines: segment.lines,
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from 'react';
2
+ /** The line a turn's reasoning folds under. It opens the reasoning in place,
3
+ * above the answer it led to, rather than in a panel the eye has to cross
4
+ * the page to read. */
5
+ export declare function ReasoningToggle({ open, onToggle, shimmer, children, }: {
6
+ open: boolean;
7
+ onToggle: () => void;
8
+ /** While the model is still thinking the label shimmers. */
9
+ shimmer?: boolean;
10
+ children: ReactNode;
11
+ }): import("react").JSX.Element;
12
+ /** The reasoning itself: smaller and quieter than the answer, hung off a
13
+ * hairline of the accent so it reads as the assistant's aside. */
14
+ export declare function ReasoningBody({ className, children, }: {
15
+ className?: string;
16
+ children: ReactNode;
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { LuChevronRight } from "../../icons/lu/LuChevronRight.js";
2
+ import cx from "classnames";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/ai/components/Reasoning.tsx
5
+ /** The line a turn's reasoning folds under. It opens the reasoning in place,
6
+ * above the answer it led to, rather than in a panel the eye has to cross
7
+ * the page to read. */
8
+ function ReasoningToggle({ open, onToggle, shimmer = false, children }) {
9
+ return /* @__PURE__ */ jsxs("button", {
10
+ type: "button",
11
+ onClick: onToggle,
12
+ "aria-expanded": open,
13
+ className: "group/reasoning -ml-1 flex items-center gap-x-1 rounded-md px-1 py-0.5 text-sm theme-muted-text transition-colors hover:theme-text",
14
+ children: [/* @__PURE__ */ jsx("span", {
15
+ className: cx(shimmer && "animate-shimmer text-shimmer"),
16
+ children
17
+ }), /* @__PURE__ */ jsx(LuChevronRight, { className: cx("h-3.5 w-3.5 transition-transform duration-200 motion-reduce:transition-none", open && "rotate-90") })]
18
+ });
19
+ }
20
+ /** The reasoning itself: smaller and quieter than the answer, hung off a
21
+ * hairline of the accent so it reads as the assistant's aside. */
22
+ function ReasoningBody({ className, children }) {
23
+ return /* @__PURE__ */ jsx("div", {
24
+ className: cx("chat-reasoning-body ml-1 mt-1.5 mb-3 pl-4 text-sm leading-relaxed theme-muted-text", className),
25
+ children
26
+ });
27
+ }
28
+ //#endregion
29
+ export { ReasoningBody, ReasoningToggle };
@@ -10,6 +10,7 @@ import { thinkingHeading } from "../../agent/transcript.js";
10
10
  import { useChatConfig } from "../../core/config.js";
11
11
  import Markdown from "../../ui/Markdown.js";
12
12
  import { chatProseClasses } from "../../ui/prose.js";
13
+ import { ReasoningBody, ReasoningToggle } from "../Reasoning.js";
13
14
  import cx from "classnames";
14
15
  import { useEffect, useMemo, useState } from "react";
15
16
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
@@ -520,41 +521,50 @@ function formatThinkingDuration(ms) {
520
521
  if (s < 60) return `${s}s`;
521
522
  return `${Math.floor(s / 60)}m ${s % 60}s`;
522
523
  }
523
- /** The TUI's thought: the body shows while it streams, then folds to its
524
- * label and opens on request. The composer's activity line carries the
525
- * live signal, so nothing here animates. */
524
+ /** The TUI's thought, drawn like a chat reply's: while it streams its label
525
+ * shimmers over the latest lines, then it folds to the label and opens in
526
+ * place on request. */
526
527
  function ReasoningBlock({ part, isStreaming }) {
527
528
  const { strings } = useChatConfig();
528
529
  const t = strings.thinking;
529
530
  const [expanded, setExpanded] = useState(false);
530
531
  const label = thinkingHeading(part.text) || (part.durationMs ? t.thoughtFor(formatThinkingDuration(part.durationMs)) : t.label);
531
532
  const hasBody = part.text.trim() !== "";
532
- const body = hasBody && /* @__PURE__ */ jsx("div", {
533
- className: "ml-1 border-l theme-border pl-3 text-sm theme-muted-text",
534
- "data-testid": "reasoning-body",
535
- children: /* @__PURE__ */ jsx(Markdown, {
536
- isStreaming: isStreaming ?? false,
537
- children: part.text
538
- })
533
+ const markdown = /* @__PURE__ */ jsx(Markdown, {
534
+ isStreaming: isStreaming ?? false,
535
+ children: part.text
539
536
  });
540
- if (isStreaming) return /* @__PURE__ */ jsx("div", {
537
+ if (isStreaming) return /* @__PURE__ */ jsxs("div", {
541
538
  className: "py-1.5",
542
539
  "data-testid": "reasoning-part",
543
- children: body
540
+ children: [/* @__PURE__ */ jsx("div", {
541
+ className: "animate-shimmer text-shimmer text-sm",
542
+ children: label
543
+ }), hasBody && /* @__PURE__ */ jsx(ReasoningBody, {
544
+ className: "chat-reasoning-tail",
545
+ children: /* @__PURE__ */ jsx("div", {
546
+ className: "flex max-h-20 flex-col justify-end overflow-hidden",
547
+ "data-testid": "reasoning-body",
548
+ children: markdown
549
+ })
550
+ })]
551
+ });
552
+ if (!hasBody) return /* @__PURE__ */ jsx("div", {
553
+ className: "py-1.5 text-sm theme-muted-text",
554
+ "data-testid": "reasoning-part",
555
+ children: label
544
556
  });
545
557
  return /* @__PURE__ */ jsxs("div", {
546
558
  className: "py-1.5",
547
559
  "data-testid": "reasoning-part",
548
- children: [/* @__PURE__ */ jsxs("button", {
549
- type: "button",
550
- onClick: () => hasBody && setExpanded((v) => !v),
551
- "aria-expanded": expanded,
552
- className: cx("flex min-h-6 items-center gap-x-1 text-sm theme-muted-text transition-colors", hasBody && "hover:theme-text"),
553
- children: [/* @__PURE__ */ jsx("span", { children: label }), hasBody && /* @__PURE__ */ jsx(LuChevronRight, { className: cx("w-3.5 h-3.5 transition-transform", expanded && "rotate-90") })]
554
- }), expanded && /* @__PURE__ */ jsx("div", {
555
- className: "mt-1.5",
556
- children: body
557
- })]
560
+ children: [/* @__PURE__ */ jsx(ReasoningToggle, {
561
+ open: expanded,
562
+ onToggle: () => setExpanded((v) => !v),
563
+ children: label
564
+ }), expanded && /* @__PURE__ */ jsx(ReasoningBody, { children: /* @__PURE__ */ jsx("div", {
565
+ "data-testid": "reasoning-body",
566
+ children: markdown
567
+ }) })]
558
568
  });
559
569
  }
560
570
  function TextBlock({ part, isStreaming }) {
@@ -441,6 +441,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
441
441
  try {
442
442
  const useResponses = state.models.find((m) => m.id === modelString)?.supports_responses === true;
443
443
  let thinkingStart = null;
444
+ let thinkingEnd = null;
444
445
  const markStreamStart = () => {
445
446
  if (thinkingStart === null) thinkingStart = Date.now();
446
447
  };
@@ -460,6 +461,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
460
461
  },
461
462
  onReasoning: (reasoning) => {
462
463
  markStreamStart();
464
+ thinkingEnd = Date.now();
463
465
  dispatch({
464
466
  type: "APPEND_STREAMING_REASONING",
465
467
  reasoning,
@@ -485,7 +487,7 @@ function ChatProvider({ adapter, currentUser, navigation, notify, activeConversa
485
487
  if (bgStream) bgStream.parts = liveParts;
486
488
  }
487
489
  }, controller.signal);
488
- const thinkingDuration = assistantReasoning && thinkingStart !== null ? Date.now() - thinkingStart : null;
490
+ const thinkingDuration = assistantReasoning && thinkingStart !== null ? (thinkingEnd ?? Date.now()) - thinkingStart : null;
489
491
  const assistantMessage = {
490
492
  id: assistantMessageId || crypto.randomUUID(),
491
493
  parentId: userMessageId,
@@ -0,0 +1,6 @@
1
+ /** The highlighter's name for the language a paste is written in, or ''
2
+ * when it is not recognisably one. */
3
+ export declare function guessPasteLanguage(text: string): string;
4
+ /** The paste as a fenced code block in its language. The fence outruns any
5
+ * run of backticks inside the paste, so nothing in it can close the fence. */
6
+ export declare function fencePaste(text: string): string;
@@ -0,0 +1,45 @@
1
+ //#region src/ai/core/pasteLanguage.ts
2
+ function meaningfulLines(text) {
3
+ return text.split("\n").filter((l) => l.trim() !== "" && !/^\s*#/.test(l));
4
+ }
5
+ function mostly(lines, test) {
6
+ return lines.length > 0 && lines.filter(test).length / lines.length >= .8;
7
+ }
8
+ function isJson(trimmed) {
9
+ if (!/^[[{]/.test(trimmed)) return false;
10
+ try {
11
+ JSON.parse(trimmed);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+ var YAML_LINE = /^\s*(-\s+)?["']?[\w./-]+["']?\s*:(\s|$)|^\s*-(\s|$)|^---\s*$/;
18
+ var TOML_LINE = /^\s*\[{1,2}[\w."-]+\]{1,2}\s*$|^\s*[\w."-]+\s*=\s*\S/;
19
+ /** The highlighter's name for the language a paste is written in, or ''
20
+ * when it is not recognisably one. */
21
+ function guessPasteLanguage(text) {
22
+ const trimmed = text.trim();
23
+ if (!trimmed) return "";
24
+ if (isJson(trimmed)) return "json";
25
+ const all = trimmed.split("\n");
26
+ if (all.some((l) => /^(diff --git |@@ -\d)/.test(l))) return "diff";
27
+ if (/^<(!doctype html|html)\b/i.test(trimmed)) return "html";
28
+ if (/^<[?!\w]/.test(trimmed) && trimmed.endsWith(">")) return "xml";
29
+ const first = all[0] ?? "";
30
+ if (/^#!.*\b(ba|z)?sh\b/.test(first) || mostly(meaningfulLines(trimmed), (l) => /^\$ /.test(l))) return "bash";
31
+ if (/^(select|insert|update|delete|create|alter|with)\s/i.test(first)) return "sql";
32
+ const lines = meaningfulLines(trimmed);
33
+ if (lines.length >= 2 && mostly(lines, (l) => YAML_LINE.test(l))) return "yaml";
34
+ if (lines.length >= 2 && mostly(lines, (l) => TOML_LINE.test(l))) return "toml";
35
+ return "";
36
+ }
37
+ /** The paste as a fenced code block in its language. The fence outruns any
38
+ * run of backticks inside the paste, so nothing in it can close the fence. */
39
+ function fencePaste(text) {
40
+ const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
41
+ const fence = "`".repeat(longest + 1);
42
+ return `${fence}${guessPasteLanguage(text)}\n${text.replace(/\n+$/, "")}\n${fence}`;
43
+ }
44
+ //#endregion
45
+ export { fencePaste, guessPasteLanguage };
@@ -35,6 +35,15 @@ export interface ChatStrings {
35
35
  };
36
36
  queue: {
37
37
  remove: string;
38
+ /** Under a message that goes out once the running reply finishes. */
39
+ waiting: string;
40
+ /** Under a message a steering host hands to the agent between steps. */
41
+ waitingNextStep: string;
42
+ /** Under a message the host has already handed to the running turn. */
43
+ handedOver: string;
44
+ /** The visible label of the remove control; `remove` names it for
45
+ * assistive technology. */
46
+ removeShort: string;
38
47
  };
39
48
  activity: {
40
49
  tokens: (input: string, output: string) => string;
@@ -32,7 +32,13 @@ var defaultChatStrings = {
32
32
  title: "Billing group \"{group}\" is over its allocation",
33
33
  message: "It has reached its allocation limit. Messages to this provider are paused until your administrator increases the allocation."
34
34
  },
35
- queue: { remove: "Remove queued message" },
35
+ queue: {
36
+ remove: "Remove queued message",
37
+ waiting: "Queued · sends when this reply finishes",
38
+ waitingNextStep: "Queued · reaches the agent after its current step",
39
+ handedOver: "Sent to the running turn",
40
+ removeShort: "Remove"
41
+ },
36
42
  providerIssue: {
37
43
  keyRejected: "API key rejected",
38
44
  unreachable: "Endpoint unreachable",
@@ -7,7 +7,7 @@ import { useState } from "react";
7
7
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
8
8
  //#region src/ai/ui/sidebar.tsx
9
9
  var SIDEBAR_RAIL_WIDTH_PX = 48;
10
- var HIGHLIGHT_SELECTED = "bg-[color-mix(in_oklab,var(--theme-panel)_8%,transparent)]";
10
+ var HIGHLIGHT_SELECTED = "bg-[color-mix(in_oklab,var(--theme-accent)_12%,transparent)]";
11
11
  var HIGHLIGHT_HOVER = "hover:bg-[color-mix(in_oklab,var(--theme-panel)_5%,transparent)]";
12
12
  /** The column a conversation list lives in: it animates between its width
13
13
  * and a rail, unless a drag is setting the width right now. */
@@ -37,7 +37,7 @@ function SidebarToggle({ collapsed, onToggle, openLabel, closeLabel, className }
37
37
  function SidebarGroupHeading({ children, tone = "default", testId }) {
38
38
  return /* @__PURE__ */ jsx("h4", {
39
39
  "data-testid": testId,
40
- className: cx("mt-4 mb-1 whitespace-nowrap px-2 text-xs font-medium", tone === "attention" ? "text-amber-700 dark:text-amber-300" : "theme-muted-text"),
40
+ className: cx("mt-5 mb-1 whitespace-nowrap px-2 text-xs font-medium", tone === "attention" ? "text-amber-700 dark:text-amber-300" : "theme-muted-text"),
41
41
  children
42
42
  });
43
43
  }
@@ -46,7 +46,7 @@ function SidebarGroupHeading({ children, tone = "default", testId }) {
46
46
  function SidebarRow({ selected, menuItems, openMenu, className, children }) {
47
47
  const { active, openMenu: openRowMenu } = useRowMenuActive(openMenu);
48
48
  return /* @__PURE__ */ jsxs("li", {
49
- className: cx("group relative flex items-center justify-between rounded-lg px-2 py-1 transition-colors duration-150", selected || active ? HIGHLIGHT_SELECTED : HIGHLIGHT_HOVER, className),
49
+ className: cx("group relative flex items-center justify-between rounded-lg px-2 py-[3px] transition-colors duration-150", selected || active ? HIGHLIGHT_SELECTED : HIGHLIGHT_HOVER, className),
50
50
  ...rowContextMenuProps(openRowMenu, menuItems),
51
51
  children: [children, /* @__PURE__ */ jsx(MoreButton, {
52
52
  active,
@@ -81,6 +81,9 @@ function isStorageDirectory(node) {
81
81
  function getErrorMessage(error) {
82
82
  return error instanceof Error ? error.message : String(error);
83
83
  }
84
+ function isWithinRoots(roots) {
85
+ return (path) => roots.some(([, root]) => path.startsWith(root));
86
+ }
84
87
  /** Directory paths from the storage root down to `path`. */
85
88
  function getAncestorDirPaths(path) {
86
89
  const isDir = path.endsWith("/");
@@ -179,14 +182,18 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
179
182
  const findStorage = (storageId) => storages.find((s) => s.id === storageId);
180
183
  const storageCanWrite = (storageId) => findStorage(storageId)?.canWrite === true;
181
184
  const storageCanUpload = (storageId) => findStorage(storageId)?.canUpload !== false;
185
+ const storageCanDelete = (storage) => (storage?.canDelete ?? storage?.canWrite) === true;
182
186
  const selectedStorage = findStorage(selectedNode?.storageId);
183
187
  const selectedNodeCanWrite = selectedStorage?.canWrite === true;
184
188
  const selectedNodeCanUpload = selectedStorage?.canUpload !== false;
189
+ const selectedNodeCanDelete = storageCanDelete(selectedStorage);
185
190
  const selectedNodeCanShare = selectedStorage?.canShare !== false;
186
191
  const selectedNodeCanManageAccess = selectedStorage?.canManageAccess !== false;
187
192
  const [accessDrawerOpen, setAccessDrawerOpen] = useState(false);
188
193
  const [connectionIssuesMessage, updateConnectionIssuesMessage] = useState(/* @__PURE__ */ new Map());
189
194
  const [hasCorsIssue, setHasCorsIssue] = useState(/* @__PURE__ */ new Map());
195
+ const [previewNode, setPreviewNode] = useState(null);
196
+ const [previewOpen, setPreviewOpen] = useState(false);
190
197
  const storageRoots = useMemo(() => {
191
198
  const roots = /* @__PURE__ */ new Map();
192
199
  for (const node of rootNodes) {
@@ -195,32 +202,52 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
195
202
  }
196
203
  return roots;
197
204
  }, [rootNodes, parentToChildrenMap]);
205
+ const storageRootPaths = useMemo(() => new Map(storages.map((storage) => [storage.id, storage.rootPath])), [storages]);
198
206
  const seenStorageRootsRef = useRef(storageRoots);
199
- const forgetStorages = useEffectEvent((removed) => {
200
- const isUnder = (path) => removed.some(([, root]) => path !== root && path.startsWith(root));
201
- const isWithin = (path) => removed.some(([, root]) => path.startsWith(root));
207
+ const seenRootPathsRef = useRef(storageRootPaths);
208
+ const dropStorageListings = (roots) => {
209
+ const isUnder = (path) => roots.some(([, root]) => path !== root && path.startsWith(root));
210
+ const isWithin = isWithinRoots(roots);
202
211
  for (const path of [...fetchesInFlightRef.current.keys()]) if (isWithin(path)) fetchesInFlightRef.current.delete(path);
203
- setListings((prev) => removed.reduce((next, [, root]) => clearListingsUnder(next, root), prev));
212
+ setListings((prev) => roots.reduce((next, [, root]) => clearListingsUnder(next, root), prev));
204
213
  setLoadingPaths((prev) => new Set([...prev].filter((path) => !isWithin(path))));
205
214
  setExpandedPaths((prev) => new Set([...prev].filter((path) => !isUnder(path))));
206
- const withoutRemoved = (prev) => {
215
+ setCheckedItems((prev) => new Set([...prev].filter((path) => !isWithin(path))));
216
+ const withoutDropped = (prev) => {
207
217
  const next = new Map(prev);
208
- for (const [id] of removed) next.delete(id);
218
+ for (const [id] of roots) next.delete(id);
209
219
  return next;
210
220
  };
211
- updateConnectionIssuesMessage(withoutRemoved);
212
- setHasCorsIssue(withoutRemoved);
221
+ updateConnectionIssuesMessage(withoutDropped);
222
+ setHasCorsIssue(withoutDropped);
223
+ };
224
+ const forgetStorages = useEffectEvent((removed) => {
225
+ dropStorageListings(removed);
213
226
  const selectedRoot = removed.find(([, root]) => normalizedSelectedPath.startsWith(root))?.[1];
214
227
  if (selectedRoot) {
215
228
  const parent = getParentPath(selectedRoot);
216
229
  setSelectedPath(treeMap[parent] ? parent : "");
217
230
  }
218
231
  });
232
+ const rerootStorages = useEffectEvent((rerooted) => {
233
+ dropStorageListings(rerooted);
234
+ clearPreviewCache(isWithinRoots(rerooted));
235
+ if (previewNode && rerooted.some(([id]) => id === previewNode.storageId)) {
236
+ setPreviewOpen(false);
237
+ setPreviewNode(null);
238
+ }
239
+ const selectedRoot = rerooted.find(([, root]) => normalizedSelectedPath.startsWith(root))?.[1];
240
+ if (selectedRoot && selectedRoot !== normalizedSelectedPath) setSelectedPath(selectedRoot);
241
+ });
219
242
  useEffect(() => {
243
+ const seenRootPaths = seenRootPathsRef.current;
220
244
  const removed = [...seenStorageRootsRef.current].filter(([id]) => !storageRoots.has(id));
245
+ const rerooted = [...storageRoots].filter(([id]) => seenRootPaths.has(id) && seenRootPaths.get(id) !== storageRootPaths.get(id));
221
246
  seenStorageRootsRef.current = storageRoots;
247
+ seenRootPathsRef.current = storageRootPaths;
222
248
  if (removed.length > 0) forgetStorages(removed);
223
- }, [storageRoots]);
249
+ if (rerooted.length > 0) rerootStorages(rerooted);
250
+ }, [storageRoots, storageRootPaths]);
224
251
  const [uploadModalOpen, setUploadModalOpen] = useState(false);
225
252
  const [createFolderTarget, setCreateFolderTarget] = useState(null);
226
253
  const [pendingUpload, setPendingUpload] = useState(null);
@@ -229,8 +256,6 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
229
256
  const folderInputRef = useRef(null);
230
257
  const [deleteModalOpen, setDeleteModalOpen] = useState(false);
231
258
  const [pendingDelete, setPendingDelete] = useState(null);
232
- const [previewNode, setPreviewNode] = useState(null);
233
- const [previewOpen, setPreviewOpen] = useState(false);
234
259
  const [detailTab, setDetailTab] = useState("details");
235
260
  useEffect(() => clearPreviewCache, []);
236
261
  const { openMenu, contextMenu } = useRowMenu();
@@ -587,7 +612,7 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
587
612
  triggerBrowserDownload(presignedUrl, node.name);
588
613
  };
589
614
  const deleteNode = (node) => {
590
- if (findStorage(node.storageId)?.canWrite !== true) {
615
+ if (!storageCanDelete(findStorage(node.storageId))) {
591
616
  notify.error(t.messages.noWriteAccess);
592
617
  return;
593
618
  }
@@ -610,7 +635,7 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
610
635
  };
611
636
  const buildRowMenu = (node) => {
612
637
  const storage = findStorage(node.storageId);
613
- const nodeCanWrite = storage?.canWrite === true;
638
+ const nodeCanDelete = storageCanDelete(storage);
614
639
  const nodeCanShare = storage?.canShare !== false;
615
640
  const isFile = node.type === "file";
616
641
  const uris = getNodeUris(node);
@@ -637,7 +662,7 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
637
662
  }
638
663
  actions.push(...getCreateFolderActions(node, storage));
639
664
  actions.push(...extraRowActions?.(node, storage) ?? []);
640
- if (nodeCanWrite && !node.root) actions.push({
665
+ if (nodeCanDelete && !node.root) actions.push({
641
666
  key: "delete",
642
667
  label: t.preview.delete,
643
668
  icon: /* @__PURE__ */ jsx(BiSolidTrashAlt, {}),
@@ -716,7 +741,7 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
716
741
  return downloadCommand.build(objects);
717
742
  };
718
743
  const onDeleteClick = () => {
719
- if (!selectedNodeCanWrite) {
744
+ if (!selectedNodeCanDelete) {
720
745
  notify.error(t.messages.noWriteAccess);
721
746
  return;
722
747
  }
@@ -1135,7 +1160,7 @@ function FileExplorer({ storages, onRefresh, refreshKey, onOpenFile, showUserHie
1135
1160
  children: t.chrome.manageAccess
1136
1161
  })]
1137
1162
  }),
1138
- selectedNodeCanWrite && /* @__PURE__ */ jsxs("button", {
1163
+ selectedNodeCanDelete && /* @__PURE__ */ jsxs("button", {
1139
1164
  type: "button",
1140
1165
  "aria-label": t.chrome.delete,
1141
1166
  className: "btn btn-destructive cursor-pointer flex items-center justify-center",
@@ -1,10 +1,12 @@
1
1
  import { useNotify, useStrings } from "../components/Provider.js";
2
2
  import { keyedByContent } from "../components/keys.js";
3
+ import { mapWithConcurrency } from "./lib/batch.js";
3
4
  import { calculateUploadNodesTotalSize, getUploadSessionETA, removeLeadingSlash } from "./lib/utils.js";
4
5
  import { UploadSessionToast } from "./toasts.js";
5
6
  import { createContext, use, useRef } from "react";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/file-explorer/FileExplorerContext.tsx
9
+ var UPLOAD_CONCURRENCY = 3;
8
10
  var FileExplorerContext = createContext(void 0);
9
11
  var permissionMessagePattern = /not authorized|access denied|status code 403/i;
10
12
  function isPermissionDenied(reason) {
@@ -157,7 +159,7 @@ function FileExplorerProvider({ children }) {
157
159
  closeButton: false,
158
160
  autoClose: false
159
161
  });
160
- const results = await Promise.allSettled(session.uploadNodes.map(async (node) => {
162
+ const results = await mapWithConcurrency(session.uploadNodes, UPLOAD_CONCURRENCY, async (node) => {
161
163
  const fileProgress = session.progress?.allFiles?.get(node.relativePath);
162
164
  if (!fileProgress) return;
163
165
  if (fileProgress.status === "cancelled") throw new Error("Upload cancelled by user");
@@ -228,7 +230,7 @@ function FileExplorerProvider({ children }) {
228
230
  });
229
231
  throw error;
230
232
  }
231
- }));
233
+ });
232
234
  try {
233
235
  await session.refreshTargetNode(session.targetNode);
234
236
  } catch (err) {
@@ -22,5 +22,5 @@ export declare function loadPreviewContent<T>(path: string, loader: (signal: Abo
22
22
  export declare function fetchPreviewObject(path: string, url: string, signal: AbortSignal, headers?: HeadersInit): Promise<Response>;
23
23
  export declare function peekPreviewUrl(path: string): UrlResult | undefined;
24
24
  export declare function peekPreviewContent<T>(path: string): T | undefined;
25
- export declare function clearPreviewCache(): void;
25
+ export declare function clearPreviewCache(isCleared?: (path: string) => boolean): void;
26
26
  export {};
@@ -89,10 +89,12 @@ function peekPreviewUrl(path) {
89
89
  function peekPreviewContent(path) {
90
90
  return contentCache.get(path)?.value;
91
91
  }
92
- function clearPreviewCache() {
93
- for (const record of contentCache.values()) disposeContent(record);
94
- contentCache.clear();
95
- urlCache.clear();
92
+ function clearPreviewCache(isCleared = () => true) {
93
+ for (const [path, record] of contentCache) if (isCleared(path)) {
94
+ disposeContent(record);
95
+ contentCache.delete(path);
96
+ }
97
+ for (const path of urlCache.keys()) if (isCleared(path)) urlCache.delete(path);
96
98
  }
97
99
  //#endregion
98
100
  export { clearPreviewCache, fetchPreviewObject, loadPreviewContent, loadPreviewUrl, peekPreviewContent, peekPreviewUrl };
@@ -1,8 +1,8 @@
1
+ import { chunk, mapWithConcurrency, paginate } from "./lib/batch.js";
1
2
  import { attachmentDisposition, collectObjectKeysToDelete, getObjectKeyFromNode, isClientValid, removeLeadingAndTrailingSlashes } from "./lib/utils.js";
2
3
  import { FileExplorerProvider, useFileExplorer } from "./FileExplorerContext.js";
3
4
  import { CorsError } from "./lib/errors.js";
4
5
  import { NoDataAvailablePreview, SkeletonPreview } from "./helpers.js";
5
6
  import { LIST_PAGE_SIZE } from "./lib/paging.js";
6
7
  import FileExplorer from "./FileExplorer.js";
7
- import { chunk, mapWithConcurrency, paginate } from "./lib/batch.js";
8
8
  export { CorsError, FileExplorer, FileExplorerProvider, LIST_PAGE_SIZE, NoDataAvailablePreview, SkeletonPreview, attachmentDisposition, chunk, collectObjectKeysToDelete, getObjectKeyFromNode, isClientValid, mapWithConcurrency, paginate, removeLeadingAndTrailingSlashes, useFileExplorer };
@@ -22,9 +22,11 @@ export type TStorage = {
22
22
  imageUrl?: string;
23
23
  canWrite?: boolean | undefined;
24
24
  canUpload?: boolean | undefined;
25
+ canDelete?: boolean | undefined;
25
26
  canShare?: boolean | undefined;
26
27
  canManageAccess?: boolean | undefined;
27
28
  previewLimits?: Partial<PreviewLimits> | undefined;
29
+ rootPath?: string | undefined;
28
30
  };
29
31
  /** A file the host is asked to name or download: its storage, key and name. */
30
32
  export interface ExplorerObject {