mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 00:32:43 +08:00
feat: add workspace directory picker
This commit is contained in:
parent
c51334ebc3
commit
e7ef0f5d49
9 changed files with 1317 additions and 511 deletions
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
230
plugin-src/client/workspace-directory-picker.js
Normal file
230
plugin-src/client/workspace-directory-picker.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue