fix: support current directory picker errors

This commit is contained in:
xmanrui 2026-09-02 23:18:28 +08:00
parent 88ef12ecc8
commit 3c71712962
4 changed files with 118 additions and 44 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased]
### Fixed / 修复
- 机器人工作区目录选择器现在同时识别新版 DSH 的 `directory-picker/*` 错误码与旧版连字符错误码;native 后端会正确回退到系统目录选择器,已失效的保存路径也会回退到 Host 主目录。
The bot workspace directory picker now recognizes both current DSH `directory-picker/*` error codes and legacy hyphenated codes, restoring the native system-picker fallback and the Host-home fallback for stale saved paths.
## [4.7.0] - 2026-09-02
### Added / 新增

View file

@ -2526,6 +2526,15 @@ var import_react_dom = require("react-dom");
function pickerErrorCode(error) {
return error?.rpcError?.code ?? error?.code;
}
var PICKER_ERROR_KINDS = /* @__PURE__ */ new Map([
["directory-picker/unavailable", "unavailable"],
["directory-picker-unavailable", "unavailable"],
["directory-picker/unreadable", "unreadable"],
["directory-unreadable", "unreadable"]
]);
function pickerErrorKind(error) {
return PICKER_ERROR_KINDS.get(pickerErrorCode(error));
}
function pickerErrorDetails(error) {
return error?.rpcError?.details ?? error?.details;
}
@ -2633,9 +2642,9 @@ function WorkspaceDirectoryPicker({
const initialPath = initialPathRef.current;
const initial = await loadDirectory(initialPath || void 0, { reportError: false });
if (!active || initial.aborted || initial.value) return;
const code = pickerErrorCode(initial.error);
const kind = pickerErrorKind(initial.error);
const details = pickerErrorDetails(initial.error);
if (code === "directory-picker-unavailable" && details?.capability === "native" && typeof picker.pickDirectory === "function") {
if (kind === "unavailable" && details?.capability === "native" && typeof picker.pickDirectory === "function") {
setLoading(true);
try {
const selected = await picker.pickDirectory();
@ -2649,7 +2658,7 @@ function WorkspaceDirectoryPicker({
}
return;
}
if (initialPath && code === "directory-unreadable") {
if (initialPath && kind === "unreadable") {
const home = await loadDirectory(void 0, { reportError: false });
if (!active || home.aborted || home.value) return;
setError(pickerErrorMessage(home.error));

View file

@ -7,6 +7,17 @@ function pickerErrorCode(error) {
return error?.rpcError?.code ?? error?.code;
}
const PICKER_ERROR_KINDS = new Map([
['directory-picker/unavailable', 'unavailable'],
['directory-picker-unavailable', 'unavailable'],
['directory-picker/unreadable', 'unreadable'],
['directory-unreadable', 'unreadable'],
]);
function pickerErrorKind(error) {
return PICKER_ERROR_KINDS.get(pickerErrorCode(error));
}
function pickerErrorDetails(error) {
return error?.rpcError?.details ?? error?.details;
}
@ -110,9 +121,9 @@ export function WorkspaceDirectoryPicker({
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 kind = pickerErrorKind(initial.error);
const details = pickerErrorDetails(initial.error);
if (code === 'directory-picker-unavailable'
if (kind === 'unavailable'
&& details?.capability === 'native'
&& typeof picker.pickDirectory === 'function') {
setLoading(true);
@ -128,7 +139,7 @@ export function WorkspaceDirectoryPicker({
}
return;
}
if (initialPath && code === 'directory-unreadable') {
if (initialPath && kind === 'unreadable') {
const home = await loadDirectory(undefined, { reportError: false });
if (!active || home.aborted || home.value) return;
setError(pickerErrorMessage(home.error));

View file

@ -46,23 +46,26 @@ function directoryListing(path, childNames = [], { home = '/workspace', truncate
};
}
function nativeUnavailable() {
function nativeUnavailable({
code = 'directory-picker/unavailable',
capability = 'native',
} = {}) {
const error = new Error('Directory browsing is unavailable');
error.rpcError = {
code: 'directory-picker-unavailable',
code,
message: error.message,
details: { capability: 'native' },
details: { capability },
};
return error;
}
function nativeDirectoryPicker(selected) {
function nativeDirectoryPicker(selected, unavailable = undefined) {
const calls = { list: 0, pick: 0 };
return {
calls,
async listDirectory() {
calls.list += 1;
throw nativeUnavailable();
throw nativeUnavailable(unavailable);
},
async pickDirectory() {
calls.pick += 1;
@ -256,7 +259,7 @@ test('WorkspaceEditor keeps the prior folder unselectable when a typed path cann
async listDirectory(path) {
if (path === missing) {
const error = new Error('cannot read requested folder');
error.rpcError = { code: 'directory-unreadable', message: error.message, details: { path } };
error.rpcError = { code: 'directory-picker/unreadable', message: error.message, details: { path } };
throw error;
}
return {
@ -387,6 +390,50 @@ test('WorkspaceEditor falls back to one native picker without restarting after s
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 0);
});
test('WorkspaceEditor keeps the legacy native unavailable code compatible', async () => {
const saved = [];
const picker = nativeDirectoryPicker('/workspace/legacy-native', {
code: 'directory-picker-unavailable',
});
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
directoryPicker: picker,
async onSave(value) { saved.push(value); },
}));
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
await flushMicrotasks();
});
assert.deepEqual(saved, ['/workspace/legacy-native']);
assert.deepEqual(picker.calls, { list: 1, pick: 1 });
});
test('WorkspaceEditor does not treat a non-native unavailable capability as native', async () => {
const picker = nativeDirectoryPicker('/workspace/must-not-pick', {
capability: 'browse',
});
let renderer;
await act(async () => {
renderer = create(React.createElement(WorkspaceEditor, {
workspace: '/workspace/current',
directoryPicker: picker,
async onSave() { throw new Error('must not save'); },
}));
});
await act(async () => {
renderer.root.findByProps({ className: 'dim-workspaceEdit' }).props.onClick();
await flushMicrotasks();
});
assert.deepEqual(picker.calls, { list: 1, pick: 0 });
assert.equal(renderer.root.findAllByProps({ role: 'alert' }).length, 1);
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 1);
});
test('WorkspaceEditor treats native picker cancellation as cancellation, not an error', async () => {
let saves = 0;
const picker = nativeDirectoryPicker(null);
@ -409,40 +456,42 @@ test('WorkspaceEditor treats native picker cancellation as cancellation, not an
assert.equal(renderer.root.findAllByProps({ role: 'dialog' }).length, 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();
});
for (const unreadableCode of ['directory-picker/unreadable', 'directory-unreadable']) {
test(`WorkspaceEditor falls back to the Host home for ${unreadableCode}`, 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: unreadableCode, 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(listed, ['/workspace/gone', undefined]);
await act(async () => {
renderer.root.findByProps({ className: 'dim-directoryPickerPrimary' }).props.onClick();
await flushMicrotasks();
});
assert.deepEqual(saved, ['/workspace']);
});
assert.deepEqual(saved, ['/workspace']);
});
}
test('WorkspaceEditor moves keyboard focus into and back out of the picker', async () => {
let dialogFocus = 0;