feat: add workspace directory picker

This commit is contained in:
xmanrui 2026-08-18 01:39:21 +08:00
parent c51334ebc3
commit e7ef0f5d49
9 changed files with 1317 additions and 511 deletions

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