Merge branch 'codex/workspace-directory-picker'

This commit is contained in:
xmanrui 2026-08-18 01:39:27 +08:00
commit 76d5430f1f
9 changed files with 1317 additions and 511 deletions

File diff suppressed because one or more lines are too long

View file

@ -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),
},

View file

@ -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',

View file

@ -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));
}

View file

@ -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; }
}
`;

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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 &amp; feedback · Open GitHub/);

View file

@ -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();
});