@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.
- package/dist/ai/ai.css +197 -21
- package/dist/ai/components/AllocationSelector.js +2 -2
- package/dist/ai/components/BranchNavigator.js +6 -6
- package/dist/ai/components/ChatEmptyState.js +39 -36
- package/dist/ai/components/ChatInput.js +67 -60
- package/dist/ai/components/ChatMessage.js +21 -20
- package/dist/ai/components/ChatMessageList.d.ts +7 -2
- package/dist/ai/components/ChatMessageList.js +65 -54
- package/dist/ai/components/ChatSidebar.js +6 -6
- package/dist/ai/components/ChatThread.js +3 -63
- package/dist/ai/components/ModelSelector.js +7 -7
- package/dist/ai/components/PastedText.d.ts +3 -1
- package/dist/ai/components/PastedText.js +21 -6
- package/dist/ai/components/Reasoning.d.ts +17 -0
- package/dist/ai/components/Reasoning.js +29 -0
- package/dist/ai/components/agent/AgentMessageParts.js +32 -22
- package/dist/ai/core/ChatProvider.js +3 -1
- package/dist/ai/core/pasteLanguage.d.ts +6 -0
- package/dist/ai/core/pasteLanguage.js +45 -0
- package/dist/ai/strings.d.ts +9 -0
- package/dist/ai/strings.js +7 -1
- package/dist/ai/ui/sidebar.js +3 -3
- package/dist/file-explorer/FileExplorer.js +41 -16
- package/dist/file-explorer/FileExplorerContext.js +4 -2
- package/dist/file-explorer/FilePreview/previewCache.d.ts +1 -1
- package/dist/file-explorer/FilePreview/previewCache.js +6 -4
- package/dist/file-explorer/index.js +1 -1
- package/dist/file-explorer/lib/types.d.ts +2 -0
- package/dist/styles.css +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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: "
|
|
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
|
|
524
|
-
*
|
|
525
|
-
*
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
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__ */
|
|
537
|
+
if (isStreaming) return /* @__PURE__ */ jsxs("div", {
|
|
541
538
|
className: "py-1.5",
|
|
542
539
|
"data-testid": "reasoning-part",
|
|
543
|
-
children:
|
|
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__ */
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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 };
|
package/dist/ai/strings.d.ts
CHANGED
|
@@ -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;
|
package/dist/ai/strings.js
CHANGED
|
@@ -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: {
|
|
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",
|
package/dist/ai/ui/sidebar.js
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
|
200
|
-
|
|
201
|
-
const
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
218
|
+
for (const [id] of roots) next.delete(id);
|
|
209
219
|
return next;
|
|
210
220
|
};
|
|
211
|
-
updateConnectionIssuesMessage(
|
|
212
|
-
setHasCorsIssue(
|
|
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
|
-
|
|
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)
|
|
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
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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 {
|