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

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