From e7ef0f5d494efa80bf38a1cf205a72009590d6c5 Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Tue, 18 Aug 2026 01:39:21 +0800 Subject: [PATCH] feat: add workspace directory picker --- lib/client.js | 1008 +++++++++++------ plugin-src/client/build.mjs | 2 +- plugin-src/client/i18n.js | 14 + plugin-src/client/index.js | 14 +- plugin-src/client/styles.js | 57 +- .../client/workspace-directory-picker.js | 230 ++++ plugin-src/client/workspace-editor.js | 103 +- test/client-ui.test.mjs | 29 +- test/workspace-editor.test.mjs | 371 ++++-- 9 files changed, 1317 insertions(+), 511 deletions(-) create mode 100644 plugin-src/client/workspace-directory-picker.js diff --git a/lib/client.js b/lib/client.js index 235aa5f..ff2c86f 100644 --- a/lib/client.js +++ b/lib/client.js @@ -40,7 +40,7 @@ __export(index_exports, { name: () => name }); module.exports = __toCommonJS(index_exports); -var React15 = __toESM(require("react"), 1); +var React16 = __toESM(require("react"), 1); // plugin-src/client/channel-logos.js var React = __toESM(require("react"), 1); @@ -355,7 +355,7 @@ function formatRemaining(milliseconds) { } // plugin-src/client/channels/dingtalk/index.js -var React6 = __toESM(require("react"), 1); +var React7 = __toESM(require("react"), 1); // plugin-src/client/credential-binding.js var React3 = __toESM(require("react"), 1); @@ -406,6 +406,20 @@ var EN = Object.freeze({ "\u6D88\u606F\u901A\u9053": "Message channel", "\u6700\u8FD1\u68C0\u67E5": "Last checked", "\u5F53\u524D\u5DE5\u4F5C\u533A": "Current workspace", + "\u9009\u62E9\u76EE\u5F55": "Choose folder", + "\u9009\u62E9\u673A\u5668\u4EBA\u5DE5\u4F5C\u533A\u76EE\u5F55": "Select bot workspace folder", + "\u5F53\u524D\u76EE\u5F55": "Current folder", + "\u4E3B\u76EE\u5F55": "Home", + "\u6B63\u5728\u51C6\u5907\u76EE\u5F55\u9009\u62E9\u5668\u2026": "Preparing folder picker\u2026", + "\u6B63\u5728\u8BFB\u53D6\u76EE\u5F55\u2026": "Loading folders\u2026", + "\u8FD9\u4E2A\u76EE\u5F55\u4E2D\u6CA1\u6709\u5B50\u6587\u4EF6\u5939\u3002": "This folder has no subfolders.", + "\u6B64\u76EE\u5F55\u7684\u5B50\u6587\u4EF6\u5939\u8FC7\u591A\uFF0C\u4EC5\u663E\u793A\u524D\u4E00\u90E8\u5206\u3002": "This folder has too many subfolders; only the first group is shown.", + "\u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55\uFF0C\u8BF7\u91CD\u8BD5\u3002": "Could not load the folder. Try again.", + "\u91CD\u8BD5": "Retry", + "\u663E\u793A\u9690\u85CF\u6587\u4EF6\u5939": "Show hidden folders", + "\u5207\u6362\u540E\u4F1A\u6E05\u9664\u8FD9\u4E2A\u673A\u5668\u4EBA\u7684\u65E7\u4F1A\u8BDD\u6620\u5C04\u3002": "Switching clears this bot\u2019s previous session mappings.", + "\u5207\u6362\u4E2D\u2026": "Switching\u2026", + "\u9009\u62E9\u6B64\u76EE\u5F55": "Select this folder", "\u5DE5\u4F5C\u533A\u7EDD\u5BF9\u8DEF\u5F84": "Absolute workspace path", "/\u7EDD\u5BF9\u8DEF\u5F84/\u5230/\u5DE5\u4F5C\u533A": "/absolute/path/to/workspace", "\u4FEE\u6539": "Change", @@ -963,54 +977,298 @@ function CredentialBindingPanel({ } // plugin-src/client/workspace-editor.js +var React5 = __toESM(require("react"), 1); + +// plugin-src/client/workspace-directory-picker.js var React4 = __toESM(require("react"), 1); -function looksLikeAbsolutePath(value) { - return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); +var import_react_dom = require("react-dom"); +function pickerErrorCode(error) { + return error?.rpcError?.code ?? error?.code; } -function WorkspaceEditor({ workspace, disabled = false, onSave }) { - const [editing, setEditing] = React4.useState(false); - const [draft, setDraft] = React4.useState(workspace ?? ""); - const [saving, setSaving] = React4.useState(false); +function pickerErrorDetails(error) { + return error?.rpcError?.details ?? error?.details; +} +function pickerErrorMessage(error) { + return error?.rpcError?.message ?? error?.message ?? "\u65E0\u6CD5\u8BFB\u53D6\u76EE\u5F55\uFF0C\u8BF7\u91CD\u8BD5\u3002"; +} +function FolderIcon() { + return React4.createElement( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.8, + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": "true" + }, + React4.createElement("path", { d: "M3.5 7.25A2.25 2.25 0 0 1 5.75 5h4.1l1.8 2h6.6a2.25 2.25 0 0 1 2.25 2.25v7A2.75 2.75 0 0 1 17.75 19h-12A2.25 2.25 0 0 1 3.5 16.75v-9.5Z" }) + ); +} +function ChevronIcon() { + return React4.createElement("svg", { + viewBox: "0 0 20 20", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.7, + strokeLinecap: "round", + strokeLinejoin: "round", + "aria-hidden": "true" + }, React4.createElement("path", { d: "m7.5 4.5 5 5.5-5 5.5" })); +} +function displayCrumbs(listing) { + const homeIndex = listing.crumbs.findIndex((crumb) => crumb.path === listing.home); + if (homeIndex < 0) return listing.crumbs; + return listing.crumbs.slice(homeIndex); +} +function WorkspaceDirectoryPicker({ + open, + startPath, + picker, + busy = false, + saveError = null, + onPicked, + onCancel +}) { + const [listing, setListing] = React4.useState(null); + const [loading, setLoading] = React4.useState(false); const [error, setError] = React4.useState(null); - const inputRef = React4.useRef(null); - const editButtonRef = React4.useRef(null); - const restoreFocus = React4.useRef(false); - React4.useEffect(() => { - if (!editing) setDraft(workspace ?? ""); - }, [workspace, editing]); - React4.useEffect(() => { - if (editing) inputRef.current?.focus(); - else if (restoreFocus.current) { - restoreFocus.current = false; - editButtonRef.current?.focus(); + const [showHidden, setShowHidden] = React4.useState(false); + const [retryKey, setRetryKey] = React4.useState(0); + const requestRef = React4.useRef(0); + const controllerRef = React4.useRef(null); + const dialogRef = React4.useRef(null); + const bodyRef = React4.useRef(null); + const titleId = React4.useId(); + const noticeId = React4.useId(); + const initialPathRef = React4.useRef(startPath); + const onPickedRef = React4.useRef(onPicked); + const onCancelRef = React4.useRef(onCancel); + const busyRef = React4.useRef(busy); + onPickedRef.current = onPicked; + onCancelRef.current = onCancel; + busyRef.current = busy; + const loadDirectory = React4.useCallback(async (path, { reportError = true } = {}) => { + const request = requestRef.current + 1; + requestRef.current = request; + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + setLoading(true); + if (reportError) setError(null); + try { + const next = await picker.listDirectory(path, controller.signal); + if (request !== requestRef.current || controller.signal.aborted) return { aborted: true }; + if (bodyRef.current) bodyRef.current.scrollTop = 0; + setListing(next); + setError(null); + return { value: next }; + } catch (cause) { + if (request !== requestRef.current || controller.signal.aborted) return { aborted: true }; + if (reportError) setError(pickerErrorMessage(cause)); + return { error: cause }; + } finally { + if (request === requestRef.current) setLoading(false); } - }, [editing]); - const cancel = () => { - setDraft(workspace ?? ""); + }, [picker]); + React4.useEffect(() => { + if (!open) return void 0; + let active = true; + setListing(null); setError(null); - restoreFocus.current = true; - setEditing(false); - }; - const submit = async (event) => { - event.preventDefault(); - const value = draft.trim(); - if (!value || saving || disabled) return; - if (!looksLikeAbsolutePath(value)) { - setError("\u5DE5\u4F5C\u533A\u5FC5\u987B\u662F\u7EDD\u5BF9\u8DEF\u5F84\u3002"); + setShowHidden(false); + dialogRef.current?.focus?.(); + const handleKeyDown = (event) => { + if (event.key === "Escape" && !busyRef.current) onCancelRef.current?.(); + }; + if (typeof document !== "undefined") document.addEventListener("keydown", handleKeyDown); + const start = async () => { + const initialPath = initialPathRef.current; + const initial = await loadDirectory(initialPath || void 0, { reportError: false }); + if (!active || initial.aborted || initial.value) return; + const code = pickerErrorCode(initial.error); + const details = pickerErrorDetails(initial.error); + if (code === "directory-picker-unavailable" && details?.capability === "native" && typeof picker.pickDirectory === "function") { + setLoading(true); + try { + const selected = await picker.pickDirectory(); + if (!active) return; + if (selected !== null) await onPickedRef.current?.(selected); + else onCancelRef.current?.(); + } catch (cause) { + if (active) setError(pickerErrorMessage(cause)); + } finally { + if (active) setLoading(false); + } + return; + } + if (initialPath && code === "directory-unreadable") { + const home = await loadDirectory(void 0, { reportError: false }); + if (!active || home.aborted || home.value) return; + setError(pickerErrorMessage(home.error)); + return; + } + setError(pickerErrorMessage(initial.error)); + }; + void start(); + return () => { + active = false; + if (typeof document !== "undefined") document.removeEventListener("keydown", handleKeyDown); + requestRef.current += 1; + controllerRef.current?.abort(); + }; + }, [loadDirectory, open, picker, retryKey]); + if (!open) return null; + const entries = (listing?.entries ?? []).filter((entry) => showHidden || !entry.hidden); + const crumbs = listing ? displayCrumbs(listing) : []; + const presentedError = saveError ?? error; + const content = h2( + "div", + { + className: "dim-directoryPickerBackdrop", + onMouseDown: (event) => { + if (event.target === event.currentTarget && !busy) onCancel(); + } + }, + h2( + "section", + { + ref: dialogRef, + className: "dim-directoryPicker", + role: "dialog", + "aria-modal": "true", + "aria-labelledby": titleId, + "aria-describedby": noticeId, + tabIndex: -1 + }, + h2( + "header", + { className: "dim-directoryPickerHeader" }, + h2("h3", { id: titleId }, "\u9009\u62E9\u673A\u5668\u4EBA\u5DE5\u4F5C\u533A\u76EE\u5F55"), + listing ? h2( + "nav", + { className: "dim-directoryCrumbs", "aria-label": "\u5F53\u524D\u76EE\u5F55" }, + crumbs.map((crumb, index) => h2( + React4.Fragment, + { key: crumb.path }, + index > 0 ? h2("span", { className: "dim-directoryCrumbSeparator", "aria-hidden": "true" }, "\u203A") : null, + React4.createElement("button", { + type: "button", + title: crumb.path, + disabled: loading || busy, + "aria-current": index === crumbs.length - 1 ? "page" : void 0, + onClick: () => void loadDirectory(crumb.path) + }, crumb.path === listing.home ? h2("span", null, "\u4E3B\u76EE\u5F55") : crumb.name || crumb.path) + )) + ) : h2("p", null, "\u6B63\u5728\u51C6\u5907\u76EE\u5F55\u9009\u62E9\u5668\u2026") + ), + h2( + "div", + { ref: bodyRef, className: "dim-directoryPickerBody", "aria-busy": loading }, + loading && !listing ? h2( + "div", + { className: "dim-directoryPickerState" }, + h2("span", { className: "dim-directoryPickerSpinner", "aria-hidden": "true" }), + h2("p", null, "\u6B63\u5728\u8BFB\u53D6\u76EE\u5F55\u2026") + ) : listing ? entries.length > 0 ? h2("ul", { className: "dim-directoryList" }, entries.map((entry) => h2( + "li", + { key: entry.path }, + React4.createElement( + "button", + { + type: "button", + title: entry.path, + disabled: loading || busy, + onClick: () => void loadDirectory(entry.path) + }, + h2("span", { className: "dim-directoryFolder" }, h2(FolderIcon)), + React4.createElement("span", { className: "dim-directoryName" }, entry.name), + h2("span", { className: "dim-directoryChevron" }, h2(ChevronIcon)) + ) + ))) : h2( + "div", + { className: "dim-directoryPickerState" }, + h2("p", null, "\u8FD9\u4E2A\u76EE\u5F55\u4E2D\u6CA1\u6709\u5B50\u6587\u4EF6\u5939\u3002") + ) : null, + listing?.truncated ? h2("p", { className: "dim-directoryPickerTruncated" }, "\u6B64\u76EE\u5F55\u7684\u5B50\u6587\u4EF6\u5939\u8FC7\u591A\uFF0C\u4EC5\u663E\u793A\u524D\u4E00\u90E8\u5206\u3002") : null, + presentedError ? h2( + "div", + { className: "dim-directoryPickerError", role: "alert" }, + h2("span", null, presentedError), + !listing && !busy ? h2("button", { + type: "button", + onClick: () => setRetryKey((value) => value + 1) + }, "\u91CD\u8BD5") : null + ) : null + ), + h2( + "footer", + { className: "dim-directoryPickerFooter" }, + h2( + "button", + { + type: "button", + className: "dim-directoryHidden", + "aria-pressed": showHidden, + onClick: () => setShowHidden((value) => !value), + disabled: busy || !listing + }, + h2("span", { className: "dim-directoryHiddenBox", "aria-hidden": "true" }), + h2("span", null, "\u663E\u793A\u9690\u85CF\u6587\u4EF6\u5939") + ), + h2("p", { id: noticeId, className: "dim-directoryPickerNotice" }, "\u5207\u6362\u540E\u4F1A\u6E05\u9664\u8FD9\u4E2A\u673A\u5668\u4EBA\u7684\u65E7\u4F1A\u8BDD\u6620\u5C04\u3002"), + h2( + "div", + { className: "dim-directoryPickerActions" }, + h2("button", { type: "button", onClick: onCancel, disabled: busy }, "\u53D6\u6D88"), + h2("button", { + type: "button", + className: "dim-directoryPickerPrimary", + disabled: busy || loading || !listing, + onClick: () => listing && void onPicked(listing.path) + }, busy ? "\u5207\u6362\u4E2D\u2026" : "\u9009\u62E9\u6B64\u76EE\u5F55") + ) + ) + ) + ); + return typeof document === "undefined" ? content : (0, import_react_dom.createPortal)(content, document.body); +} + +// plugin-src/client/workspace-editor.js +var WorkspaceDirectoryPickerContext = React5.createContext(null); +function WorkspaceEditor({ workspace, directoryPicker, disabled = false, onSave }) { + const sharedDirectoryPicker = React5.useContext(WorkspaceDirectoryPickerContext); + const activeDirectoryPicker = directoryPicker ?? sharedDirectoryPicker; + const [open, setOpen] = React5.useState(false); + const [saving, setSaving] = React5.useState(false); + const [error, setError] = React5.useState(null); + const editButtonRef = React5.useRef(null); + const savingRef = React5.useRef(false); + const close = React5.useCallback(() => { + setOpen(false); + setError(null); + queueMicrotask(() => editButtonRef.current?.focus?.()); + }, []); + const pick = React5.useCallback(async (value) => { + if (!value || savingRef.current || disabled) return; + if (value === workspace) { + close(); return; } + savingRef.current = true; setSaving(true); setError(null); try { await onSave?.(value); - restoreFocus.current = true; - setEditing(false); + close(); } catch (cause) { setError(cause?.message ?? "\u5DE5\u4F5C\u533A\u4FEE\u6539\u5931\u8D25\uFF0C\u8BF7\u91CD\u8BD5\u3002"); } finally { + savingRef.current = false; setSaving(false); } - }; + }, [close, disabled, onSave, workspace]); return h2( "div", { className: "dim-workspace" }, @@ -1018,55 +1276,38 @@ function WorkspaceEditor({ workspace, disabled = false, onSave }) { "div", { className: "dim-workspaceHeader" }, h2("span", null, "\u5F53\u524D\u5DE5\u4F5C\u533A"), - editing ? null : h2("button", { + h2("button", { type: "button", ref: editButtonRef, className: "dim-workspaceEdit", onClick: () => { - setEditing(true); + setOpen(true); setError(null); }, - disabled - }, "\u4FEE\u6539") + disabled: disabled || !activeDirectoryPicker + }, "\u9009\u62E9\u76EE\u5F55") ), - editing ? h2( - "form", - { className: "dim-workspaceForm", onSubmit: submit }, - h2("input", { - ref: inputRef, - value: draft, - onChange: (event) => setDraft(event.target.value), - placeholder: "/\u7EDD\u5BF9\u8DEF\u5F84/\u5230/\u5DE5\u4F5C\u533A", - "aria-label": "\u5DE5\u4F5C\u533A\u7EDD\u5BF9\u8DEF\u5F84", - autoCapitalize: "none", - autoCorrect: "off", - spellCheck: false, - maxLength: 4096, - required: true, - disabled: saving || disabled - }), - h2( - "div", - { className: "dim-workspaceActions" }, - h2("button", { - type: "submit", - disabled: saving || disabled || !draft.trim() - }, saving ? "\u4FDD\u5B58\u4E2D\u2026" : "\u4FDD\u5B58"), - h2("button", { type: "button", onClick: cancel, disabled: saving || disabled }, "\u53D6\u6D88") - ), - error ? h2("p", { className: "dim-workspaceError", role: "alert" }, error) : null - ) : workspace ? React4.createElement("code", { + workspace ? React5.createElement("code", { className: "dim-workspacePath", title: workspace - }, workspace) : h2("code", { className: "dim-workspacePath" }, "\u672A\u8BBE\u7F6E") + }, workspace) : h2("code", { className: "dim-workspacePath" }, "\u672A\u8BBE\u7F6E"), + open ? h2(WorkspaceDirectoryPicker, { + open, + startPath: workspace, + picker: activeDirectoryPicker, + busy: saving || disabled, + saveError: error, + onPicked: pick, + onCancel: close + }) : null ); } // plugin-src/client/workspace-snapshot-fence.js -var React5 = __toESM(require("react"), 1); +var React6 = __toESM(require("react"), 1); function useWorkspaceSnapshotFence() { - const state = React5.useRef({ version: 0, pendingMutations: 0 }); - return React5.useMemo(() => Object.freeze({ + const state = React6.useRef({ version: 0, pendingMutations: 0 }); + return React6.useMemo(() => Object.freeze({ beginStatus() { return state.current.pendingMutations === 0 ? state.current.version : null; }, @@ -1246,7 +1487,7 @@ function DingtalkIcon({ size = 28 }) { d: "M37.05 22.783c-6.758-5.216-14.378-12.128-22.73-19.538-.655-.585-1.242-.354-1.536.42-1.88 4.973-.058 9.386 2.889 11.932s7.368 4.912 10.058 6.155c.105.049.013.203-.093.163-4.953-2.182-8.397-3.765-13.07-7.368-.497-.388-1.01-.242-1.07.521-.384 4.748 2.657 8.483 6.058 9.745 2.1.781 4.398 1.212 6.53 1.474.109.015.084.178-.027.178-2.747.01-6.058-.654-8.935-1.751-.606-.233-.818.25-.722.633.491 2.008 2.974 5.076 6.926 5.73a12 12 0 0 0 2.228.115c.164 0 .208.089.154.217q-2.685 4.6-2.803 4.797c-.091.152-.036.275.156.275h3.543c.164 0 .264.106.18.246l-4.958 8.196c-.191.328.035.565.395.301s15.212-11.133 15.636-11.448c.195-.142.148-.327-.124-.327h-3.18c-.206 0-.252-.14-.111-.28.14-.141 3.602-3.594 4.837-4.888 1.283-1.35 1.938-3.825-.231-5.498" })); } -var Button = React6.forwardRef(function Button2({ children, kind = "secondary", className = "", ...props }, ref) { +var Button = React7.forwardRef(function Button2({ children, kind = "secondary", className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -1342,13 +1583,13 @@ function EmptyView({ busy, onStart }) { ); } function QrPanel({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React6.useState(false); + const [imageFailed, setImageFailed] = React7.useState(false); const source = safeQrSource(provision.qrCodeDataUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = remaining === 0 || provision.status === "expired"; const duration = Math.max(1, provision.durationMs ?? 10 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); - React6.useEffect(() => setImageFailed(false), [source]); + React7.useEffect(() => setImageFailed(false), [source]); return h2( "div", { className: "ddt-card dim-surfaceCard" }, @@ -1465,8 +1706,8 @@ function checkedTime(value) { } } function RemoveConfirmation({ account, busy, onConfirm, onCancel }) { - const cancelRef = React6.useRef(null); - React6.useEffect(() => cancelRef.current?.focus(), []); + const cancelRef = React7.useRef(null); + React7.useEffect(() => cancelRef.current?.focus(), []); return h2( "div", { @@ -1608,28 +1849,28 @@ function AccountList(props) { } var EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 }); function DingtalkSettingsTab({ rpcCall }) { - const [model, setModel] = React6.useState({ + const [model, setModel] = React7.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS, revision: 0, error: null }); - const [provision, setProvision] = React6.useState(null); - const [busy, setBusy] = React6.useState(false); - const [busyByBot, setBusyByBot] = React6.useState({}); - const [removeTarget, setRemoveTarget] = React6.useState(null); - const [credentialOpen, setCredentialOpen] = React6.useState(false); - const [credentialError, setCredentialError] = React6.useState(null); - const [notice, setNotice] = React6.useState(""); - const [now, setNow] = React6.useState(() => Date.now()); - const addButtonRef = React6.useRef(null); - const mountedRef = React6.useRef(true); - const statusRequestRef = React6.useRef(0); + const [provision, setProvision] = React7.useState(null); + const [busy, setBusy] = React7.useState(false); + const [busyByBot, setBusyByBot] = React7.useState({}); + const [removeTarget, setRemoveTarget] = React7.useState(null); + const [credentialOpen, setCredentialOpen] = React7.useState(false); + const [credentialError, setCredentialError] = React7.useState(null); + const [notice, setNotice] = React7.useState(""); + const [now, setNow] = React7.useState(() => Date.now()); + const addButtonRef = React7.useRef(null); + const mountedRef = React7.useRef(true); + const statusRequestRef = React7.useRef(0); const workspaceFence = useWorkspaceSnapshotFence(); - const noticeFrameRef = React6.useRef(null); - const focusFrameRef = React6.useRef(null); - React6.useEffect(() => { + const noticeFrameRef = React7.useRef(null); + const focusFrameRef = React7.useRef(null); + React7.useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; @@ -1644,8 +1885,8 @@ function DingtalkSettingsTab({ rpcCall }) { } }; }, []); - React6.useEffect(() => installDingtalkStyles(), []); - const announce = React6.useCallback((message) => { + React7.useEffect(() => installDingtalkStyles(), []); + const announce = React7.useCallback((message) => { if (!mountedRef.current) return; if (noticeFrameRef.current !== null) { window.cancelAnimationFrame(noticeFrameRef.current); @@ -1659,7 +1900,7 @@ function DingtalkSettingsTab({ rpcCall }) { }); } }, []); - const focusAddButton = React6.useCallback(() => { + const focusAddButton = React7.useCallback(() => { if (!mountedRef.current) return; if (focusFrameRef.current !== null) window.cancelAnimationFrame(focusFrameRef.current); focusFrameRef.current = window.requestAnimationFrame(() => { @@ -1667,11 +1908,11 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) addButtonRef.current?.focus(); }); }, []); - const invoke = React6.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React7.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("\u9489\u9489\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React6.useCallback(async ({ + const loadStatus = React7.useCallback(async ({ signal, silent = false, restoreProvisioning = false @@ -1713,12 +1954,12 @@ function DingtalkSettingsTab({ rpcCall }) { return void 0; } }, [invoke, workspaceFence]); - React6.useEffect(() => { + React7.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React6.useEffect(() => { + React7.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; @@ -1737,14 +1978,14 @@ function DingtalkSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React6.useEffect(() => { + React7.useEffect(() => { if (!provision || !ACTIVE_PROVISION_STATES.has(provision.status)) return void 0; const timer = window.setInterval(() => { if (mountedRef.current) setNow(Date.now()); }, 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React6.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React7.useCallback(async ({ replace = false } = {}) => { if (!mountedRef.current) return; setCredentialOpen(false); setCredentialError(null); @@ -1782,7 +2023,7 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const bindCredentials = React6.useCallback(async ({ identity, secret }) => { + const bindCredentials = React7.useCallback(async ({ identity, secret }) => { if (!mountedRef.current) return; const snapshotVersion = workspaceFence.beginMutation(); setBusy(true); @@ -1812,7 +2053,7 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBusy(false); } }, [announce, invoke, loadStatus, workspaceFence]); - const cancelProvisioning = React6.useCallback(async () => { + const cancelProvisioning = React7.useCallback(async () => { if (!mountedRef.current) return; setBusy(true); try { @@ -1830,7 +2071,7 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBusy(false); } }, [announce, focusAddButton, invoke, provision?.attemptId, provision?.status]); - React6.useEffect(() => { + React7.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_PROVISION_STATES.has(provision.status)) return void 0; const controller = new AbortController(); @@ -1889,7 +2130,7 @@ function DingtalkSettingsTab({ rpcCall }) { timer = null; }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const setBotBusy = React6.useCallback((botId, operation) => { + const setBotBusy = React7.useCallback((botId, operation) => { if (!mountedRef.current) return; setBusyByBot((current) => { const next = { ...current }; @@ -1898,7 +2139,7 @@ function DingtalkSettingsTab({ rpcCall }) { return next; }); }, []); - const runBotAction = React6.useCallback(async ({ account, operation, endpoint, payload, success }) => { + const runBotAction = React7.useCallback(async ({ account, operation, endpoint, payload, success }) => { if (!mountedRef.current) return void 0; const snapshotVersion = workspaceFence.beginMutation(); setBotBusy(account.botId, operation); @@ -1926,14 +2167,14 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBotBusy(account.botId, null); } }, [announce, invoke, loadStatus, setBotBusy, workspaceFence]); - const reconnect = React6.useCallback((account) => runBotAction({ + const reconnect = React7.useCallback((account) => runBotAction({ account, operation: "reconnect", endpoint: DINGTALK_ENDPOINTS.reconnectBot, payload: { botId: account.botId }, success: (snapshot) => snapshot?.bots.find((bot) => bot.botId === account.botId)?.connected ? "\u9489\u9489\u8FDE\u63A5\u68C0\u67E5\u5B8C\u6210\u3002" : "\u9489\u9489\u4ECD\u672A\u8FDE\u63A5\uFF0C\u63D2\u4EF6\u4F1A\u7EE7\u7EED\u81EA\u52A8\u91CD\u8BD5\u3002" }), [runBotAction]); - const saveWorkspace = React6.useCallback(async (account, workspace) => { + const saveWorkspace = React7.useCallback(async (account, workspace) => { const workspaceVersion = workspaceFence.beginMutation(); setBotBusy(account.botId, "workspace"); try { @@ -1956,7 +2197,7 @@ function DingtalkSettingsTab({ rpcCall }) { if (mountedRef.current) setBotBusy(account.botId, null); } }, [invoke, loadStatus, setBotBusy, workspaceFence]); - const remove = React6.useCallback(async (account) => { + const remove = React7.useCallback(async (account) => { const snapshot = await runBotAction({ account, operation: "delete", @@ -2038,7 +2279,7 @@ function DingtalkSettingsTab({ rpcCall }) { h2(Button, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) ) : h2( - React6.Fragment, + React7.Fragment, null, credentialView, provisionView, @@ -2148,8 +2389,8 @@ var normalizeSnapshot2 = api.normalizeSnapshot; var presentError2 = api.presentError; // plugin-src/client/channels/shared/token-channel.js -var React7 = __toESM(require("react"), 1); -var Button3 = React7.forwardRef(function Button4({ children, kind = "secondary", className = "", ...props }, ref) { +var React8 = __toESM(require("react"), 1); +var Button3 = React8.forwardRef(function Button4({ children, kind = "secondary", className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -2290,20 +2531,20 @@ function createTokenChannelSettings(definition) { ); } function SettingsTab({ rpcCall }) { - const [model, setModel] = React7.useState({ + const [model, setModel] = React8.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [credentialOpen, setCredentialOpen] = React7.useState(false); - const [credentialError, setCredentialError] = React7.useState(null); - const [busy, setBusy] = React7.useState(false); - const [busyByBot, setBusyByBot] = React7.useState({}); - const [removeTarget, setRemoveTarget] = React7.useState(null); - const mounted = React7.useRef(true); + const [credentialOpen, setCredentialOpen] = React8.useState(false); + const [credentialError, setCredentialError] = React8.useState(null); + const [busy, setBusy] = React8.useState(false); + const [busyByBot, setBusyByBot] = React8.useState({}); + const [removeTarget, setRemoveTarget] = React8.useState(null); + const mounted = React8.useRef(true); const workspaceFence = useWorkspaceSnapshotFence(); - React7.useEffect(() => { + React8.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); const disposeChannel = installStyles(); mounted.current = true; @@ -2313,11 +2554,11 @@ function createTokenChannelSettings(definition) { disposeDingtalk(); }; }, []); - const invoke = React7.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React8.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError(`${channel4} \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5`); return api4.unwrapRpcResult(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React7.useCallback(async ({ signal, silent = false } = {}) => { + const loadStatus = React8.useCallback(async ({ signal, silent = false } = {}) => { const workspaceVersion = workspaceFence.beginStatus(); if (workspaceVersion === null) return; if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); @@ -2335,12 +2576,12 @@ function createTokenChannelSettings(definition) { } } }, [invoke, workspaceFence]); - React7.useEffect(() => { + React8.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal }); return () => controller.abort(); }, [loadStatus]); - React7.useEffect(() => { + React8.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); const timer = window.setInterval( @@ -2352,7 +2593,7 @@ function createTokenChannelSettings(definition) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - const bindCredentials = React7.useCallback(async (values) => { + const bindCredentials = React8.useCallback(async (values) => { const snapshotVersion = workspaceFence.beginMutation(); setBusy(true); setCredentialError(null); @@ -2374,7 +2615,7 @@ function createTokenChannelSettings(definition) { if (mounted.current) setBusy(false); } }, [invoke, loadStatus, workspaceFence]); - const botAction = React7.useCallback(async (account, operation, endpoint, payload) => { + const botAction = React8.useCallback(async (account, operation, endpoint, payload) => { const snapshotVersion = workspaceFence.beginMutation(); setBusyByBot((current) => ({ ...current, [account.botId]: operation })); try { @@ -2475,7 +2716,7 @@ function createTokenChannelSettings(definition) { h2(Button3, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) ) : h2( - React7.Fragment, + React8.Fragment, null, credentialOpen ? CredentialPanel ? h2(CredentialPanel, { busy, @@ -2576,7 +2817,7 @@ var DiscordSettingsTab = channel.SettingsTab; var DiscordAccountCard = channel.AccountCard; // plugin-src/client/channels/feishu/index.js -var React9 = __toESM(require("react"), 1); +var React10 = __toESM(require("react"), 1); // plugin-src/client/channels/feishu/api.js var FEISHU_RPC_CHANNEL = "/feishu"; @@ -2796,7 +3037,7 @@ function formatRemaining2(milliseconds) { } // plugin-src/client/lifecycle.js -var React8 = __toESM(require("react"), 1); +var React9 = __toESM(require("react"), 1); function createPollScheduler({ setTimeoutFn, clearTimeoutFn }) { let disposed = false; let timer; @@ -2858,8 +3099,8 @@ function createAnimationFrameScheduler({ requestFrame, cancelFrame }) { }; } function useAnimationFrameScheduler() { - const schedulerRef = React8.useRef(null); - React8.useEffect(() => { + const schedulerRef = React9.useRef(null); + React9.useEffect(() => { const scheduler = createAnimationFrameScheduler({ requestFrame: (callback) => window.requestAnimationFrame(callback), cancelFrame: (frame) => window.cancelAnimationFrame(frame) @@ -2870,7 +3111,7 @@ function useAnimationFrameScheduler() { if (schedulerRef.current === scheduler) schedulerRef.current = null; }; }, []); - return React8.useCallback( + return React9.useCallback( (callback, key) => schedulerRef.current?.schedule(callback, key) ?? false, [] ); @@ -3438,7 +3679,7 @@ function QrIcon({ size = 58 }) { fill: "currentColor" })); } -var Button5 = React9.forwardRef(function Button6({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { +var Button5 = React10.forwardRef(function Button6({ children, kind = "secondary", size, icon, className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -3550,13 +3791,13 @@ function safeQrSource2(value) { return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value) ? value : void 0; } function QrPane({ provision, now, onRefresh, onCancel, busy }) { - const [imageFailed, setImageFailed] = React9.useState(false); + const [imageFailed, setImageFailed] = React10.useState(false); const qrSource = safeQrSource2(provision.qrCodeDataUrl); const href = safeVerificationHref(provision.verificationUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = provision.expired === true || remaining === 0; const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining)); - React9.useEffect(() => setImageFailed(false), [qrSource]); + React10.useEffect(() => setImageFailed(false), [qrSource]); return h2( "div", { className: "bxf-card bxf-provisionCard dim-surfaceCard" }, @@ -3706,11 +3947,11 @@ function formatCheckedTime(timestamp7) { } } function RemoveConfirmation2({ bot, busy, onConfirm, onCancel }) { - const cancelRef = React9.useRef(null); + const cancelRef = React10.useRef(null); const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-"); const titleId = `bxf-remove-title-${idPart}`; const descriptionId = `bxf-remove-description-${idPart}`; - React9.useEffect(() => cancelRef.current?.focus(), []); + React10.useEffect(() => cancelRef.current?.focus(), []); return h2( "div", { @@ -3938,7 +4179,7 @@ function mergeFeishuSnapshotState(current, snapshot, { restoreProvisioning = fal }; } function FeishuSettingsTab({ rpcCall }) { - const [model, setModel] = React9.useState({ + const [model, setModel] = React10.useState({ phase: "loading", revision: 0, bots: [], @@ -3947,39 +4188,39 @@ function FeishuSettingsTab({ rpcCall }) { pageError: null, statusError: null }); - const [pageBusy, setPageBusy] = React9.useState(false); - const [provisionBusy, setProvisionBusy] = React9.useState(false); - const [credentialOpen, setCredentialOpen] = React9.useState(false); - const [credentialBusy, setCredentialBusy] = React9.useState(false); - const [credentialError, setCredentialError] = React9.useState(null); - const [busyByBot, setBusyByBot] = React9.useState({}); - const [errorsByBot, setErrorsByBot] = React9.useState({}); - const [removeTargetId, setRemoveTargetId] = React9.useState(null); - const [announcement, setAnnouncement] = React9.useState(""); - const [now, setNow] = React9.useState(() => Date.now()); - const [focusBotId, setFocusBotId] = React9.useState(null); - const cardRefs = React9.useRef(/* @__PURE__ */ new Map()); - const removeButtonRefs = React9.useRef(/* @__PURE__ */ new Map()); - const addButtonRef = React9.useRef(null); - const mountedRef = React9.useRef(true); + const [pageBusy, setPageBusy] = React10.useState(false); + const [provisionBusy, setProvisionBusy] = React10.useState(false); + const [credentialOpen, setCredentialOpen] = React10.useState(false); + const [credentialBusy, setCredentialBusy] = React10.useState(false); + const [credentialError, setCredentialError] = React10.useState(null); + const [busyByBot, setBusyByBot] = React10.useState({}); + const [errorsByBot, setErrorsByBot] = React10.useState({}); + const [removeTargetId, setRemoveTargetId] = React10.useState(null); + const [announcement, setAnnouncement] = React10.useState(""); + const [now, setNow] = React10.useState(() => Date.now()); + const [focusBotId, setFocusBotId] = React10.useState(null); + const cardRefs = React10.useRef(/* @__PURE__ */ new Map()); + const removeButtonRefs = React10.useRef(/* @__PURE__ */ new Map()); + const addButtonRef = React10.useRef(null); + const mountedRef = React10.useRef(true); const workspaceFence = useWorkspaceSnapshotFence(); const scheduleAnimationFrame = useAnimationFrameScheduler(); - React9.useEffect(() => { + React10.useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); - const announce = React9.useCallback((message) => { + const announce = React10.useCallback((message) => { setAnnouncement(""); scheduleAnimationFrame(() => { if (message) setAnnouncement(message); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React9.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React10.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult3(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const mergeSnapshot = React9.useCallback((snapshot, { restoreProvisioning = false } = {}) => { + const mergeSnapshot = React10.useCallback((snapshot, { restoreProvisioning = false } = {}) => { const now2 = Date.now(); setModel((current) => mergeFeishuSnapshotState( current, @@ -3987,7 +4228,7 @@ function FeishuSettingsTab({ rpcCall }) { { restoreProvisioning, now: now2 } )); }, []); - const loadStatus = React9.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { + const loadStatus = React10.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => { const workspaceVersion = workspaceFence.beginStatus(); if (workspaceVersion === null || !mountedRef.current) return void 0; if (!silent) setPageBusy(true); @@ -4005,12 +4246,12 @@ function FeishuSettingsTab({ rpcCall }) { if (!silent && !signal?.aborted && mountedRef.current) setPageBusy(false); } }, [invoke, mergeSnapshot, workspaceFence]); - React9.useEffect(() => { + React10.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React9.useEffect(() => { + React10.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let inFlight = false; @@ -4029,7 +4270,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React9.useEffect(() => { + React10.useEffect(() => { if (!focusBotId) return; const node = cardRefs.current.get(focusBotId); if (!node) return; @@ -4037,7 +4278,7 @@ function FeishuSettingsTab({ rpcCall }) { node.focus({ preventScroll: true }); setFocusBotId(null); }, [focusBotId, model.bots]); - const startProvisioning = React9.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React10.useCallback(async ({ replace = false } = {}) => { setCredentialOpen(false); setCredentialError(null); setProvisionBusy(true); @@ -4077,7 +4318,7 @@ function FeishuSettingsTab({ rpcCall }) { setProvisionBusy(false); } }, [announce, invoke, model.provisioning?.attemptId]); - const bindCredentials = React9.useCallback(async ({ identity, secret }) => { + const bindCredentials = React10.useCallback(async ({ identity, secret }) => { const snapshotVersion = workspaceFence.beginMutation(); setCredentialBusy(true); setCredentialError(null); @@ -4099,7 +4340,7 @@ function FeishuSettingsTab({ rpcCall }) { setCredentialBusy(false); } }, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]); - const cancelProvisioning = React9.useCallback(async () => { + const cancelProvisioning = React10.useCallback(async () => { const attemptId = model.provisioning?.attemptId; setProvisionBusy(true); try { @@ -4121,7 +4362,7 @@ function FeishuSettingsTab({ rpcCall }) { const countdownPhase = model.provisioning?.phase; const countdownExpiresAt = model.provisioning?.expiresAt; const countdownExpired = model.provisioning?.expired; - React9.useEffect(() => { + React10.useEffect(() => { if (!countdownAttemptId || countdownPhase !== "qr" || countdownExpired) return void 0; const tick = () => { const timestamp7 = Date.now(); @@ -4134,7 +4375,7 @@ function FeishuSettingsTab({ rpcCall }) { const timer = window.setInterval(tick, 1e3); return () => window.clearInterval(timer); }, [countdownAttemptId, countdownPhase, countdownExpiresAt, countdownExpired]); - React9.useEffect(() => { + React10.useEffect(() => { const provision2 = model.provisioning; if (!provision2 || !["qr", "connecting"].includes(provision2.phase) || !provision2.attemptId || provision2.expired) return void 0; const controller = new AbortController(); @@ -4198,7 +4439,7 @@ function FeishuSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [announce, invoke, loadStatus, model.provisioning]); - const setBotBusy = React9.useCallback((botId, value) => { + const setBotBusy = React10.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -4206,7 +4447,7 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const setBotError = React9.useCallback((botId, error) => { + const setBotError = React10.useCallback((botId, error) => { setErrorsByBot((current) => { const next = { ...current }; if (error) next[botId] = presentError3(error); @@ -4214,7 +4455,7 @@ function FeishuSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnectOneBot = React9.useCallback(async (connection) => { + const reconnectOneBot = React10.useCallback(async (connection) => { const { botId, bot } = connection; const snapshotVersion = workspaceFence.beginMutation(); setBotBusy(botId, "reconnect"); @@ -4242,7 +4483,7 @@ function FeishuSettingsTab({ rpcCall }) { setBotBusy(botId, null); } }, [announce, invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]); - const saveWorkspace = React9.useCallback(async (connection, workspace) => { + const saveWorkspace = React10.useCallback(async (connection, workspace) => { const { botId } = connection; const workspaceVersion = workspaceFence.beginMutation(); setBotBusy(botId, "workspace"); @@ -4261,15 +4502,15 @@ function FeishuSettingsTab({ rpcCall }) { if (mountedRef.current) setBotBusy(botId, null); } }, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]); - const requestRemove = React9.useCallback((connection) => { + const requestRemove = React10.useCallback((connection) => { setRemoveTargetId(connection.botId); }, []); - const cancelRemove = React9.useCallback(() => { + const cancelRemove = React10.useCallback(() => { const botId = removeTargetId; setRemoveTargetId(null); scheduleAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus(), "focus"); }, [removeTargetId, scheduleAnimationFrame]); - const confirmRemove = React9.useCallback(async (connection) => { + const confirmRemove = React10.useCallback(async (connection) => { const { botId, bot } = connection; const snapshotVersion = workspaceFence.beginMutation(); setBotBusy(botId, "delete"); @@ -4334,11 +4575,11 @@ function FeishuSettingsTab({ rpcCall }) { setCredentialError(null); } }) : null; - const setCardRef = React9.useCallback((botId, node) => { + const setCardRef = React10.useCallback((botId, node) => { if (node) cardRefs.current.set(botId, node); else cardRefs.current.delete(botId); }, []); - const setRemoveButtonRef = React9.useCallback((botId, node) => { + const setRemoveButtonRef = React10.useCallback((botId, node) => { if (node) removeButtonRefs.current.set(botId, node); else removeButtonRefs.current.delete(botId); }, []); @@ -4375,7 +4616,7 @@ function FeishuSettingsTab({ rpcCall }) { onRetry: () => void loadStatus(), busy: pageBusy }) : h2( - React9.Fragment, + React10.Fragment, null, credentialContent, provisionContent, @@ -4507,7 +4748,7 @@ function formatRemaining3(milliseconds) { } // plugin-src/client/channels/qq/index.js -var React10 = __toESM(require("react"), 1); +var React11 = __toESM(require("react"), 1); // plugin-src/client/channels/qq/styles.js var QQ_STYLE_ID = "xmanrui-dsh-im-qq-settings"; @@ -4532,7 +4773,7 @@ function installQqStyles() { // plugin-src/client/channels/qq/index.js var ACTIVE_STATES = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); -var Button7 = React10.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { +var Button7 = React11.forwardRef(function Button8({ children, kind = "secondary", className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -4811,18 +5052,18 @@ function AccountCard2({ ); } function QqSettingsTab({ rpcCall }) { - const [model, setModel] = React10.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [provision, setProvision] = React10.useState(null); - const [busy, setBusy] = React10.useState(false); - const [busyByBot, setBusyByBot] = React10.useState({}); - const [removeTarget, setRemoveTarget] = React10.useState(null); - const [credentialOpen, setCredentialOpen] = React10.useState(false); - const [credentialError, setCredentialError] = React10.useState(null); - const [now, setNow] = React10.useState(Date.now()); - const mounted = React10.useRef(true); + const [model, setModel] = React11.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React11.useState(null); + const [busy, setBusy] = React11.useState(false); + const [busyByBot, setBusyByBot] = React11.useState({}); + const [removeTarget, setRemoveTarget] = React11.useState(null); + const [credentialOpen, setCredentialOpen] = React11.useState(false); + const [credentialError, setCredentialError] = React11.useState(null); + const [now, setNow] = React11.useState(Date.now()); + const mounted = React11.useRef(true); const workspaceFence = useWorkspaceSnapshotFence(); - const addButtonRef = React10.useRef(null); - React10.useEffect(() => { + const addButtonRef = React11.useRef(null); + React11.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); const disposeQq = installQqStyles(); mounted.current = true; @@ -4832,11 +5073,11 @@ function QqSettingsTab({ rpcCall }) { disposeDingtalk(); }; }, []); - const invoke = React10.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React11.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("QQ \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult4(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React10.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + const loadStatus = React11.useCallback(async ({ signal, silent = false, restore = false } = {}) => { const workspaceVersion = workspaceFence.beginStatus(); if (workspaceVersion === null) return void 0; if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); @@ -4856,12 +5097,12 @@ function QqSettingsTab({ rpcCall }) { return void 0; } }, [invoke, workspaceFence]); - React10.useEffect(() => { + React11.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restore: true }); return () => controller.abort(); }, [loadStatus]); - React10.useEffect(() => { + React11.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3); @@ -4870,12 +5111,12 @@ function QqSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React10.useEffect(() => { + React11.useEffect(() => { if (!provision || !ACTIVE_STATES.has(provision.status)) return void 0; const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React10.useCallback(async (replace = false) => { + const startProvisioning = React11.useCallback(async (replace = false) => { setCredentialOpen(false); setCredentialError(null); setBusy(true); @@ -4893,7 +5134,7 @@ function QqSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId]); - const bindCredentials = React10.useCallback(async ({ identity, secret }) => { + const bindCredentials = React11.useCallback(async ({ identity, secret }) => { const snapshotVersion = workspaceFence.beginMutation(); setBusy(true); setCredentialError(null); @@ -4915,7 +5156,7 @@ function QqSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, loadStatus, workspaceFence]); - const closeProvision = React10.useCallback(async () => { + const closeProvision = React11.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && ACTIVE_STATES.has(provision.status)) { @@ -4926,7 +5167,7 @@ function QqSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId, provision?.status]); - React10.useEffect(() => { + React11.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_STATES.has(provision.status)) return void 0; const controller = new AbortController(); @@ -4956,7 +5197,7 @@ function QqSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const botAction = React10.useCallback(async (account, operation, endpoint, payload) => { + const botAction = React11.useCallback(async (account, operation, endpoint, payload) => { const snapshotVersion = workspaceFence.beginMutation(); setBusyByBot((current) => ({ ...current, [account.botId]: operation })); try { @@ -5047,7 +5288,7 @@ function QqSettingsTab({ rpcCall }) { addButtonRef }), model.phase === "loading" ? h2(LoadingView3) : model.phase === "error" ? h2("div", { className: "ddt-card dim-surfaceCard" }, h2("div", { className: "ddt-inlineError dim-inlineError" }, h2("h3", null, "\u65E0\u6CD5\u8BFB\u53D6 QQ \u673A\u5668\u4EBA\u72B6\u6001"), h2("p", null, model.error?.message), h2(Button7, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h2( - React10.Fragment, + React11.Fragment, null, credentialView, provisionView, @@ -5066,7 +5307,7 @@ var normalizeSnapshot4 = api2.normalizeSnapshot; var presentError5 = api2.presentError; // plugin-src/client/channels/slack/index.js -var React11 = __toESM(require("react"), 1); +var React12 = __toESM(require("react"), 1); // src/channels/slack/manifest.mjs var SLACK_APP_MANIFEST_YAML = `_metadata: @@ -5138,10 +5379,10 @@ function installSlackStyles() { // plugin-src/client/channels/slack/index.js function SlackCredentialPanel({ busy, error, onSubmit, onCancel }) { - const [botToken, setBotToken] = React11.useState(""); - const [appToken, setAppToken] = React11.useState(""); - const [copied, setCopied] = React11.useState(false); - const headingId = React11.useId(); + const [botToken, setBotToken] = React12.useState(""); + const [appToken, setAppToken] = React12.useState(""); + const [copied, setCopied] = React12.useState(false); + const headingId = React12.useId(); const copyManifest = async () => { try { await navigator.clipboard.writeText(SLACK_APP_MANIFEST_YAML); @@ -5435,7 +5676,7 @@ function formatRemaining4(milliseconds) { } // plugin-src/client/channels/wecom/index.js -var React12 = __toESM(require("react"), 1); +var React13 = __toESM(require("react"), 1); // plugin-src/client/channels/wecom/styles.js var WECOM_STYLE_ID = "xmanrui-dsh-im-wecom-settings"; @@ -5460,7 +5701,7 @@ function installWecomStyles() { // plugin-src/client/channels/wecom/index.js var ACTIVE_STATES2 = /* @__PURE__ */ new Set(["pending", "refreshing", "connecting"]); -var Button9 = React12.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { +var Button9 = React13.forwardRef(function Button10({ children, kind = "secondary", className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -5739,18 +5980,18 @@ function AccountCard3({ ); } function WecomSettingsTab({ rpcCall }) { - const [model, setModel] = React12.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [provision, setProvision] = React12.useState(null); - const [busy, setBusy] = React12.useState(false); - const [busyByBot, setBusyByBot] = React12.useState({}); - const [removeTarget, setRemoveTarget] = React12.useState(null); - const [credentialOpen, setCredentialOpen] = React12.useState(false); - const [credentialError, setCredentialError] = React12.useState(null); - const [now, setNow] = React12.useState(Date.now()); - const mounted = React12.useRef(true); + const [model, setModel] = React13.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); + const [provision, setProvision] = React13.useState(null); + const [busy, setBusy] = React13.useState(false); + const [busyByBot, setBusyByBot] = React13.useState({}); + const [removeTarget, setRemoveTarget] = React13.useState(null); + const [credentialOpen, setCredentialOpen] = React13.useState(false); + const [credentialError, setCredentialError] = React13.useState(null); + const [now, setNow] = React13.useState(Date.now()); + const mounted = React13.useRef(true); const workspaceFence = useWorkspaceSnapshotFence(); - const addButtonRef = React12.useRef(null); - React12.useEffect(() => { + const addButtonRef = React13.useRef(null); + React13.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); const disposeWecom = installWecomStyles(); mounted.current = true; @@ -5760,11 +6001,11 @@ function WecomSettingsTab({ rpcCall }) { disposeDingtalk(); }; }, []); - const invoke = React12.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React13.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("\u4F01\u4E1A\u5FAE\u4FE1\u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult7(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React12.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + const loadStatus = React13.useCallback(async ({ signal, silent = false, restore = false } = {}) => { const workspaceVersion = workspaceFence.beginStatus(); if (workspaceVersion === null) return void 0; if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); @@ -5784,12 +6025,12 @@ function WecomSettingsTab({ rpcCall }) { return void 0; } }, [invoke, workspaceFence]); - React12.useEffect(() => { + React13.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restore: true }); return () => controller.abort(); }, [loadStatus]); - React12.useEffect(() => { + React13.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15e3); @@ -5798,12 +6039,12 @@ function WecomSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React12.useEffect(() => { + React13.useEffect(() => { if (!provision || !ACTIVE_STATES2.has(provision.status)) return void 0; const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React12.useCallback(async (replace = false) => { + const startProvisioning = React13.useCallback(async (replace = false) => { setCredentialOpen(false); setCredentialError(null); setBusy(true); @@ -5821,7 +6062,7 @@ function WecomSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId]); - const bindCredentials = React12.useCallback(async ({ identity, secret }) => { + const bindCredentials = React13.useCallback(async ({ identity, secret }) => { const snapshotVersion = workspaceFence.beginMutation(); setBusy(true); setCredentialError(null); @@ -5843,7 +6084,7 @@ function WecomSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, loadStatus, workspaceFence]); - const closeProvision = React12.useCallback(async () => { + const closeProvision = React13.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && ACTIVE_STATES2.has(provision.status)) { @@ -5854,7 +6095,7 @@ function WecomSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId, provision?.status]); - React12.useEffect(() => { + React13.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_STATES2.has(provision.status)) return void 0; const controller = new AbortController(); @@ -5884,7 +6125,7 @@ function WecomSettingsTab({ rpcCall }) { window.clearTimeout(timer); }; }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]); - const botAction = React12.useCallback(async (account, operation, endpoint, payload) => { + const botAction = React13.useCallback(async (account, operation, endpoint, payload) => { const snapshotVersion = workspaceFence.beginMutation(); setBusyByBot((current) => ({ ...current, [account.botId]: operation })); try { @@ -5975,7 +6216,7 @@ function WecomSettingsTab({ rpcCall }) { addButtonRef }), model.phase === "loading" ? h2(LoadingView4) : model.phase === "error" ? h2("div", { className: "ddt-card dim-surfaceCard" }, h2("div", { className: "ddt-inlineError dim-inlineError" }, h2("h3", null, "\u65E0\u6CD5\u8BFB\u53D6\u4F01\u4E1A\u5FAE\u4FE1\u673A\u5668\u4EBA\u72B6\u6001"), h2("p", null, model.error?.message), h2(Button9, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6"))) : h2( - React12.Fragment, + React13.Fragment, null, credentialView, provisionView, @@ -5986,7 +6227,7 @@ function WecomSettingsTab({ rpcCall }) { } // plugin-src/client/channels/weixin/index.js -var React13 = __toESM(require("react"), 1); +var React14 = __toESM(require("react"), 1); // plugin-src/client/channels/weixin/api.js var WEIXIN_RPC_CHANNEL = "/weixin"; @@ -6251,7 +6492,7 @@ function installWeixinStyles() { } // plugin-src/client/channels/weixin/index.js -var Button11 = React13.forwardRef(function Button12({ children, kind = "secondary", className = "", ...props }, ref) { +var Button11 = React14.forwardRef(function Button12({ children, kind = "secondary", className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -6324,14 +6565,14 @@ function EmptyView5({ onStart, busy }) { ); } function QrPanel4({ provision, now, busy, onRefresh, onCancel }) { - const [imageFailed, setImageFailed] = React13.useState(false); + const [imageFailed, setImageFailed] = React14.useState(false); const source = safeQrSource5(provision.qrCodeDataUrl); const href = safeVerificationUrl(provision.verificationUrl); const remaining = Math.max(0, provision.expiresAt - now); const expired = remaining === 0 || provision.status === "expired"; const duration = Math.max(1, provision.durationMs ?? 5 * 6e4); const progress = Math.round(Math.min(1, remaining / duration) * 100); - React13.useEffect(() => setImageFailed(false), [source]); + React14.useEffect(() => setImageFailed(false), [source]); return h2( "div", { className: "dxw-card dim-surfaceCard" }, @@ -6398,9 +6639,9 @@ function QrPanel4({ provision, now, busy, onRefresh, onCancel }) { ); } function VerificationPanel({ provision, busy, onSubmit, onCancel }) { - const [code, setCode] = React13.useState(""); + const [code, setCode] = React14.useState(""); const valid = /^\d{4,8}$/.test(code); - React13.useEffect(() => setCode(""), [provision.attemptId]); + React14.useEffect(() => setCode(""), [provision.attemptId]); return h2( "div", { className: "dxw-card dim-surfaceCard" }, @@ -6613,39 +6854,39 @@ function mergeWeixinProvisioningSnapshot(current, incoming, { restoreProvisionin }; } function WeixinSettingsTab({ rpcCall }) { - const [model, setModel] = React13.useState({ + const [model, setModel] = React14.useState({ phase: "loading", bots: [], totals: EMPTY_TOTALS3, revision: 0, error: null }); - const [provision, setProvision] = React13.useState(null); - const [busy, setBusy] = React13.useState(false); - const [busyByBot, setBusyByBot] = React13.useState({}); - const [removeTarget, setRemoveTarget] = React13.useState(null); - const [notice, setNotice] = React13.useState(""); - const [now, setNow] = React13.useState(() => Date.now()); - const addButtonRef = React13.useRef(null); - const mountedRef = React13.useRef(true); + const [provision, setProvision] = React14.useState(null); + const [busy, setBusy] = React14.useState(false); + const [busyByBot, setBusyByBot] = React14.useState({}); + const [removeTarget, setRemoveTarget] = React14.useState(null); + const [notice, setNotice] = React14.useState(""); + const [now, setNow] = React14.useState(() => Date.now()); + const addButtonRef = React14.useRef(null); + const mountedRef = React14.useRef(true); const workspaceFence = useWorkspaceSnapshotFence(); const scheduleAnimationFrame = useAnimationFrameScheduler(); - React13.useEffect(() => { + React14.useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); - const announce = React13.useCallback((value) => { + const announce = React14.useCallback((value) => { setNotice(""); scheduleAnimationFrame(() => { if (value) setNotice(value); }, "announcement"); }, [scheduleAnimationFrame]); - const invoke = React13.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React14.useCallback(async (endpoint, payload = {}, signal) => { return unwrapRpcResult8(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React13.useCallback(async ({ + const loadStatus = React14.useCallback(async ({ signal, silent = false, restoreProvisioning = false @@ -6681,12 +6922,12 @@ function WeixinSettingsTab({ rpcCall }) { return void 0; } }, [invoke, workspaceFence]); - React13.useEffect(() => { + React14.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restoreProvisioning: true }); return () => controller.abort(); }, [loadStatus]); - React13.useEffect(() => { + React14.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); let running = false; @@ -6705,12 +6946,12 @@ function WeixinSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React13.useEffect(() => { + React14.useEffect(() => { if (!provision || !["pending", "scanned"].includes(provision.status)) return void 0; const timer = window.setInterval(() => setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React13.useCallback(async ({ replace = false } = {}) => { + const startProvisioning = React14.useCallback(async ({ replace = false } = {}) => { setBusy(true); try { if (replace && provision?.attemptId) { @@ -6731,7 +6972,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - const cancelProvisioning = React13.useCallback(async () => { + const cancelProvisioning = React14.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && !["failed", "expired", "cancelled"].includes(provision.status)) { @@ -6746,7 +6987,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId, provision?.status, scheduleAnimationFrame]); - const submitVerification = React13.useCallback(async (verifyCode) => { + const submitVerification = React14.useCallback(async (verifyCode) => { if (!provision?.attemptId) return; setBusy(true); try { @@ -6762,7 +7003,7 @@ function WeixinSettingsTab({ rpcCall }) { setBusy(false); } }, [announce, invoke, provision?.attemptId]); - React13.useEffect(() => { + React14.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !["pending", "scanned", "connecting"].includes(provision.status)) return void 0; const controller = new AbortController(); @@ -6810,7 +7051,7 @@ function WeixinSettingsTab({ rpcCall }) { controller.abort(); }; }, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]); - const setBotBusy = React13.useCallback((botId, value) => { + const setBotBusy = React14.useCallback((botId, value) => { setBusyByBot((current) => { const next = { ...current }; if (value) next[botId] = value; @@ -6818,7 +7059,7 @@ function WeixinSettingsTab({ rpcCall }) { return next; }); }, []); - const reconnect = React13.useCallback(async (account) => { + const reconnect = React14.useCallback(async (account) => { const snapshotVersion = workspaceFence.beginMutation(); setBotBusy(account.botId, "reconnect"); try { @@ -6836,7 +7077,7 @@ function WeixinSettingsTab({ rpcCall }) { setBotBusy(account.botId, null); } }, [announce, invoke, loadStatus, setBotBusy, workspaceFence]); - const saveWorkspace = React13.useCallback(async (account, workspace) => { + const saveWorkspace = React14.useCallback(async (account, workspace) => { const workspaceVersion = workspaceFence.beginMutation(); setBotBusy(account.botId, "workspace"); try { @@ -6859,7 +7100,7 @@ function WeixinSettingsTab({ rpcCall }) { if (mountedRef.current) setBotBusy(account.botId, null); } }, [invoke, loadStatus, setBotBusy, workspaceFence]); - const remove = React13.useCallback(async (account) => { + const remove = React14.useCallback(async (account) => { const snapshotVersion = workspaceFence.beginMutation(); setBotBusy(account.botId, "delete"); try { @@ -6935,7 +7176,7 @@ function WeixinSettingsTab({ rpcCall }) { h2(Button11, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) ) : h2( - React13.Fragment, + React14.Fragment, null, provisionView, model.bots.length === 0 && !provision ? h2(EmptyView5, { onStart: () => void startProvisioning(), busy }) : null, @@ -7066,7 +7307,7 @@ function formatRemaining6(milliseconds) { } // plugin-src/client/channels/whatsapp/index.js -var React14 = __toESM(require("react"), 1); +var React15 = __toESM(require("react"), 1); // plugin-src/client/channels/whatsapp/styles.js var WHATSAPP_STYLE_ID = "xmanrui-dsh-im-whatsapp-settings"; @@ -7091,7 +7332,7 @@ function installWhatsappStyles() { // plugin-src/client/channels/whatsapp/index.js var ACTIVE_STATES3 = /* @__PURE__ */ new Set(["pending", "connecting"]); -var Button13 = React14.forwardRef(function Button14({ children, kind = "secondary", className = "", ...props }, ref) { +var Button13 = React15.forwardRef(function Button14({ children, kind = "secondary", className = "", ...props }, ref) { return h2("button", { ...props, ref, @@ -7392,21 +7633,21 @@ function WhatsappAccountCard({ ); } function WhatsappSettingsTab({ rpcCall }) { - const [model, setModel] = React14.useState({ + const [model, setModel] = React15.useState({ phase: "loading", bots: [], totals: { configured: 0, connected: 0 }, error: null }); - const [provision, setProvision] = React14.useState(null); - const [busy, setBusy] = React14.useState(false); - const [busyByBot, setBusyByBot] = React14.useState({}); - const [removeTarget, setRemoveTarget] = React14.useState(null); - const [now, setNow] = React14.useState(Date.now()); - const mounted = React14.useRef(true); + const [provision, setProvision] = React15.useState(null); + const [busy, setBusy] = React15.useState(false); + const [busyByBot, setBusyByBot] = React15.useState({}); + const [removeTarget, setRemoveTarget] = React15.useState(null); + const [now, setNow] = React15.useState(Date.now()); + const mounted = React15.useRef(true); const workspaceFence = useWorkspaceSnapshotFence(); - const addButtonRef = React14.useRef(null); - React14.useEffect(() => { + const addButtonRef = React15.useRef(null); + React15.useEffect(() => { const disposeDingtalk = installDingtalkStyles(); const disposeWhatsapp = installWhatsappStyles(); mounted.current = true; @@ -7416,11 +7657,11 @@ function WhatsappSettingsTab({ rpcCall }) { disposeDingtalk(); }; }, []); - const invoke = React14.useCallback(async (endpoint, payload = {}, signal) => { + const invoke = React15.useCallback(async (endpoint, payload = {}, signal) => { if (typeof rpcCall !== "function") throw new TypeError("WhatsApp \u8BBE\u7F6E\u9875\u7F3A\u5C11 RPC \u8FDE\u63A5"); return unwrapRpcResult9(await rpcCall(endpoint, payload, signal)); }, [rpcCall]); - const loadStatus = React14.useCallback(async ({ signal, silent = false, restore = false } = {}) => { + const loadStatus = React15.useCallback(async ({ signal, silent = false, restore = false } = {}) => { const workspaceVersion = workspaceFence.beginStatus(); if (workspaceVersion === null) return void 0; if (!silent && mounted.current) setModel((current) => ({ ...current, phase: "loading", error: null })); @@ -7444,12 +7685,12 @@ function WhatsappSettingsTab({ rpcCall }) { return void 0; } }, [invoke, workspaceFence]); - React14.useEffect(() => { + React15.useEffect(() => { const controller = new AbortController(); void loadStatus({ signal: controller.signal, restore: true }); return () => controller.abort(); }, [loadStatus]); - React14.useEffect(() => { + React15.useEffect(() => { if (model.phase !== "ready") return void 0; const controller = new AbortController(); const timer = window.setInterval( @@ -7461,12 +7702,12 @@ function WhatsappSettingsTab({ rpcCall }) { window.clearInterval(timer); }; }, [loadStatus, model.phase]); - React14.useEffect(() => { + React15.useEffect(() => { if (!provision || !ACTIVE_STATES3.has(provision.status)) return void 0; const timer = window.setInterval(() => mounted.current && setNow(Date.now()), 1e3); return () => window.clearInterval(timer); }, [provision?.attemptId, provision?.status]); - const startProvisioning = React14.useCallback(async (replace = false) => { + const startProvisioning = React15.useCallback(async (replace = false) => { setBusy(true); try { if (replace && provision?.attemptId) { @@ -7484,7 +7725,7 @@ function WhatsappSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId]); - const closeProvision = React14.useCallback(async () => { + const closeProvision = React15.useCallback(async () => { setBusy(true); try { if (provision?.attemptId && ACTIVE_STATES3.has(provision.status)) { @@ -7495,7 +7736,7 @@ function WhatsappSettingsTab({ rpcCall }) { if (mounted.current) setBusy(false); } }, [invoke, provision?.attemptId, provision?.status]); - React14.useEffect(() => { + React15.useEffect(() => { const attemptId = provision?.attemptId; if (!attemptId || !ACTIVE_STATES3.has(provision.status)) return void 0; const controller = new AbortController(); @@ -7536,7 +7777,7 @@ function WhatsappSettingsTab({ rpcCall }) { if (timer) window.clearTimeout(timer); }; }, [invoke, loadStatus, provision?.attemptId, provision?.status]); - const botAction = React14.useCallback(async (account, operation, endpoint, payload) => { + const botAction = React15.useCallback(async (account, operation, endpoint, payload) => { const snapshotVersion = workspaceFence.beginMutation(); setBusyByBot((current) => ({ ...current, [account.botId]: operation })); try { @@ -7612,7 +7853,7 @@ function WhatsappSettingsTab({ rpcCall }) { h2(Button13, { onClick: () => void loadStatus() }, "\u91CD\u65B0\u8BFB\u53D6") ) ) : h2( - React14.Fragment, + React15.Fragment, null, provision?.status === "pending" ? h2(QrPanel5, { provision, @@ -7771,14 +8012,47 @@ var CSS10 = String.raw` .dim-panel .dim-workspaceEdit { padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; cursor: pointer; } .dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; } .dim-panel .dim-workspacePath { min-width: 0; display: block; overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } -.dim-panel .dim-workspaceForm { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; } -.dim-panel .dim-workspaceForm input { width: 100%; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } -.dim-panel .dim-workspaceForm input:focus { border-color: #1677ff; box-shadow: 0 0 0 2px rgb(22 119 255 / 10%); } -.dim-panel .dim-workspaceActions { display: flex; gap: 6px; } -.dim-panel .dim-workspaceActions button { min-height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; } -.dim-panel .dim-workspaceActions button[type="submit"] { border-color: #1677ff; color: white; background: #1677ff; } -.dim-panel .dim-workspaceActions button:disabled { cursor: not-allowed; opacity: .55; } -.dim-panel .dim-workspaceError { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; } +.dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); } +.dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; } +.dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); } +.dim-directoryPickerHeader { min-width: 0; padding: 22px 24px 17px; border-bottom: 1px solid var(--dsw-alias-border-l1, #eef0f3); } +.dim-directoryPickerHeader h3 { margin: 0 0 14px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 1.35; font-weight: 680; } +.dim-directoryPickerHeader > p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; } +.dim-directoryCrumbs { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); } +.dim-directoryCrumbs button { max-width: 210px; overflow: hidden; padding: 3px 5px; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.dim-directoryCrumbs button:hover:not(:disabled) { color: var(--dim-blue); background: var(--dim-blue-soft); } +.dim-directoryCrumbs button[aria-current="page"] { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 650; } +.dim-directoryCrumbs button:focus-visible, .dim-directoryList button:focus-visible, .dim-directoryPickerActions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 1px; } +.dim-directoryCrumbSeparator { flex: none; font-size: 12px; } +.dim-directoryPickerBody { min-height: 0; overflow-y: auto; padding: 14px 16px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, #dfe1e5) transparent; } +.dim-directoryList { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; } +.dim-directoryList button { width: 100%; min-height: 46px; display: grid; grid-template-columns: 24px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 7px 11px; border: 0; border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: transparent; font: inherit; text-align: left; cursor: pointer; } +.dim-directoryList button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); } +.dim-directoryList button:disabled, .dim-directoryCrumbs button:disabled { cursor: wait; opacity: .55; } +.dim-directoryFolder { width: 24px; height: 24px; display: grid; place-items: center; color: var(--dsw-alias-label-secondary, #646a73); } +.dim-directoryFolder svg { width: 22px; height: 22px; } +.dim-directoryName { min-width: 0; overflow: hidden; font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } +.dim-directoryChevron { width: 18px; height: 18px; display: grid; place-items: center; color: var(--dsw-alias-label-tertiary, #8f959e); } +.dim-directoryChevron svg { width: 17px; height: 17px; } +.dim-directoryPickerState { min-height: 210px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; } +.dim-directoryPickerState p { margin: 0; font-size: 13px; line-height: 1.6; } +.dim-directoryPickerSpinner { width: 24px; height: 24px; border: 3px solid var(--dsw-alias-border-l2, #e6e8eb); border-top-color: var(--dim-blue); border-radius: 50%; animation: dim-spin .8s linear infinite; } +.dim-directoryPickerError { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 22%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 8px; color: var(--dsw-alias-state-error-primary, #d54941); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); font-size: 12px; line-height: 1.5; } +.dim-directoryPickerError button { flex: none; padding: 4px 8px; border: 0; border-radius: 6px; color: inherit; background: transparent; font: inherit; font-weight: 650; cursor: pointer; } +.dim-directoryPickerTruncated { margin: 10px 4px 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 1.5; } +.dim-directoryPickerFooter { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 14px; padding: 16px 20px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); background: var(--dsw-alias-bg-layer-1, #fff); } +.dim-directoryHidden { display: inline-flex; align-items: center; gap: 7px; padding: 2px 0; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; } +.dim-directoryHidden:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 2px; } +.dim-directoryHidden:disabled { cursor: not-allowed; opacity: .52; } +.dim-directoryHiddenBox { position: relative; width: 15px; height: 15px; flex: 0 0 15px; border: 1px solid var(--dsw-alias-border-l2, #c9cdd4); border-radius: 4px; background: var(--dsw-alias-bg-layer-1, #fff); } +.dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox { border-color: var(--dim-blue); background: var(--dim-blue); } +.dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } +.dim-directoryPickerNotice { min-width: 0; margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.45; text-align: right; } +.dim-directoryPickerActions { display: flex; gap: 8px; } +.dim-directoryPickerActions button { min-height: 36px; padding: 0 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; } +.dim-directoryPickerActions .dim-directoryPickerPrimary { border-color: var(--dim-blue); color: #fff; background: var(--dim-blue); } +.dim-directoryPickerActions button:hover:not(:disabled) { filter: brightness(.97); } +.dim-directoryPickerActions button:disabled { cursor: not-allowed; opacity: .52; } .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; } .dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; } .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; } @@ -7839,9 +8113,17 @@ var CSS10 = String.raw` .dim-title p { white-space: normal; } .dim-githubTooltip { right: auto; left: 0; } .dim-rail { grid-template-columns: minmax(0, 1fr); } + .dim-directoryPickerBackdrop { padding: 10px; } + .dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; } + .dim-directoryPickerHeader { padding: 18px 17px 14px; } + .dim-directoryPickerHeader h3 { font-size: 18px; } + .dim-directoryPickerBody { padding: 10px; } + .dim-directoryPickerFooter { grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; padding: 13px 14px; } + .dim-directoryPickerNotice { grid-column: 1 / -1; grid-row: 1; text-align: left; } } @media (prefers-reduced-motion: reduce) { .dim-page * { transition-duration: .01ms !important; } + .dim-directoryPickerSpinner { animation-duration: 1.8s; } } `; function installImStyles() { @@ -7860,7 +8142,7 @@ function installImStyles() { // plugin-src/client/index.js var name = "im-settings"; -var inject = ["slots", "connection", "locale"]; +var inject = ["slots", "connection", "locale", "workspaces"]; var IM_PLUGIN_LOGO_URL = "data:image/png;base64,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"; var CHANNELS = Object.freeze([ { id: "weixin", label: "\u5FAE\u4FE1" }, @@ -7948,85 +8230,90 @@ function IMSettingsTab({ telegramRpcCall, wecomRpcCall, weixinRpcCall, - whatsappRpcCall + whatsappRpcCall, + workspaceDirectoryPicker }) { - const [selected, setSelected] = React15.useState("weixin"); - const githubTooltipId = React15.useId(); + const [selected, setSelected] = React16.useState("weixin"); + const githubTooltipId = React16.useId(); const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0]; return h2( - "section", - { className: "dim-page", "aria-label": "IM\u673A\u5668\u4EBA\u8BBE\u7F6E" }, + WorkspaceDirectoryPickerContext.Provider, + { value: workspaceDirectoryPicker }, h2( - "header", - { className: "dim-title" }, + "section", + { className: "dim-page", "aria-label": "IM\u673A\u5668\u4EBA\u8BBE\u7F6E" }, + h2( + "header", + { className: "dim-title" }, + h2( + "div", + { className: "dim-brand" }, + h2("img", { + className: "dim-brandLogo", + src: IM_PLUGIN_LOGO_URL, + alt: "dsh-im", + width: 48, + height: 48 + }), + h2("p", null, "\u8BA9\u804A\u5929\u673A\u5668\u4EBA\u8F7B\u677E\u63A5\u5165 DeepSeek Harness") + ), + h2( + "span", + { className: "dim-githubAction" }, + h2( + "a", + { + className: "dim-githubLink", + href: "https://github.com/xmanrui/dsh-im", + target: "_blank", + rel: "noopener noreferrer", + "aria-label": "dsh-im GitHub", + "aria-describedby": githubTooltipId + }, + h2("span", null, "GitHub"), + h2("span", { className: "dim-githubArrow", "aria-hidden": "true" }, "\u2197") + ), + h2("span", { + id: githubTooltipId, + className: "dim-githubTooltip", + role: "tooltip" + }, "\u5E2E\u52A9\u4E0E\u53CD\u9988 \xB7 \u524D\u5F80 GitHub") + ) + ), h2( "div", - { className: "dim-brand" }, - h2("img", { - className: "dim-brandLogo", - src: IM_PLUGIN_LOGO_URL, - alt: "dsh-im", - width: 48, - height: 48 - }), - h2("p", null, "\u8BA9\u804A\u5929\u673A\u5668\u4EBA\u8F7B\u677E\u63A5\u5165 DeepSeek Harness") - ), - h2( - "span", - { className: "dim-githubAction" }, + { className: "dim-layout" }, h2( - "a", - { - className: "dim-githubLink", - href: "https://github.com/xmanrui/dsh-im", - target: "_blank", - rel: "noopener noreferrer", - "aria-label": "dsh-im GitHub", - "aria-describedby": githubTooltipId - }, - h2("span", null, "GitHub"), - h2("span", { className: "dim-githubArrow", "aria-hidden": "true" }, "\u2197") + "nav", + { className: "dim-rail", role: "tablist", "aria-label": "IM \u6E20\u9053" }, + CHANNELS.map((channel4) => h2( + "button", + { + key: channel4.id, + type: "button", + role: "tab", + id: `dim-tab-${channel4.id}`, + className: "dim-channel", + "aria-selected": channel4.id === active.id, + "aria-controls": `dim-panel-${channel4.id}`, + onClick: () => setSelected(channel4.id) + }, + h2(ChannelLogo, { channel: channel4.id }), + h2( + "span", + { className: "dim-channelCopy" }, + h2("strong", null, channel4.label) + ) + )) ), - h2("span", { - id: githubTooltipId, - className: "dim-githubTooltip", - role: "tooltip" - }, "\u5E2E\u52A9\u4E0E\u53CD\u9988 \xB7 \u524D\u5F80 GitHub") + h2("div", { className: "dim-divider", "aria-hidden": "true" }), + h2("main", { + className: "dim-panel", + role: "tabpanel", + id: `dim-panel-${active.id}`, + "aria-labelledby": `dim-tab-${active.id}` + }, active.id === "weixin" ? h2(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h2(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h2(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h2(WecomSettingsTab, { rpcCall: wecomRpcCall }) : active.id === "qq" ? h2(QqSettingsTab, { rpcCall: qqRpcCall }) : active.id === "slack" ? h2(SlackSettingsTab, { rpcCall: slackRpcCall }) : active.id === "telegram" ? h2(TelegramSettingsTab, { rpcCall: telegramRpcCall }) : active.id === "discord" ? h2(DiscordSettingsTab, { rpcCall: discordRpcCall }) : h2(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })) ) - ), - h2( - "div", - { className: "dim-layout" }, - h2( - "nav", - { className: "dim-rail", role: "tablist", "aria-label": "IM \u6E20\u9053" }, - CHANNELS.map((channel4) => h2( - "button", - { - key: channel4.id, - type: "button", - role: "tab", - id: `dim-tab-${channel4.id}`, - className: "dim-channel", - "aria-selected": channel4.id === active.id, - "aria-controls": `dim-panel-${channel4.id}`, - onClick: () => setSelected(channel4.id) - }, - h2(ChannelLogo, { channel: channel4.id }), - h2( - "span", - { className: "dim-channelCopy" }, - h2("strong", null, channel4.label) - ) - )) - ), - h2("div", { className: "dim-divider", "aria-hidden": "true" }), - h2("main", { - className: "dim-panel", - role: "tabpanel", - id: `dim-panel-${active.id}`, - "aria-labelledby": `dim-tab-${active.id}` - }, active.id === "weixin" ? h2(WeixinSettingsTab, { rpcCall: weixinRpcCall }) : active.id === "feishu" ? h2(FeishuSettingsTab, { rpcCall: feishuRpcCall }) : active.id === "dingtalk" ? h2(DingtalkSettingsTab, { rpcCall: dingtalkRpcCall }) : active.id === "wecom" ? h2(WecomSettingsTab, { rpcCall: wecomRpcCall }) : active.id === "qq" ? h2(QqSettingsTab, { rpcCall: qqRpcCall }) : active.id === "slack" ? h2(SlackSettingsTab, { rpcCall: slackRpcCall }) : active.id === "telegram" ? h2(TelegramSettingsTab, { rpcCall: telegramRpcCall }) : active.id === "discord" ? h2(DiscordSettingsTab, { rpcCall: discordRpcCall }) : h2(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })) ) ); } @@ -8062,6 +8349,10 @@ function apply(ctx) { const discordRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(DISCORD_RPC_CHANNEL, endpoint, payload, signal); const whatsappRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal); const slackRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal); + const workspaceDirectoryPicker = Object.freeze({ + listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), + pickDirectory: () => ctx.workspaces.pickDirectory() + }); ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({ name: "settings.plugins.tab", id: "im", @@ -8077,7 +8368,8 @@ function apply(ctx) { telegramRpcCall, wecomRpcCall, weixinRpcCall, - whatsappRpcCall + whatsappRpcCall, + workspaceDirectoryPicker }) }, IMSettingsTab)); } diff --git a/plugin-src/client/build.mjs b/plugin-src/client/build.mjs index 24820aa..20379fc 100644 --- a/plugin-src/client/build.mjs +++ b/plugin-src/client/build.mjs @@ -17,7 +17,7 @@ const result = await build({ format: 'cjs', platform: 'browser', target: ['chrome100'], - external: ['react'], + external: ['react', 'react-dom'], define: { 'globalThis.__DSH_IM_LOGO_DATA_URL__': JSON.stringify(logoDataUrl), }, diff --git a/plugin-src/client/i18n.js b/plugin-src/client/i18n.js index cbe6405..472a683 100644 --- a/plugin-src/client/i18n.js +++ b/plugin-src/client/i18n.js @@ -45,6 +45,20 @@ const EN = Object.freeze({ '消息通道': 'Message channel', '最近检查': 'Last checked', '当前工作区': 'Current workspace', + '选择目录': 'Choose folder', + '选择机器人工作区目录': 'Select bot workspace folder', + '当前目录': 'Current folder', + '主目录': 'Home', + '正在准备目录选择器…': 'Preparing folder picker…', + '正在读取目录…': 'Loading folders…', + '这个目录中没有子文件夹。': 'This folder has no subfolders.', + '此目录的子文件夹过多,仅显示前一部分。': 'This folder has too many subfolders; only the first group is shown.', + '无法读取目录,请重试。': 'Could not load the folder. Try again.', + '重试': 'Retry', + '显示隐藏文件夹': 'Show hidden folders', + '切换后会清除这个机器人的旧会话映射。': 'Switching clears this bot’s previous session mappings.', + '切换中…': 'Switching…', + '选择此目录': 'Select this folder', '工作区绝对路径': 'Absolute workspace path', '/绝对路径/到/工作区': '/absolute/path/to/workspace', '修改': 'Change', diff --git a/plugin-src/client/index.js b/plugin-src/client/index.js index 6717fae..f40ff30 100644 --- a/plugin-src/client/index.js +++ b/plugin-src/client/index.js @@ -39,9 +39,10 @@ import { WhatsappSettingsTab } from './channels/whatsapp/index.js'; import { installWhatsappStyles } from './channels/whatsapp/styles.js'; import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js'; import { installImStyles } from './styles.js'; +import { WorkspaceDirectoryPickerContext } from './workspace-editor.js'; export const name = 'im-settings'; -export const inject = ['slots', 'connection', 'locale']; +export const inject = ['slots', 'connection', 'locale', 'workspaces']; const IM_PLUGIN_LOGO_URL = globalThis.__DSH_IM_LOGO_DATA_URL__ ?? 'assets/logo-icon.png'; @@ -122,11 +123,13 @@ export function IMSettingsTab({ wecomRpcCall, weixinRpcCall, whatsappRpcCall, + workspaceDirectoryPicker, }) { const [selected, setSelected] = React.useState('weixin'); const githubTooltipId = React.useId(); const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0]; - return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' }, + return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker }, + h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' }, h('header', { className: 'dim-title' }, h('div', { className: 'dim-brand' }, h('img', { @@ -194,7 +197,7 @@ export function IMSettingsTab({ ? h(DiscordSettingsTab, { rpcCall: discordRpcCall }) : h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })), ), - ); + )); } export function apply(ctx) { @@ -240,6 +243,10 @@ export function apply(ctx) { ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal); const slackRpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal); + const workspaceDirectoryPicker = Object.freeze({ + listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal), + pickDirectory: () => ctx.workspaces.pickDirectory(), + }); ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', @@ -257,6 +264,7 @@ export function apply(ctx) { wecomRpcCall, weixinRpcCall, whatsappRpcCall, + workspaceDirectoryPicker, }), }, IMSettingsTab)); } diff --git a/plugin-src/client/styles.js b/plugin-src/client/styles.js index 3fca638..ccc306c 100644 --- a/plugin-src/client/styles.js +++ b/plugin-src/client/styles.js @@ -138,14 +138,47 @@ const CSS = String.raw` .dim-panel .dim-workspaceEdit { padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; cursor: pointer; } .dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; } .dim-panel .dim-workspacePath { min-width: 0; display: block; overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } -.dim-panel .dim-workspaceForm { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; } -.dim-panel .dim-workspaceForm input { width: 100%; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } -.dim-panel .dim-workspaceForm input:focus { border-color: #1677ff; box-shadow: 0 0 0 2px rgb(22 119 255 / 10%); } -.dim-panel .dim-workspaceActions { display: flex; gap: 6px; } -.dim-panel .dim-workspaceActions button { min-height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; } -.dim-panel .dim-workspaceActions button[type="submit"] { border-color: #1677ff; color: white; background: #1677ff; } -.dim-panel .dim-workspaceActions button:disabled { cursor: not-allowed; opacity: .55; } -.dim-panel .dim-workspaceError { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; } +.dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); } +.dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; } +.dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); } +.dim-directoryPickerHeader { min-width: 0; padding: 22px 24px 17px; border-bottom: 1px solid var(--dsw-alias-border-l1, #eef0f3); } +.dim-directoryPickerHeader h3 { margin: 0 0 14px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 1.35; font-weight: 680; } +.dim-directoryPickerHeader > p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; } +.dim-directoryCrumbs { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); } +.dim-directoryCrumbs button { max-width: 210px; overflow: hidden; padding: 3px 5px; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.dim-directoryCrumbs button:hover:not(:disabled) { color: var(--dim-blue); background: var(--dim-blue-soft); } +.dim-directoryCrumbs button[aria-current="page"] { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 650; } +.dim-directoryCrumbs button:focus-visible, .dim-directoryList button:focus-visible, .dim-directoryPickerActions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 1px; } +.dim-directoryCrumbSeparator { flex: none; font-size: 12px; } +.dim-directoryPickerBody { min-height: 0; overflow-y: auto; padding: 14px 16px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, #dfe1e5) transparent; } +.dim-directoryList { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; } +.dim-directoryList button { width: 100%; min-height: 46px; display: grid; grid-template-columns: 24px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 7px 11px; border: 0; border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: transparent; font: inherit; text-align: left; cursor: pointer; } +.dim-directoryList button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); } +.dim-directoryList button:disabled, .dim-directoryCrumbs button:disabled { cursor: wait; opacity: .55; } +.dim-directoryFolder { width: 24px; height: 24px; display: grid; place-items: center; color: var(--dsw-alias-label-secondary, #646a73); } +.dim-directoryFolder svg { width: 22px; height: 22px; } +.dim-directoryName { min-width: 0; overflow: hidden; font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } +.dim-directoryChevron { width: 18px; height: 18px; display: grid; place-items: center; color: var(--dsw-alias-label-tertiary, #8f959e); } +.dim-directoryChevron svg { width: 17px; height: 17px; } +.dim-directoryPickerState { min-height: 210px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; } +.dim-directoryPickerState p { margin: 0; font-size: 13px; line-height: 1.6; } +.dim-directoryPickerSpinner { width: 24px; height: 24px; border: 3px solid var(--dsw-alias-border-l2, #e6e8eb); border-top-color: var(--dim-blue); border-radius: 50%; animation: dim-spin .8s linear infinite; } +.dim-directoryPickerError { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 22%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 8px; color: var(--dsw-alias-state-error-primary, #d54941); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); font-size: 12px; line-height: 1.5; } +.dim-directoryPickerError button { flex: none; padding: 4px 8px; border: 0; border-radius: 6px; color: inherit; background: transparent; font: inherit; font-weight: 650; cursor: pointer; } +.dim-directoryPickerTruncated { margin: 10px 4px 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 1.5; } +.dim-directoryPickerFooter { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 14px; padding: 16px 20px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); background: var(--dsw-alias-bg-layer-1, #fff); } +.dim-directoryHidden { display: inline-flex; align-items: center; gap: 7px; padding: 2px 0; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; } +.dim-directoryHidden:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 2px; } +.dim-directoryHidden:disabled { cursor: not-allowed; opacity: .52; } +.dim-directoryHiddenBox { position: relative; width: 15px; height: 15px; flex: 0 0 15px; border: 1px solid var(--dsw-alias-border-l2, #c9cdd4); border-radius: 4px; background: var(--dsw-alias-bg-layer-1, #fff); } +.dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox { border-color: var(--dim-blue); background: var(--dim-blue); } +.dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } +.dim-directoryPickerNotice { min-width: 0; margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.45; text-align: right; } +.dim-directoryPickerActions { display: flex; gap: 8px; } +.dim-directoryPickerActions button { min-height: 36px; padding: 0 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; white-space: nowrap; cursor: pointer; } +.dim-directoryPickerActions .dim-directoryPickerPrimary { border-color: var(--dim-blue); color: #fff; background: var(--dim-blue); } +.dim-directoryPickerActions button:hover:not(:disabled) { filter: brightness(.97); } +.dim-directoryPickerActions button:disabled { cursor: not-allowed; opacity: .52; } .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; } .dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; } .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; } @@ -206,9 +239,17 @@ const CSS = String.raw` .dim-title p { white-space: normal; } .dim-githubTooltip { right: auto; left: 0; } .dim-rail { grid-template-columns: minmax(0, 1fr); } + .dim-directoryPickerBackdrop { padding: 10px; } + .dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; } + .dim-directoryPickerHeader { padding: 18px 17px 14px; } + .dim-directoryPickerHeader h3 { font-size: 18px; } + .dim-directoryPickerBody { padding: 10px; } + .dim-directoryPickerFooter { grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; padding: 13px 14px; } + .dim-directoryPickerNotice { grid-column: 1 / -1; grid-row: 1; text-align: left; } } @media (prefers-reduced-motion: reduce) { .dim-page * { transition-duration: .01ms !important; } + .dim-directoryPickerSpinner { animation-duration: 1.8s; } } `; diff --git a/plugin-src/client/workspace-directory-picker.js b/plugin-src/client/workspace-directory-picker.js new file mode 100644 index 0000000..5bab829 --- /dev/null +++ b/plugin-src/client/workspace-directory-picker.js @@ -0,0 +1,230 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import { h } from './i18n.js'; + +function pickerErrorCode(error) { + return error?.rpcError?.code ?? error?.code; +} + +function pickerErrorDetails(error) { + return error?.rpcError?.details ?? error?.details; +} + +function pickerErrorMessage(error) { + return error?.rpcError?.message ?? error?.message ?? '无法读取目录,请重试。'; +} + +function FolderIcon() { + return React.createElement('svg', { + viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, + strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', + }, + React.createElement('path', { d: 'M3.5 7.25A2.25 2.25 0 0 1 5.75 5h4.1l1.8 2h6.6a2.25 2.25 0 0 1 2.25 2.25v7A2.75 2.75 0 0 1 17.75 19h-12A2.25 2.25 0 0 1 3.5 16.75v-9.5Z' })); +} + +function ChevronIcon() { + return React.createElement('svg', { + viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7, + strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', + }, React.createElement('path', { d: 'm7.5 4.5 5 5.5-5 5.5' })); +} + +function displayCrumbs(listing) { + const homeIndex = listing.crumbs.findIndex((crumb) => crumb.path === listing.home); + if (homeIndex < 0) return listing.crumbs; + return listing.crumbs.slice(homeIndex); +} + +export function WorkspaceDirectoryPicker({ + open, + startPath, + picker, + busy = false, + saveError = null, + onPicked, + onCancel, +}) { + const [listing, setListing] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [showHidden, setShowHidden] = React.useState(false); + const [retryKey, setRetryKey] = React.useState(0); + const requestRef = React.useRef(0); + const controllerRef = React.useRef(null); + const dialogRef = React.useRef(null); + const bodyRef = React.useRef(null); + const titleId = React.useId(); + const noticeId = React.useId(); + const initialPathRef = React.useRef(startPath); + const onPickedRef = React.useRef(onPicked); + const onCancelRef = React.useRef(onCancel); + const busyRef = React.useRef(busy); + + onPickedRef.current = onPicked; + onCancelRef.current = onCancel; + busyRef.current = busy; + + const loadDirectory = React.useCallback(async (path, { reportError = true } = {}) => { + const request = requestRef.current + 1; + requestRef.current = request; + controllerRef.current?.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + setLoading(true); + if (reportError) setError(null); + try { + const next = await picker.listDirectory(path, controller.signal); + if (request !== requestRef.current || controller.signal.aborted) return { aborted: true }; + if (bodyRef.current) bodyRef.current.scrollTop = 0; + setListing(next); + setError(null); + return { value: next }; + } catch (cause) { + if (request !== requestRef.current || controller.signal.aborted) return { aborted: true }; + if (reportError) setError(pickerErrorMessage(cause)); + return { error: cause }; + } finally { + if (request === requestRef.current) setLoading(false); + } + }, [picker]); + + React.useEffect(() => { + if (!open) return undefined; + let active = true; + setListing(null); + setError(null); + setShowHidden(false); + dialogRef.current?.focus?.(); + const handleKeyDown = (event) => { + if (event.key === 'Escape' && !busyRef.current) onCancelRef.current?.(); + }; + if (typeof document !== 'undefined') document.addEventListener('keydown', handleKeyDown); + + const start = async () => { + const initialPath = initialPathRef.current; + const initial = await loadDirectory(initialPath || undefined, { reportError: false }); + if (!active || initial.aborted || initial.value) return; + const code = pickerErrorCode(initial.error); + const details = pickerErrorDetails(initial.error); + if (code === 'directory-picker-unavailable' + && details?.capability === 'native' + && typeof picker.pickDirectory === 'function') { + setLoading(true); + try { + const selected = await picker.pickDirectory(); + if (!active) return; + if (selected !== null) await onPickedRef.current?.(selected); + else onCancelRef.current?.(); + } catch (cause) { + if (active) setError(pickerErrorMessage(cause)); + } finally { + if (active) setLoading(false); + } + return; + } + if (initialPath && code === 'directory-unreadable') { + const home = await loadDirectory(undefined, { reportError: false }); + if (!active || home.aborted || home.value) return; + setError(pickerErrorMessage(home.error)); + return; + } + setError(pickerErrorMessage(initial.error)); + }; + + void start(); + return () => { + active = false; + if (typeof document !== 'undefined') document.removeEventListener('keydown', handleKeyDown); + requestRef.current += 1; + controllerRef.current?.abort(); + }; + }, [loadDirectory, open, picker, retryKey]); + + if (!open) return null; + const entries = (listing?.entries ?? []).filter((entry) => showHidden || !entry.hidden); + const crumbs = listing ? displayCrumbs(listing) : []; + const presentedError = saveError ?? error; + + const content = h('div', { + className: 'dim-directoryPickerBackdrop', + onMouseDown: (event) => { + if (event.target === event.currentTarget && !busy) onCancel(); + }, + }, + h('section', { + ref: dialogRef, + className: 'dim-directoryPicker', + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': titleId, + 'aria-describedby': noticeId, + tabIndex: -1, + }, + h('header', { className: 'dim-directoryPickerHeader' }, + h('h3', { id: titleId }, '选择机器人工作区目录'), + listing + ? h('nav', { className: 'dim-directoryCrumbs', 'aria-label': '当前目录' }, + crumbs.map((crumb, index) => h(React.Fragment, { key: crumb.path }, + index > 0 ? h('span', { className: 'dim-directoryCrumbSeparator', 'aria-hidden': 'true' }, '›') : null, + React.createElement('button', { + type: 'button', + title: crumb.path, + disabled: loading || busy, + 'aria-current': index === crumbs.length - 1 ? 'page' : undefined, + onClick: () => void loadDirectory(crumb.path), + }, crumb.path === listing.home + ? h('span', null, '主目录') + : (crumb.name || crumb.path)))), + ) + : h('p', null, '正在准备目录选择器…')), + h('div', { ref: bodyRef, className: 'dim-directoryPickerBody', 'aria-busy': loading }, + loading && !listing + ? h('div', { className: 'dim-directoryPickerState' }, + h('span', { className: 'dim-directoryPickerSpinner', 'aria-hidden': 'true' }), + h('p', null, '正在读取目录…')) + : listing + ? entries.length > 0 + ? h('ul', { className: 'dim-directoryList' }, entries.map((entry) => h('li', { key: entry.path }, + React.createElement('button', { + type: 'button', + title: entry.path, + disabled: loading || busy, + onClick: () => void loadDirectory(entry.path), + }, + h('span', { className: 'dim-directoryFolder' }, h(FolderIcon)), + React.createElement('span', { className: 'dim-directoryName' }, entry.name), + h('span', { className: 'dim-directoryChevron' }, h(ChevronIcon)))))) + : h('div', { className: 'dim-directoryPickerState' }, + h('p', null, '这个目录中没有子文件夹。')) + : null, + listing?.truncated + ? h('p', { className: 'dim-directoryPickerTruncated' }, '此目录的子文件夹过多,仅显示前一部分。') + : null, + presentedError ? h('div', { className: 'dim-directoryPickerError', role: 'alert' }, + h('span', null, presentedError), + !listing && !busy ? h('button', { + type: 'button', onClick: () => setRetryKey((value) => value + 1), + }, '重试') : null) : null), + h('footer', { className: 'dim-directoryPickerFooter' }, + h('button', { + type: 'button', + className: 'dim-directoryHidden', + 'aria-pressed': showHidden, + onClick: () => setShowHidden((value) => !value), + disabled: busy || !listing, + }, + h('span', { className: 'dim-directoryHiddenBox', 'aria-hidden': 'true' }), + h('span', null, '显示隐藏文件夹')), + h('p', { id: noticeId, className: 'dim-directoryPickerNotice' }, '切换后会清除这个机器人的旧会话映射。'), + h('div', { className: 'dim-directoryPickerActions' }, + h('button', { type: 'button', onClick: onCancel, disabled: busy }, '取消'), + h('button', { + type: 'button', + className: 'dim-directoryPickerPrimary', + disabled: busy || loading || !listing, + onClick: () => listing && void onPicked(listing.path), + }, busy ? '切换中…' : '选择此目录'))))); + + return typeof document === 'undefined' ? content : createPortal(content, document.body); +} diff --git a/plugin-src/client/workspace-editor.js b/plugin-src/client/workspace-editor.js index e2eb991..f45c3b5 100644 --- a/plugin-src/client/workspace-editor.js +++ b/plugin-src/client/workspace-editor.js @@ -1,96 +1,69 @@ import * as React from 'react'; import { h } from './i18n.js'; +import { WorkspaceDirectoryPicker } from './workspace-directory-picker.js'; -function looksLikeAbsolutePath(value) { - return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); -} +export const WorkspaceDirectoryPickerContext = React.createContext(null); -export function WorkspaceEditor({ workspace, disabled = false, onSave }) { - const [editing, setEditing] = React.useState(false); - const [draft, setDraft] = React.useState(workspace ?? ''); +export function WorkspaceEditor({ workspace, directoryPicker, disabled = false, onSave }) { + const sharedDirectoryPicker = React.useContext(WorkspaceDirectoryPickerContext); + const activeDirectoryPicker = directoryPicker ?? sharedDirectoryPicker; + const [open, setOpen] = React.useState(false); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); - const inputRef = React.useRef(null); const editButtonRef = React.useRef(null); - const restoreFocus = React.useRef(false); + const savingRef = React.useRef(false); - React.useEffect(() => { - if (!editing) setDraft(workspace ?? ''); - }, [workspace, editing]); - - React.useEffect(() => { - if (editing) inputRef.current?.focus(); - else if (restoreFocus.current) { - restoreFocus.current = false; - editButtonRef.current?.focus(); - } - }, [editing]); - - const cancel = () => { - setDraft(workspace ?? ''); + const close = React.useCallback(() => { + setOpen(false); setError(null); - restoreFocus.current = true; - setEditing(false); - }; + queueMicrotask(() => editButtonRef.current?.focus?.()); + }, []); - const submit = async (event) => { - event.preventDefault(); - const value = draft.trim(); - if (!value || saving || disabled) return; - if (!looksLikeAbsolutePath(value)) { - setError('工作区必须是绝对路径。'); + const pick = React.useCallback(async (value) => { + if (!value || savingRef.current || disabled) return; + if (value === workspace) { + close(); return; } + savingRef.current = true; setSaving(true); setError(null); try { await onSave?.(value); - restoreFocus.current = true; - setEditing(false); + close(); } catch (cause) { setError(cause?.message ?? '工作区修改失败,请重试。'); } finally { + savingRef.current = false; setSaving(false); } - }; + }, [close, disabled, onSave, workspace]); return h('div', { className: 'dim-workspace' }, h('div', { className: 'dim-workspaceHeader' }, h('span', null, '当前工作区'), - editing ? null : h('button', { + h('button', { type: 'button', ref: editButtonRef, className: 'dim-workspaceEdit', - onClick: () => { setEditing(true); setError(null); }, - disabled, - }, '修改')), - editing - ? h('form', { className: 'dim-workspaceForm', onSubmit: submit }, - h('input', { - ref: inputRef, - value: draft, - onChange: (event) => setDraft(event.target.value), - placeholder: '/绝对路径/到/工作区', - 'aria-label': '工作区绝对路径', - autoCapitalize: 'none', - autoCorrect: 'off', - spellCheck: false, - maxLength: 4_096, - required: true, - disabled: saving || disabled, - }), - h('div', { className: 'dim-workspaceActions' }, - h('button', { - type: 'submit', disabled: saving || disabled || !draft.trim(), - }, saving ? '保存中…' : '保存'), - h('button', { type: 'button', onClick: cancel, disabled: saving || disabled }, '取消')), - error ? h('p', { className: 'dim-workspaceError', role: 'alert' }, error) : null) - : workspace - ? React.createElement('code', { - className: 'dim-workspacePath', - title: workspace, - }, workspace) - : h('code', { className: 'dim-workspacePath' }, '未设置'), + onClick: () => { setOpen(true); setError(null); }, + disabled: disabled || !activeDirectoryPicker, + }, '选择目录')), + workspace + ? React.createElement('code', { + className: 'dim-workspacePath', + title: workspace, + }, workspace) + : h('code', { className: 'dim-workspacePath' }, '未设置'), + open ? h(WorkspaceDirectoryPicker, { + open, + startPath: workspace, + picker: activeDirectoryPicker, + busy: saving || disabled, + saveError: error, + onPicked: pick, + onCancel: close, + }) : null, ); } diff --git a/test/client-ui.test.mjs b/test/client-ui.test.mjs index 0f69fd0..5a76335 100644 --- a/test/client-ui.test.mjs +++ b/test/client-ui.test.mjs @@ -557,10 +557,11 @@ test('every shipped Chinese client string has an English projection', async () = } }); -test('client registers a live bilingual locale seat for the IM settings tab', () => { +test('client registers a live bilingual locale seat and directory picker for the IM settings tab', async () => { const effects = []; const registrations = []; const dictionaries = []; + const directoryCalls = []; const rpcCall = async () => ({ ok: true, value: {} }); const ctx = { effect(install, label) { @@ -577,6 +578,16 @@ test('client registers a live bilingual locale seat for the IM settings tab', () }, }, connection: { rpc: { call: rpcCall } }, + workspaces: { + async listDirectory(path, signal) { + directoryCalls.push({ operation: 'list', path, signal }); + return { path, entries: [] }; + }, + async pickDirectory() { + directoryCalls.push({ operation: 'pick' }); + return '/workspace/chosen'; + }, + }, slots: { inject(name, install) { assert.equal(name, 'settings.plugins.tab'); @@ -595,16 +606,28 @@ test('client registers a live bilingual locale seat for the IM settings tab', () assert.ok(dictionaryEffect); dictionaryEffect.install(); - assert.deepEqual(clientInject, ['slots', 'connection', 'locale']); + assert.deepEqual(clientInject, ['slots', 'connection', 'locale', 'workspaces']); assert.equal(dictionaries[0].namespace, IM_LOCALE_NAMESPACE); assert.deepEqual(Object.keys(dictionaries[0].value.en).sort(), Object.keys(dictionaries[0].value.zh).sort()); assert.equal(registrations.length, 1); assert.equal(registrations[0].options.locale, IM_LOCALE_NAMESPACE); assert.equal(registrations[0].options.label(), 'IM bots'); + const injected = registrations[0].options.inject(); + const signal = new AbortController().signal; + assert.deepEqual( + await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal), + { path: '/workspace/current', entries: [] }, + ); + assert.equal(await injected.workspaceDirectoryPicker.pickDirectory(), '/workspace/chosen'); + assert.deepEqual(directoryCalls, [ + { operation: 'list', path: '/workspace/current', signal }, + { operation: 'pick' }, + ]); + const markup = renderToStaticMarkup(React.createElement( registrations[0].component, - registrations[0].options.inject(), + injected, )); assert.match(markup, /Connect IM bots to DeepSeek Harness with ease/); assert.match(markup, /Help & feedback · Open GitHub/); diff --git a/test/workspace-editor.test.mjs b/test/workspace-editor.test.mjs index 5291254..482e72f 100644 --- a/test/workspace-editor.test.mjs +++ b/test/workspace-editor.test.mjs @@ -3,15 +3,15 @@ import test from 'node:test'; import * as React from 'react'; import TestRenderer from 'react-test-renderer'; -import { WorkspaceEditor } from '../plugin-src/client/workspace-editor.js'; +import { + WorkspaceDirectoryPickerContext, + WorkspaceEditor, +} from '../plugin-src/client/workspace-editor.js'; import { DiscordSettingsTab } from '../plugin-src/client/channels/discord/index.js'; +import { en, setImTranslator } from '../plugin-src/client/i18n.js'; const { act, create } = TestRenderer; -function submitEvent() { - return { preventDefault() {} }; -} - function deferred() { let resolve; const promise = new Promise((onResolve) => { resolve = onResolve; }); @@ -22,6 +22,68 @@ async function flushMicrotasks() { for (let index = 0; index < 6; index += 1) await Promise.resolve(); } +function directoryListing(path, childNames = [], { home = '/workspace', truncated = false } = {}) { + let cursor = ''; + const crumbs = [{ name: '/', path: '/', hidden: false }]; + for (const name of path.split('/').filter(Boolean)) { + cursor += `/${name}`; + crumbs.push({ name, path: cursor, hidden: false }); + } + return { + path, + home, + crumbs, + entries: childNames.map((name) => ({ + name, + path: `${path === '/' ? '' : path}/${name}`, + hidden: name.startsWith('.'), + })), + truncated, + }; +} + +function nativeUnavailable() { + const error = new Error('Directory browsing is unavailable'); + error.rpcError = { + code: 'directory-picker-unavailable', + message: error.message, + details: { capability: 'native' }, + }; + return error; +} + +function nativeDirectoryPicker(selected) { + const calls = { list: 0, pick: 0 }; + return { + calls, + async listDirectory() { + calls.list += 1; + throw nativeUnavailable(); + }, + async pickDirectory() { + calls.pick += 1; + return selected; + }, + }; +} + +function withDirectoryPicker(element, picker) { + return React.createElement( + WorkspaceDirectoryPickerContext.Provider, + { value: picker }, + element, + ); +} + +function textOf(node) { + if (typeof node === 'string' || typeof node === 'number') return String(node); + return node?.children?.map(textOf).join('') ?? ''; +} + +function buttonNamed(root, name) { + return root.findAllByType('button').find((button) => textOf(button) === name); +} + function discordSnapshot(workspace) { return { revision: 1, @@ -53,12 +115,139 @@ function twoBotDiscordSnapshot(firstWorkspace) { }; } -test('WorkspaceEditor shows the current path and saves an edited absolute path', async () => { +test('WorkspaceEditor browses from the current path and saves the selected directory', async () => { const saved = []; + const listed = []; + const picker = { + async listDirectory(path) { + listed.push(path); + if (path === '/workspace/current') { + return directoryListing(path, ['next project', '.hidden']); + } + if (path === '/workspace/current/next project') return directoryListing(path); + throw new Error(`Unexpected directory: ${path}`); + }, + async pickDirectory() { throw new Error('native picker should not run'); }, + }; function Fixture() { const [workspace, setWorkspace] = React.useState('/workspace/current'); return React.createElement(WorkspaceEditor, { workspace, + directoryPicker: picker, + async onSave(value) { + saved.push(value); + setWorkspace(value); + }, + }); + } + + const bodyNode = { scrollTop: 0 }; + let renderer; + await act(async () => { + renderer = create(React.createElement(Fixture), { + createNodeMock(element) { + if (element.props?.className === 'dim-directoryPickerBody') return bodyNode; + return {}; + }, + }); + }); + assert.equal(renderer.root.findByType('code').props.title, '/workspace/current'); + + await act(async () => { + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); + }); + const dialog = renderer.root.findByProps({ role: 'dialog' }); + assert.equal(dialog.props['aria-modal'], 'true'); + assert.equal( + textOf(renderer.root.findByProps({ id: dialog.props['aria-describedby'] })), + '切换后会清除这个机器人的旧会话映射。', + ); + assert.equal(renderer.root.findAllByProps({ title: '/workspace/current/.hidden' }).length, 0); + bodyNode.scrollTop = 240; + await act(async () => { + renderer.root.findByProps({ title: '/workspace/current/next project' }).props.onClick(); + await flushMicrotasks(); + }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick(); + await flushMicrotasks(); + }); + + assert.deepEqual(listed, ['/workspace/current', '/workspace/current/next project']); + assert.equal(bodyNode.scrollTop, 0); + assert.deepEqual(saved, ['/workspace/current/next project']); + assert.equal(renderer.root.findByType('code').props.title, '/workspace/current/next project'); +}); + +test('WorkspaceEditor keeps the picker open and presents a rejected workspace error', async () => { + const picker = { + async listDirectory(path) { + return path === '/workspace/current' + ? directoryListing(path, ['missing']) + : directoryListing(path); + }, + }; + let renderer; + await act(async () => { + renderer = create(React.createElement(WorkspaceEditor, { + workspace: '/workspace/current', + directoryPicker: picker, + async onSave() { + const error = new Error('工作区路径不存在。'); + error.code = 'workspace-not-found'; + throw error; + }, + })); + }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); + }); + await act(async () => { + renderer.root.findByProps({ title: '/workspace/current/missing' }).props.onClick(); + await flushMicrotasks(); + renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick(); + await flushMicrotasks(); + }); + + assert.equal(textOf(renderer.root.findByProps({ role: 'alert' })), '工作区路径不存在。'); + assert.equal(renderer.root.findByProps({ role: 'dialog' }).props['aria-modal'], 'true'); + assert.equal(renderer.root.findByType('code').props.title, '/workspace/current'); +}); + +test('WorkspaceEditor closes without saving when the current directory is selected', async () => { + let saves = 0; + const picker = { async listDirectory(path) { return directoryListing(path); } }; + let renderer; + await act(async () => { + renderer = create(React.createElement(WorkspaceEditor, { + workspace: '/workspace/current', + directoryPicker: picker, + async onSave() { saves += 1; }, + })); + }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); + }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick(); + await flushMicrotasks(); + }); + + assert.equal(saves, 0); + assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0); +}); + +test('WorkspaceEditor falls back to one native picker without restarting after save', async () => { + const saved = []; + const picker = nativeDirectoryPicker('/workspace/native'); + function Fixture() { + const [workspace, setWorkspace] = React.useState('/workspace/current'); + return React.createElement(WorkspaceEditor, { + workspace, + directoryPicker: picker, async onSave(value) { saved.push(value); setWorkspace(value); @@ -68,81 +257,89 @@ test('WorkspaceEditor shows the current path and saves an edited absolute path', let renderer; await act(async () => { renderer = create(React.createElement(Fixture)); }); - assert.equal(renderer.root.findByType('code').props.title, '/workspace/current'); - await act(async () => { - renderer.root.findByType('button').props.onClick(); - }); - const input = renderer.root.findByType('input'); - assert.equal(input.props.value, '/workspace/current'); - assert.equal(input.props.maxLength, 4_096); - await act(async () => { - input.props.onChange({ target: { value: ' /workspace/next project ' } }); - }); - await act(async () => { - await renderer.root.findByType('form').props.onSubmit(submitEvent()); + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); }); - assert.deepEqual(saved, ['/workspace/next project']); - assert.equal(renderer.root.findByType('code').props.title, '/workspace/next project'); + assert.deepEqual(saved, ['/workspace/native']); + assert.deepEqual(picker.calls, { list: 1, pick: 1 }); + assert.equal(renderer.root.findByType('code').props.title, '/workspace/native'); + assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0); }); -test('WorkspaceEditor keeps editing and presents a rejected workspace error', async () => { - let renderer; - await act(async () => { - renderer = create(React.createElement(WorkspaceEditor, { - workspace: '/workspace/current', - async onSave() { - const error = new Error('工作区路径不存在。'); - error.code = 'workspace-not-found'; - throw error; - }, - })); - }); - await act(async () => { renderer.root.findByType('button').props.onClick(); }); - await act(async () => { - renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/missing' } }); - }); - await act(async () => { - await renderer.root.findByType('form').props.onSubmit(submitEvent()); - }); - - assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区路径不存在。'); - assert.equal(renderer.root.findByType('input').props.value, '/workspace/missing'); -}); - -test('WorkspaceEditor rejects a relative path before calling the Host', async () => { +test('WorkspaceEditor treats native picker cancellation as cancellation, not an error', async () => { let saves = 0; + const picker = nativeDirectoryPicker(null); let renderer; await act(async () => { renderer = create(React.createElement(WorkspaceEditor, { workspace: '/workspace/current', + directoryPicker: picker, async onSave() { saves += 1; }, })); }); - await act(async () => { renderer.root.findByType('button').props.onClick(); }); await act(async () => { - renderer.root.findByType('input').props.onChange({ target: { value: 'relative/path' } }); - }); - await act(async () => { - await renderer.root.findByType('form').props.onSubmit(submitEvent()); + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); }); assert.equal(saves, 0); - assert.equal(renderer.root.findByProps({ role: 'alert' }).children.join(''), '工作区必须是绝对路径。'); + assert.deepEqual(picker.calls, { list: 1, pick: 1 }); + assert.equal(renderer.root.findAllByProps({ role: 'alert' }).length, 0); + assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0); }); -test('WorkspaceEditor moves keyboard focus into and back out of edit mode', async () => { - let inputFocus = 0; +test('WorkspaceEditor falls back to the Host home when the saved path is unreadable', async () => { + const listed = []; + const saved = []; + const picker = { + async listDirectory(path) { + listed.push(path); + if (path === '/workspace/gone') { + const error = new Error('missing'); + error.rpcError = { code: 'directory-unreadable', message: 'missing', details: { path } }; + throw error; + } + return directoryListing('/workspace', ['projects']); + }, + }; + let renderer; + await act(async () => { + renderer = create(React.createElement(WorkspaceEditor, { + workspace: '/workspace/gone', + directoryPicker: picker, + async onSave(value) { saved.push(value); }, + })); + }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); + }); + + assert.deepEqual(listed, ['/workspace/gone', undefined]); + await act(async () => { + renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick(); + await flushMicrotasks(); + }); + assert.deepEqual(saved, ['/workspace']); +}); + +test('WorkspaceEditor moves keyboard focus into and back out of the picker', async () => { + let dialogFocus = 0; let editFocus = 0; + const picker = { async listDirectory(path) { return directoryListing(path); } }; let renderer; await act(async () => { renderer = create(React.createElement(WorkspaceEditor, { workspace: '/workspace/current', + directoryPicker: picker, async onSave() {}, }), { createNodeMock(element) { - if (element.type === 'input') return { focus() { inputFocus += 1; } }; + if (element.props?.className === 'dim-directoryPicker') { + return { focus() { dialogFocus += 1; } }; + } if (element.props?.className === 'dim-workspaceEdit') { return { focus() { editFocus += 1; } }; } @@ -150,14 +347,44 @@ test('WorkspaceEditor moves keyboard focus into and back out of edit mode', asyn }, }); }); - await act(async () => { renderer.root.findByType('button').props.onClick(); }); - assert.equal(inputFocus, 1); - const cancel = renderer.root.findAllByType('button') - .find((button) => button.children.join('') === '取消'); - await act(async () => { cancel.props.onClick(); }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); + }); + assert.equal(dialogFocus, 1); + await act(async () => { + buttonNamed(renderer.root, '取消').props.onClick(); + await flushMicrotasks(); + }); assert.equal(editFocus, 1); }); +test('WorkspaceEditor never translates Host filesystem names in the English UI', async (t) => { + setImTranslator((key) => en[key] ?? key); + t.after(() => setImTranslator(null)); + const picker = { + async listDirectory(path) { return directoryListing(path, ['微信']); }, + }; + let renderer; + await act(async () => { + renderer = create(React.createElement(WorkspaceEditor, { + workspace: '/workspace/current', + directoryPicker: picker, + async onSave() {}, + })); + await flushMicrotasks(); + }); + await act(async () => { + renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick(); + await flushMicrotasks(); + }); + + const directory = renderer.root.findByProps({ title: '/workspace/current/微信' }); + assert.equal(textOf(directory), '微信'); + assert.doesNotMatch(textOf(directory), /WeChat/); + await act(async () => { renderer.unmount(); }); +}); + test('a status response started before saving cannot restore the old workspace', async (t) => { const previousWindow = globalThis.window; let intervalCallback; @@ -185,8 +412,12 @@ test('a status response started before saving cannot restore the old workspace', }; let renderer; + const picker = nativeDirectoryPicker('/workspace/new'); await act(async () => { - renderer = create(React.createElement(DiscordSettingsTab, { rpcCall })); + renderer = create(withDirectoryPicker( + React.createElement(DiscordSettingsTab, { rpcCall }), + picker, + )); await flushMicrotasks(); }); await act(async () => { @@ -194,12 +425,7 @@ test('a status response started before saving cannot restore the old workspace', await flushMicrotasks(); }); await act(async () => { - renderer.root.findAllByType('button') - .find((button) => button.children.join('') === '修改').props.onClick(); - }); - await act(async () => { - renderer.root.findByType('input').props.onChange({ target: { value: '/workspace/new' } }); - await renderer.root.findByType('form').props.onSubmit(submitEvent()); + buttonNamed(renderer.root, '选择目录').props.onClick(); await flushMicrotasks(); }); assert.equal(renderer.root.findByType('code').props.title, '/workspace/new'); @@ -237,8 +463,12 @@ test('an older reconnect snapshot from another bot cannot restore a saved worksp }; let renderer; + const picker = nativeDirectoryPicker('/workspace/new'); await act(async () => { - renderer = create(React.createElement(DiscordSettingsTab, { rpcCall })); + renderer = create(withDirectoryPicker( + React.createElement(DiscordSettingsTab, { rpcCall }), + picker, + )); await flushMicrotasks(); }); const firstCard = renderer.root.findByProps({ 'data-bot-id': 'discord_first' }); @@ -249,12 +479,7 @@ test('an older reconnect snapshot from another bot cannot restore a saved worksp await flushMicrotasks(); }); await act(async () => { - firstCard.findAllByType('button') - .find((button) => button.children.join('') === '修改').props.onClick(); - }); - await act(async () => { - firstCard.findByType('input').props.onChange({ target: { value: '/workspace/new' } }); - await firstCard.findByType('form').props.onSubmit(submitEvent()); + buttonNamed(firstCard, '选择目录').props.onClick(); await flushMicrotasks(); });