dsh-calorie 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -185,6 +185,27 @@ window.__ModuleLoader__.load({
185
185
  //#region src/dsh-ctx.ts
186
186
  /** 宿主目录选择命名空间的服务名(#736;出处与禁令见 cookbook §13)。 */
187
187
  const REMOTE_DIRECTORY_PICKER = "remote.directoryPicker";
188
+ /** 宿主「这条路没给」的回执码(#744):一次组合里只服务一种能力(native 或 browse),
189
+ * 另一条路上的动词一律回它。
190
+ *
191
+ * 出处:`@deepseek-ai/dsh-api-workspace-controller/lib/types/directory-picker.js` 的 `requireCapability()`
192
+ * ——`RemoteError('directory-picker/unavailable', …)`。三条动词在客户端命名空间上都在,
193
+ * 所以只有真调一次、看它拒没拒,才知道组合里服务哪种能力。
194
+ * 共用件不认识这个名字(只照比对),于是这条宿主事实住在本镜像里。 */
195
+ const DIRECTORY_PICKER_REFUSED = "directory-picker/unavailable";
196
+ /** 总管那条电话通道的两个字面量(#744):载体第一段与第二段。
197
+ *
198
+ * 出处:主管包 `packages/plugin-manager/src/update-contract.ts` 的 `MANAGER_RPC.base`/`.endpoint`
199
+ * ——那边是一处定义,这边是镜像(本包不许 import 总管的宿主半:会把 node 内建带进浏览器束)。
200
+ * 传错第一段就是 404:总管自己在 #678 栽过一次、各家的配置体检在 #735 又栽过一次。 */
201
+ const MANAGER_RPC_BASE = "/api";
202
+ const MANAGER_RPC_ENDPOINT = "ilife-manager";
203
+ /** 总管那通「本机根清单」电话名(#744):入参 `{}`,回包 `{roots: [{path, kind}]}`。
204
+ *
205
+ * 出处:主管包 `update-contract.ts` 的 `MANAGER_ACTIONS.roots`。为什么要走总管:
206
+ * 浏览器里没有卷清单接口,宿主那条目录选择接缝也只有 `list`/`createDirectory` 两格,
207
+ * 「这台机器上有哪些盘」只能由宿主去问操作系统——那份实现在总管宿主半的 `roots.ts`。 */
208
+ const MANAGER_ROOTS_METHOD = "ilife-manager.roots";
188
209
  //#endregion
189
210
  //#region ../plugin-manager/dist/directory-browser-contract.js
190
211
  /** 目录浏览器的纯逻辑半(**不认识宿主**:只认两格原语)。
@@ -212,10 +233,15 @@ window.__ModuleLoader__.load({
212
233
  if (typeof raw !== "object" || raw === null) return false;
213
234
  return typeof raw.pick === "function";
214
235
  }
215
- /** 三态判定:先 native(有 pick),再 browse(有两格原语),都没有=`none`。 */
236
+ /** 三态判定:**先看两格浏览原语**(都在=应用内浏览器这条路在),再看单独的 `pick`,都没有=`none`。
237
+ *
238
+ * 为什么浏览优先:客户端命名空间上三条动词一定都在(由 `TYPERT_REMOTE.descriptors` 生成,
239
+ * 与组合里服务哪种能力无关),所以两格原语在=这台宿主**可能**给浏览;真调一次才知道给不给,
240
+ * 不给({@link CAPABILITY_REFUSED})再由调用方换系统对话框。反过来先认 `pick` 就会在
241
+ * Desktop 这种「只服务 browse」的宿主上每次都去唤一次系统对话框、被拒一次(#744 实测)。 */
216
242
  function pickerModeOf$1(raw) {
217
- if (hasPickFn(raw)) return "native";
218
- return isBrowseFace(raw) ? "browse" : "none";
243
+ if (isBrowseFace(raw)) return "browse";
244
+ return hasPickFn(raw) ? "native" : "none";
219
245
  }
220
246
  /** 平台回执 → 三态(**永不抛**):成功回的是信封里的 `value` 不是路径;`ok:false` 是「供不了」不是「取消」。
221
247
  *
@@ -236,11 +262,52 @@ window.__ModuleLoader__.load({
236
262
  } : { kind: "cancelled" };
237
263
  }
238
264
  const detail = answer.error?.message?.trim() ?? "";
265
+ const code = answer.error?.code;
239
266
  return {
240
267
  kind: "unavailable",
268
+ ...typeof code === "string" && code !== "" ? { code } : {},
241
269
  message: "打不开系统文件夹对话框" + (detail === "" ? "" : "(" + detail + ")") + ":请直接在框里填绝对路径。"
242
270
  };
243
271
  }
272
+ /** 取数原语失败:带上宿主给的回执码,好让调用方分辨「没这条路」与「这一层读不出来」。
273
+ *
274
+ * `code` 就是 `humanize`(状态那半)读的那一格,人话直接上图。 */
275
+ var BrowseAnswerError = class extends Error {
276
+ code;
277
+ constructor(code, message) {
278
+ super(message);
279
+ this.name = "BrowseAnswerError";
280
+ this.code = code;
281
+ }
282
+ };
283
+ /** 宿主 browse 原语的**信封** → 值,或按信封里的 `error` 抛。
284
+ *
285
+ * 为什么必须走这一道:`list`/`createDirectory` 与 `pick` 一样是 Remote 动词,客户端拿到的是
286
+ * `{ok:true,value}`/`{ok:false,error}`(`@deepseek-ai/dsh-api-gateway/lib/client.js` 的 `invoke()`)。
287
+ * 把信封当值用,图上就会是一层「读出来了但一行都没有」的空目录(#744 实测),
288
+ * 而不是报错——所以宁可在这里抛,也不许把它当列举结果传下去。
289
+ * 第一方消费方同口径:`@deepseek-ai/dsh-client-ui-workspace/lib/client.js:105-113` 也是这么拆的。 */
290
+ function readBrowseAnswer(raw, what) {
291
+ const answer = typeof raw === "object" && raw !== null ? raw : {};
292
+ if (answer.ok === true && answer.value !== void 0 && answer.value !== null) return answer.value;
293
+ const detail = answer.error?.message?.trim() ?? "";
294
+ const code = answer.error?.code;
295
+ throw new BrowseAnswerError(typeof code === "string" && code !== "" ? code : "browse-failed", detail === "" ? what + "没有回执" : detail);
296
+ }
297
+ /** 宿主命名空间 → 本件的取数面:把两条原语的信封拆开,成功给值、失败按码抛。
298
+ *
299
+ * 命名空间上两格原语不在就回 null(调用方据此不画入口)。**透传参数个数照宿主的规矩**:
300
+ * 描述符声明了可选 `AbortSignal`(`cancellation: {parameter:'signal'}`),而客户端按
301
+ * `values.length === 业务参数个数 + 1` 判有没有 signal——显式传一个 `undefined` 会被当成 signal
302
+ * 去 `AbortSignal.any([…, undefined])` 而炸。本件的界面不做取消,故**从不传 signal**。 */
303
+ function browseFaceOf(raw) {
304
+ if (!isBrowseFace(raw)) return null;
305
+ const namespace = raw;
306
+ return {
307
+ list: async (path) => readBrowseAnswer(path === void 0 ? await namespace.list() : await namespace.list(path), "列举目录"),
308
+ createDirectory: async (path, name) => readBrowseAnswer(await namespace.createDirectory(path, name), "新建文件夹")
309
+ };
310
+ }
244
311
  /** 一段路径里最后那个分隔符的位置(Windows 上 `\` 与 `/` 都算;POSIX 上 `\` 是合法文件名字符,不算)。 */
245
312
  function lastSeparator(path) {
246
313
  const back = path.lastIndexOf("\\");
@@ -343,7 +410,8 @@ window.__ModuleLoader__.load({
343
410
  draft: deps.initialPath,
344
411
  filter: "",
345
412
  creating: null,
346
- notice: null
413
+ notice: null,
414
+ roots: []
347
415
  };
348
416
  /** 每次列举带一个序号:晚回来的旧回执直接丢掉,不许覆盖新一层。 */
349
417
  let generation = 0;
@@ -354,6 +422,18 @@ window.__ModuleLoader__.load({
354
422
  };
355
423
  for (const listener of [...listeners]) listener(state);
356
424
  };
425
+ /** 根清单只问一次:取不到就是空(不报错、不重试——它只是图上的一行入口)。
426
+ *
427
+ * 「问一次」钉在这里而不是图里:视图那半每次重画都会重新求值,问在这里才谈得上一次。 */
428
+ let rootsAsked = false;
429
+ const loadRoots = async () => {
430
+ if (rootsAsked || deps.rootsSource === void 0) return;
431
+ rootsAsked = true;
432
+ try {
433
+ const roots = await deps.rootsSource();
434
+ if (roots.length > 0) emit({ roots });
435
+ } catch {}
436
+ };
357
437
  const humanize = (cause) => {
358
438
  const raw = typeof cause === "object" && cause !== null ? cause : {};
359
439
  const message = typeof raw.message === "string" && raw.message.trim() !== "" ? raw.message.trim() : String(cause);
@@ -410,7 +490,16 @@ window.__ModuleLoader__.load({
410
490
  listeners.add(listener);
411
491
  return () => listeners.delete(listener);
412
492
  },
413
- open: () => load(null),
493
+ async open() {
494
+ loadRoots();
495
+ const wanted = deps.initialPath.trim();
496
+ if (wanted !== "") {
497
+ await load(wanted);
498
+ if (state.phase === "ready") return state;
499
+ }
500
+ await load(null);
501
+ return state;
502
+ },
414
503
  enter: (path) => load(path),
415
504
  async up() {
416
505
  const listing = state.listing;
@@ -506,10 +595,12 @@ window.__ModuleLoader__.load({
506
595
  initialPath: deps.initialPath,
507
596
  onPicked: deps.onPicked,
508
597
  onClose: () => deps.onClosed?.(),
509
- ...deps.failureLabel === void 0 ? {} : { failureLabel: deps.failureLabel }
598
+ ...deps.failureLabel === void 0 ? {} : { failureLabel: deps.failureLabel },
599
+ ...deps.rootsSource === void 0 ? {} : { rootsSource: deps.rootsSource }
510
600
  });
511
601
  return {
512
602
  open: () => controller.open(),
603
+ subscribe: (listener) => controller.subscribe(listener),
513
604
  state: () => controller.getState(),
514
605
  summary: () => describe(controller.getState()),
515
606
  actions: {
@@ -526,19 +617,99 @@ window.__ModuleLoader__.load({
526
617
  }
527
618
  };
528
619
  }
620
+ /** 一条目录行点下去该走哪条路(**六家共用这一处策略**,单独写在各家就会各错各的)。
621
+ *
622
+ * 顺序是「先应用内浏览,被拒再换系统对话框」,因为客户端命名空间上三条动词都在、问不出组合里服务哪种
623
+ * 能力(见 `pickerModeOf`)。回执:
624
+ * - `undefined`=连两格浏览原语都没有(这条路没有,调用方自己想办法);
625
+ * - `'refused'`=宿主回了 `refusalCode`(组合里是系统对话框),图已收起;
626
+ * - `'open'`=图开着,后面的事都在图里。
627
+ *
628
+ * **状态由调用方持有**:`onRow` 收到刚建好的那条(或 null=收起),React 那边据此决定画不画图。
629
+ *
630
+ * `refusalCode` 是**宿主的回执码**(各家自己从宿主镜像里拿,本件只照比不认名):
631
+ * 它换名字本件照样跑,六家一起换。 */
632
+ function openRowBrowser(input) {
633
+ const face = browseFaceOf(input.picker);
634
+ if (face === null) return void 0;
635
+ const row = createDirectoryRowBrowser({
636
+ face,
637
+ initialPath: input.initialPath,
638
+ onPicked: (picked) => {
639
+ input.onChange(picked);
640
+ input.onRow(null);
641
+ },
642
+ onClosed: () => input.onRow(null),
643
+ ...input.rootsSource === void 0 ? {} : { rootsSource: input.rootsSource }
644
+ });
645
+ input.onRow(row);
646
+ return row.open().then((settled) => {
647
+ if (settled.phase === "failed" && settled.failure?.code === input.refusalCode) {
648
+ input.onRow(null);
649
+ return "refused";
650
+ }
651
+ return "open";
652
+ });
653
+ }
529
654
  //#endregion
530
- //#region ../plugin-manager/dist/directory-browser-ui.js
531
- /** 文件浏览器的视图半:把 `BrowseState` 画成一张对话框。**不认识宿主**,也不自己做 IO。
655
+ //#region ../plugin-manager/dist/directory-browser-roots.js
656
+ /** 「本机根清单」的取数接线(客户端半):把一通电话变成图上那一行可跳转的根。
532
657
  *
533
- * 手写 `React.createElement`(本包 client 束禁 JSX,见 `tsdown.config.ts` 的既成口径)。
534
- * 只读状态、只回调;进哪一层、选到什么由操作半(`directory-browser-state.ts`)算。
658
+ * **不认识宿主**:载体基段、通道名(第二段)、电话名全由调用方传进来——各家从自己的宿主镜像里拿
659
+ * 那几个字面量(和 `openRowBrowser` 的 `refusalCode` 同一条规矩)。本件只做两件事:
660
+ * 发一次调用、把回执信封拆成根清单;**任何失败都当空清单**(图上少画一行,不报错、不挡路)。
535
661
  */
536
- const EMPTY_LISTING = {
537
- path: "",
538
- home: "",
539
- crumbs: [],
540
- entries: [],
541
- truncated: false
662
+ /** 认一认某个值像不像一个根(认不出的行丢掉:图的干净比多画一行重要)。 */
663
+ function isRootRow(raw) {
664
+ if (typeof raw !== "object" || raw === null) return false;
665
+ const row = raw;
666
+ return typeof row.path === "string" && row.path !== "" && typeof row.kind === "string";
667
+ }
668
+ /** 信封 → 根清单(纯函数,**永不抛**):`{ok:true, value:{roots:[…]}}` 之外的形状一律当空。 */
669
+ function readRootsAnswer(raw) {
670
+ const answer = typeof raw === "object" && raw !== null ? raw : {};
671
+ if (answer.ok !== true) return [];
672
+ const value = typeof answer.value === "object" && answer.value !== null ? answer.value : {};
673
+ if (!Array.isArray(value.roots)) return [];
674
+ return value.roots.filter(isRootRow).map((row) => ({
675
+ path: row.path,
676
+ kind: row.kind
677
+ }));
678
+ }
679
+ /** 建一个「根清单取数器」:给调用口与三个名字,回一个 `() => Promise<RootRow[]>`。
680
+ *
681
+ * 界面按需调它(开图一次),不在渲染里调,也不缓存到这里——缓存是宿主半的事。 */
682
+ function createRootsSource(input) {
683
+ return async () => {
684
+ const call = input.getCall();
685
+ if (typeof call !== "function") return [];
686
+ try {
687
+ return readRootsAnswer(await call(input.base, input.endpoint, {
688
+ method: input.method,
689
+ payload: {}
690
+ }));
691
+ } catch {
692
+ return [];
693
+ }
694
+ };
695
+ }
696
+ //#endregion
697
+ //#region ../plugin-manager/dist/directory-browser-parts.js
698
+ /** 文件浏览器视图半的**零件**:样式表与四行控件(面包屑/根/一行子目录/新建行)。
699
+ *
700
+ * 为什么拆出来:整张对话框的组版与这些零件的变化频率不同——零件跟着「图上多一行/少一行」动,
701
+ * 组版跟着「对话框长什么样」动。拆开之后两边都在 350 行告警线内,而对外面不变
702
+ * (`directory-browser-ui.ts` 照旧转出组件与两个接线函数)。
703
+ *
704
+ * 本件与视图半同样**不认识宿主**:只认状态与文案。
705
+ */
706
+ /** 根的类型说明(悬停可见):**本件自己的词汇**,与取数方无关。 */
707
+ const ROOT_KIND_TEXT = {
708
+ fixed: "固定磁盘",
709
+ network: "网络驱动器",
710
+ removable: "可移动磁盘",
711
+ optical: "光驱",
712
+ other: "磁盘"
542
713
  };
543
714
  const S$1 = {
544
715
  scrim: {
@@ -604,6 +775,24 @@ window.__ModuleLoader__.load({
604
775
  opacity: .45,
605
776
  fontSize: 12
606
777
  },
778
+ roots: {
779
+ display: "flex",
780
+ flexWrap: "wrap",
781
+ alignItems: "center",
782
+ gap: 6,
783
+ padding: "0 12px 8px",
784
+ fontSize: 12
785
+ },
786
+ rootsLabel: {
787
+ opacity: .72,
788
+ marginRight: 2
789
+ },
790
+ rootButton: {
791
+ border: "1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))",
792
+ borderRadius: 6,
793
+ padding: "3px 8px",
794
+ opacity: 1
795
+ },
607
796
  pathRow: {
608
797
  display: "flex",
609
798
  gap: 6,
@@ -682,7 +871,8 @@ window.__ModuleLoader__.load({
682
871
  },
683
872
  error: { color: "var(--dsw-alias-label-error, #e66)" }
684
873
  };
685
- function crumbRow(state, labels, onEnter) {
874
+ /** 面包屑:从文件系统根到当前层,每一格都是跳转目标(`listing.crumbs` 由取数方给)。 */
875
+ function crumbRow(state, onEnter) {
686
876
  const listing = state.listing;
687
877
  if (listing === null || listing.crumbs.length === 0) return null;
688
878
  const parts = [];
@@ -701,10 +891,34 @@ window.__ModuleLoader__.load({
701
891
  },
702
892
  title: crumb.path,
703
893
  onClick: () => onEnter(crumb.path)
704
- }, index === 0 ? labels.up : crumb.name));
894
+ }, crumb.name));
705
895
  });
706
896
  return react.createElement("div", { style: S$1.crumbs }, parts);
707
897
  }
898
+ /** 可跳转的根那一行(Windows 上的盘符等)。**少于两个根就不画**:一个根没有「跳到别处」可言。 */
899
+ function rootsRow(state, labels, onEnter) {
900
+ if (state.roots.length < 2) return null;
901
+ const parts = [react.createElement("span", {
902
+ key: "roots-label",
903
+ style: S$1.rootsLabel
904
+ }, labels.roots)];
905
+ for (const root of state.roots) parts.push(react.createElement("button", {
906
+ key: root.path,
907
+ type: "button",
908
+ style: {
909
+ ...S$1.chromeButton,
910
+ ...S$1.rootButton
911
+ },
912
+ title: ROOT_KIND_TEXT[root.kind] ?? ROOT_KIND_TEXT.other,
913
+ onClick: () => onEnter(root.path)
914
+ }, root.path));
915
+ return react.createElement("div", {
916
+ style: S$1.roots,
917
+ role: "group",
918
+ "aria-label": labels.roots
919
+ }, parts);
920
+ }
921
+ /** 一行子目录:点名字进它,点「选」把它当目标(「选」再点一次取消)。 */
708
922
  function entryRow(entry, selected, labels, onToggleSelect, onEnter) {
709
923
  return react.createElement("div", {
710
924
  key: entry.path,
@@ -733,6 +947,7 @@ window.__ModuleLoader__.load({
733
947
  onClick: () => onToggleSelect(entry.path)
734
948
  }, selected ? "✓" : labels.select));
735
949
  }
950
+ /** 「新建文件夹」那一行(`creatingName === null` 时收起)。 */
736
951
  function createRow(labels, onName, onCancel, onCreate, creatingName) {
737
952
  if (creatingName === null) return null;
738
953
  return react.createElement("div", { style: {
@@ -758,6 +973,21 @@ window.__ModuleLoader__.load({
758
973
  onClick: onCancel
759
974
  }, labels.createCancel));
760
975
  }
976
+ //#endregion
977
+ //#region ../plugin-manager/dist/directory-browser-ui.js
978
+ /** 文件浏览器的视图半:把 `BrowseState` 画成一张对话框。**不认识宿主**,也不自己做 IO。
979
+ *
980
+ * 手写 `React.createElement`(本包 client 束禁 JSX,见 `tsdown.config.ts` 的既成口径)。
981
+ * 只读状态、只回调;进哪一层、选到什么由操作半(`directory-browser-state.ts`)算。
982
+ * 样式与四行控件在 `directory-browser-parts.ts`(那里跟着「多一行/少一行」动,本件只管组版)。
983
+ */
984
+ const EMPTY_LISTING = {
985
+ path: "",
986
+ home: "",
987
+ crumbs: [],
988
+ entries: [],
989
+ truncated: false
990
+ };
761
991
  /** 画一张文件浏览器对话框。`open=false` 时返回 null(本件不挂 portal,由调用方决定挂在哪)。 */
762
992
  function DirectoryBrowser(props) {
763
993
  const { open, state, labels } = props;
@@ -767,7 +997,8 @@ window.__ModuleLoader__.load({
767
997
  const listing = state.listing ?? EMPTY_LISTING;
768
998
  const hidden = hiddenCount(listing);
769
999
  const body = [];
770
- body.push(crumbRow(state, labels, props.onEnter));
1000
+ body.push(crumbRow(state, props.onEnter));
1001
+ body.push(rootsRow(state, labels, props.onEnter));
771
1002
  body.push(react.createElement("div", { style: S$1.pathRow }, react.createElement("input", {
772
1003
  style: S$1.input,
773
1004
  value: state.draft,
@@ -848,11 +1079,15 @@ window.__ModuleLoader__.load({
848
1079
  /** 目录行接线的 React 半边:把操作半那组动作直接喂给组件。
849
1080
  *
850
1081
  * 操作半(`directory-browser-state.ts` 的 `createDirectoryRowBrowser`)不碰 React,
851
- * 这里只把它的 `actions` 摊进 props —— 于是「进哪一层」那套在 Node 里可测,这里只做接线。 */
1082
+ * 这里只把它的 `actions` 摊进 props —— 于是「进哪一层」那套在 Node 里可测,这里只做接线。
1083
+ *
1084
+ * `useSyncExternalStore` 是**必需**的:列举是异步落定的,不订阅的话这一帧画完就再没有重画,
1085
+ * 图上会永远停在「正在读取…」(#744 实测)。 */
852
1086
  function DirectoryBrowserFromRow(props) {
1087
+ const state = react.useSyncExternalStore(props.row.subscribe, props.row.state);
853
1088
  return DirectoryBrowser({
854
1089
  open: props.open,
855
- state: props.row.state(),
1090
+ state,
856
1091
  labels: props.labels,
857
1092
  ...props.row.actions
858
1093
  });
@@ -1339,23 +1574,6 @@ window.__ModuleLoader__.load({
1339
1574
  function directoryEntryMode(picker) {
1340
1575
  return picker === null ? "none" : pickerModeOf(picker);
1341
1576
  }
1342
- /** 开一行的应用内浏览器;返回 undefined=这条路供不了(调用方据此不画入口)。
1343
- *
1344
- * **状态由调用方持有**(`setBrowseRow`):本函数只造浏览器、开图,不碰 React 状态。 */
1345
- function openRowBrowser(input) {
1346
- if (input.picker === null || pickerModeOf(input.picker) !== "browse") return void 0;
1347
- const row = createDirectoryRowBrowser({
1348
- face: input.picker,
1349
- initialPath: input.path,
1350
- onPicked: (picked) => {
1351
- input.onChange(input.key, picked);
1352
- input.setBrowseRow(null);
1353
- },
1354
- onClosed: () => input.setBrowseRow(null)
1355
- });
1356
- input.setBrowseRow(row);
1357
- return row.open();
1358
- }
1359
1577
  /** 「选择文件夹」按钮的真装配函数(目录行渲染出来的按钮,onClick 就是它)。
1360
1578
  *
1361
1579
  * 回 Promise 是为了本地可判(用例直接 await);接进 React 时调用方 `void` 掉。 */
@@ -1474,34 +1692,44 @@ window.__ModuleLoader__.load({
1474
1692
  * 状态全部住在组件自己(hook 顺序与旧版同形):浏览器那一份只存「当前开着的那条」。 */
1475
1693
  const picker = pickerGone ? null : props.pickerSource();
1476
1694
  const [browseRow, setBrowseRow] = react.useState(null);
1477
- /** 目录行的入口动作:三态各一条路;都供不了就不给入口(`undefined` ⇒ Row 不画按钮)。 */
1478
- const onOpenRow = react.useCallback(async (key) => {
1479
- if (pickerModeOf(picker) === "native") {
1480
- setPicking(true);
1481
- setError(null);
1482
- const outcome = await pickDirectory(picker);
1483
- setPicking(false);
1484
- if (outcome.kind === "picked") onChange(key, outcome.path);
1485
- else if (outcome.kind === "unavailable") {
1486
- setError(outcome.message);
1487
- setPickerGone(true);
1488
- }
1489
- return;
1695
+ /** 唤起系统文件夹选择器那一条路(应用内浏览被拒时的兜底,也是命名空间只给 `pick` 时的唯一一条)。 */
1696
+ const openNative = react.useCallback(async (key) => {
1697
+ const face = pickerGone ? null : props.pickerSource();
1698
+ if (face === null) return;
1699
+ setPicking(true);
1700
+ setError(null);
1701
+ const outcome = await pickDirectory(face);
1702
+ setPicking(false);
1703
+ if (outcome.kind === "picked") onChange(key, outcome.path);
1704
+ else if (outcome.kind === "unavailable") {
1705
+ setError(outcome.message);
1706
+ setPickerGone(true);
1490
1707
  }
1708
+ }, [
1709
+ pickerGone,
1710
+ onChange,
1711
+ props.pickerSource
1712
+ ]);
1713
+ /** 目录行的入口动作:**先应用内浏览,宿主没给这条路再换系统对话框**(判断在共用件里)。
1714
+ *
1715
+ * 两条都供不了就不给入口(`undefined` 时 `rowEntry` 为 null ⇒ Row 不画按钮)。 */
1716
+ const onOpenRow = react.useCallback(async (key) => {
1717
+ setError(null);
1491
1718
  const opened = openRowBrowser({
1492
1719
  picker,
1493
- key,
1494
- path: draft[key] ?? "",
1495
- onChange,
1496
- setBrowseRow
1720
+ initialPath: draft[key] ?? "",
1721
+ onChange: (next) => onChange(key, next),
1722
+ onRow: setBrowseRow,
1723
+ refusalCode: DIRECTORY_PICKER_REFUSED,
1724
+ rootsSource: props.rootsSource
1497
1725
  });
1498
- if (opened === void 0) return;
1499
- setError(null);
1500
- await opened;
1726
+ if (opened === void 0 || await opened === "refused") await openNative(key);
1501
1727
  }, [
1502
1728
  picker,
1503
1729
  onChange,
1504
- draft
1730
+ draft,
1731
+ openNative,
1732
+ props.rootsSource
1505
1733
  ]);
1506
1734
  /** 给 Row 的三态入口:`none` 时给 null(不画按钮,文本框照旧)。 */
1507
1735
  const rowEntry = pickerModeOf(picker) === "none" ? null : {
@@ -1610,7 +1838,8 @@ window.__ModuleLoader__.load({
1610
1838
  selected: "已选",
1611
1839
  open: "选定这个目录",
1612
1840
  cancel: "取消",
1613
- willPick: "将选定:"
1841
+ willPick: "将选定:",
1842
+ roots: "其他磁盘:"
1614
1843
  }
1615
1844
  }) : null, notice !== null ? react.createElement("div", { style: S.okText }, notice) : null, error !== null ? react.createElement("div", { style: S.error }, error) : null, react.createElement(VersionLine, {
1616
1845
  pluginVersion: versions.plugin,
@@ -1628,7 +1857,13 @@ window.__ModuleLoader__.load({
1628
1857
  channel: RPC_CHANNEL
1629
1858
  }, () => react.createElement(CalorieConfig, {
1630
1859
  getCall,
1631
- pickerSource: getPicker
1860
+ pickerSource: getPicker,
1861
+ rootsSource: createRootsSource({
1862
+ getCall,
1863
+ base: MANAGER_RPC_BASE,
1864
+ endpoint: MANAGER_RPC_ENDPOINT,
1865
+ method: MANAGER_ROOTS_METHOD
1866
+ })
1632
1867
  })));
1633
1868
  const ensureWorkTab = () => {
1634
1869
  try {