dsh-memo-ilife 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
@@ -60,7 +60,7 @@ window.__ModuleLoader__.load({
60
60
  /** 版本行唯一来源(面板展示用;smoke 断言与两处 package.json 一致,防漂移)。
61
61
  * 发版 bump 时同步改这里(与 package.json 同值):0.3.2 那轮之前停在 0.2.1——
62
62
  * 面板自报家门报了旧号(#743 真机截图逮到),本票一并摆正。 */
63
- const PLUGIN_VERSION = "0.3.3";
63
+ const PLUGIN_VERSION = "0.3.7";
64
64
  const SKILL_VERSION = "0.3.0";
65
65
  //#endregion
66
66
  //#region src/settings.ts
@@ -160,6 +160,27 @@ window.__ModuleLoader__.load({
160
160
  //#region src/dsh-ctx.ts
161
161
  /** 宿主目录选择命名空间的服务名(#736;出处与禁令见 cookbook §13)。 */
162
162
  const REMOTE_DIRECTORY_PICKER = "remote.directoryPicker";
163
+ /** 宿主「这条路没给」的回执码(#744):一次组合里只服务一种能力(native 或 browse),
164
+ * 另一条路上的动词一律回它。
165
+ *
166
+ * 出处:`@deepseek-ai/dsh-api-workspace-controller/lib/types/directory-picker.js` 的 `requireCapability()`
167
+ * ——`RemoteError('directory-picker/unavailable', …)`。三条动词在客户端命名空间上都在,
168
+ * 所以只有真调一次、看它拒没拒,才知道组合里服务哪种能力。
169
+ * 共用件不认识这个名字(只照比对),于是这条宿主事实住在本镜像里。 */
170
+ const DIRECTORY_PICKER_REFUSED = "directory-picker/unavailable";
171
+ /** 总管那条电话通道的两个字面量(#744):载体第一段与第二段。
172
+ *
173
+ * 出处:主管包 `packages/plugin-manager/src/update-contract.ts` 的 `MANAGER_RPC.base`/`.endpoint`
174
+ * ——那边是一处定义,这边是镜像(本包不许 import 总管的宿主半:会把 node 内建带进浏览器束)。
175
+ * 传错第一段就是 404:总管自己在 #678 栽过一次、各家的配置体检在 #735 又栽过一次。 */
176
+ const MANAGER_RPC_BASE = "/api";
177
+ const MANAGER_RPC_ENDPOINT = "ilife-manager";
178
+ /** 总管那通「本机根清单」电话名(#744):入参 `{}`,回包 `{roots: [{path, kind}]}`。
179
+ *
180
+ * 出处:主管包 `update-contract.ts` 的 `MANAGER_ACTIONS.roots`。为什么要走总管:
181
+ * 浏览器里没有卷清单接口,宿主那条目录选择接缝也只有 `list`/`createDirectory` 两格,
182
+ * 「这台机器上有哪些盘」只能由宿主去问操作系统——那份实现在总管宿主半的 `roots.ts`。 */
183
+ const MANAGER_ROOTS_METHOD = "ilife-manager.roots";
163
184
  //#endregion
164
185
  //#region ../plugin-manager/dist/directory-browser-contract.js
165
186
  /** 目录浏览器的纯逻辑半(**不认识宿主**:只认两格原语)。
@@ -187,10 +208,15 @@ window.__ModuleLoader__.load({
187
208
  if (typeof raw !== "object" || raw === null) return false;
188
209
  return typeof raw.pick === "function";
189
210
  }
190
- /** 三态判定:先 native(有 pick),再 browse(有两格原语),都没有=`none`。 */
211
+ /** 三态判定:**先看两格浏览原语**(都在=应用内浏览器这条路在),再看单独的 `pick`,都没有=`none`。
212
+ *
213
+ * 为什么浏览优先:客户端命名空间上三条动词一定都在(由 `TYPERT_REMOTE.descriptors` 生成,
214
+ * 与组合里服务哪种能力无关),所以两格原语在=这台宿主**可能**给浏览;真调一次才知道给不给,
215
+ * 不给({@link CAPABILITY_REFUSED})再由调用方换系统对话框。反过来先认 `pick` 就会在
216
+ * Desktop 这种「只服务 browse」的宿主上每次都去唤一次系统对话框、被拒一次(#744 实测)。 */
191
217
  function pickerModeOf$1(raw) {
192
- if (hasPickFn(raw)) return "native";
193
- return isBrowseFace(raw) ? "browse" : "none";
218
+ if (isBrowseFace(raw)) return "browse";
219
+ return hasPickFn(raw) ? "native" : "none";
194
220
  }
195
221
  /** 平台回执 → 三态(**永不抛**):成功回的是信封里的 `value` 不是路径;`ok:false` 是「供不了」不是「取消」。
196
222
  *
@@ -211,11 +237,52 @@ window.__ModuleLoader__.load({
211
237
  } : { kind: "cancelled" };
212
238
  }
213
239
  const detail = answer.error?.message?.trim() ?? "";
240
+ const code = answer.error?.code;
214
241
  return {
215
242
  kind: "unavailable",
243
+ ...typeof code === "string" && code !== "" ? { code } : {},
216
244
  message: "打不开系统文件夹对话框" + (detail === "" ? "" : "(" + detail + ")") + ":请直接在框里填绝对路径。"
217
245
  };
218
246
  }
247
+ /** 取数原语失败:带上宿主给的回执码,好让调用方分辨「没这条路」与「这一层读不出来」。
248
+ *
249
+ * `code` 就是 `humanize`(状态那半)读的那一格,人话直接上图。 */
250
+ var BrowseAnswerError = class extends Error {
251
+ code;
252
+ constructor(code, message) {
253
+ super(message);
254
+ this.name = "BrowseAnswerError";
255
+ this.code = code;
256
+ }
257
+ };
258
+ /** 宿主 browse 原语的**信封** → 值,或按信封里的 `error` 抛。
259
+ *
260
+ * 为什么必须走这一道:`list`/`createDirectory` 与 `pick` 一样是 Remote 动词,客户端拿到的是
261
+ * `{ok:true,value}`/`{ok:false,error}`(`@deepseek-ai/dsh-api-gateway/lib/client.js` 的 `invoke()`)。
262
+ * 把信封当值用,图上就会是一层「读出来了但一行都没有」的空目录(#744 实测),
263
+ * 而不是报错——所以宁可在这里抛,也不许把它当列举结果传下去。
264
+ * 第一方消费方同口径:`@deepseek-ai/dsh-client-ui-workspace/lib/client.js:105-113` 也是这么拆的。 */
265
+ function readBrowseAnswer(raw, what) {
266
+ const answer = typeof raw === "object" && raw !== null ? raw : {};
267
+ if (answer.ok === true && answer.value !== void 0 && answer.value !== null) return answer.value;
268
+ const detail = answer.error?.message?.trim() ?? "";
269
+ const code = answer.error?.code;
270
+ throw new BrowseAnswerError(typeof code === "string" && code !== "" ? code : "browse-failed", detail === "" ? what + "没有回执" : detail);
271
+ }
272
+ /** 宿主命名空间 → 本件的取数面:把两条原语的信封拆开,成功给值、失败按码抛。
273
+ *
274
+ * 命名空间上两格原语不在就回 null(调用方据此不画入口)。**透传参数个数照宿主的规矩**:
275
+ * 描述符声明了可选 `AbortSignal`(`cancellation: {parameter:'signal'}`),而客户端按
276
+ * `values.length === 业务参数个数 + 1` 判有没有 signal——显式传一个 `undefined` 会被当成 signal
277
+ * 去 `AbortSignal.any([…, undefined])` 而炸。本件的界面不做取消,故**从不传 signal**。 */
278
+ function browseFaceOf(raw) {
279
+ if (!isBrowseFace(raw)) return null;
280
+ const namespace = raw;
281
+ return {
282
+ list: async (path) => readBrowseAnswer(path === void 0 ? await namespace.list() : await namespace.list(path), "列举目录"),
283
+ createDirectory: async (path, name) => readBrowseAnswer(await namespace.createDirectory(path, name), "新建文件夹")
284
+ };
285
+ }
219
286
  /** 一段路径里最后那个分隔符的位置(Windows 上 `\` 与 `/` 都算;POSIX 上 `\` 是合法文件名字符,不算)。 */
220
287
  function lastSeparator(path) {
221
288
  const back = path.lastIndexOf("\\");
@@ -318,7 +385,8 @@ window.__ModuleLoader__.load({
318
385
  draft: deps.initialPath,
319
386
  filter: "",
320
387
  creating: null,
321
- notice: null
388
+ notice: null,
389
+ roots: []
322
390
  };
323
391
  /** 每次列举带一个序号:晚回来的旧回执直接丢掉,不许覆盖新一层。 */
324
392
  let generation = 0;
@@ -329,6 +397,18 @@ window.__ModuleLoader__.load({
329
397
  };
330
398
  for (const listener of [...listeners]) listener(state);
331
399
  };
400
+ /** 根清单只问一次:取不到就是空(不报错、不重试——它只是图上的一行入口)。
401
+ *
402
+ * 「问一次」钉在这里而不是图里:视图那半每次重画都会重新求值,问在这里才谈得上一次。 */
403
+ let rootsAsked = false;
404
+ const loadRoots = async () => {
405
+ if (rootsAsked || deps.rootsSource === void 0) return;
406
+ rootsAsked = true;
407
+ try {
408
+ const roots = await deps.rootsSource();
409
+ if (roots.length > 0) emit({ roots });
410
+ } catch {}
411
+ };
332
412
  const humanize = (cause) => {
333
413
  const raw = typeof cause === "object" && cause !== null ? cause : {};
334
414
  const message = typeof raw.message === "string" && raw.message.trim() !== "" ? raw.message.trim() : String(cause);
@@ -385,7 +465,16 @@ window.__ModuleLoader__.load({
385
465
  listeners.add(listener);
386
466
  return () => listeners.delete(listener);
387
467
  },
388
- open: () => load(null),
468
+ async open() {
469
+ loadRoots();
470
+ const wanted = deps.initialPath.trim();
471
+ if (wanted !== "") {
472
+ await load(wanted);
473
+ if (state.phase === "ready") return state;
474
+ }
475
+ await load(null);
476
+ return state;
477
+ },
389
478
  enter: (path) => load(path),
390
479
  async up() {
391
480
  const listing = state.listing;
@@ -481,10 +570,12 @@ window.__ModuleLoader__.load({
481
570
  initialPath: deps.initialPath,
482
571
  onPicked: deps.onPicked,
483
572
  onClose: () => deps.onClosed?.(),
484
- ...deps.failureLabel === void 0 ? {} : { failureLabel: deps.failureLabel }
573
+ ...deps.failureLabel === void 0 ? {} : { failureLabel: deps.failureLabel },
574
+ ...deps.rootsSource === void 0 ? {} : { rootsSource: deps.rootsSource }
485
575
  });
486
576
  return {
487
577
  open: () => controller.open(),
578
+ subscribe: (listener) => controller.subscribe(listener),
488
579
  state: () => controller.getState(),
489
580
  summary: () => describe(controller.getState()),
490
581
  actions: {
@@ -501,19 +592,99 @@ window.__ModuleLoader__.load({
501
592
  }
502
593
  };
503
594
  }
595
+ /** 一条目录行点下去该走哪条路(**六家共用这一处策略**,单独写在各家就会各错各的)。
596
+ *
597
+ * 顺序是「先应用内浏览,被拒再换系统对话框」,因为客户端命名空间上三条动词都在、问不出组合里服务哪种
598
+ * 能力(见 `pickerModeOf`)。回执:
599
+ * - `undefined`=连两格浏览原语都没有(这条路没有,调用方自己想办法);
600
+ * - `'refused'`=宿主回了 `refusalCode`(组合里是系统对话框),图已收起;
601
+ * - `'open'`=图开着,后面的事都在图里。
602
+ *
603
+ * **状态由调用方持有**:`onRow` 收到刚建好的那条(或 null=收起),React 那边据此决定画不画图。
604
+ *
605
+ * `refusalCode` 是**宿主的回执码**(各家自己从宿主镜像里拿,本件只照比不认名):
606
+ * 它换名字本件照样跑,六家一起换。 */
607
+ function openRowBrowser(input) {
608
+ const face = browseFaceOf(input.picker);
609
+ if (face === null) return void 0;
610
+ const row = createDirectoryRowBrowser({
611
+ face,
612
+ initialPath: input.initialPath,
613
+ onPicked: (picked) => {
614
+ input.onChange(picked);
615
+ input.onRow(null);
616
+ },
617
+ onClosed: () => input.onRow(null),
618
+ ...input.rootsSource === void 0 ? {} : { rootsSource: input.rootsSource }
619
+ });
620
+ input.onRow(row);
621
+ return row.open().then((settled) => {
622
+ if (settled.phase === "failed" && settled.failure?.code === input.refusalCode) {
623
+ input.onRow(null);
624
+ return "refused";
625
+ }
626
+ return "open";
627
+ });
628
+ }
504
629
  //#endregion
505
- //#region ../plugin-manager/dist/directory-browser-ui.js
506
- /** 文件浏览器的视图半:把 `BrowseState` 画成一张对话框。**不认识宿主**,也不自己做 IO。
630
+ //#region ../plugin-manager/dist/directory-browser-roots.js
631
+ /** 「本机根清单」的取数接线(客户端半):把一通电话变成图上那一行可跳转的根。
507
632
  *
508
- * 手写 `React.createElement`(本包 client 束禁 JSX,见 `tsdown.config.ts` 的既成口径)。
509
- * 只读状态、只回调;进哪一层、选到什么由操作半(`directory-browser-state.ts`)算。
633
+ * **不认识宿主**:载体基段、通道名(第二段)、电话名全由调用方传进来——各家从自己的宿主镜像里拿
634
+ * 那几个字面量(和 `openRowBrowser` 的 `refusalCode` 同一条规矩)。本件只做两件事:
635
+ * 发一次调用、把回执信封拆成根清单;**任何失败都当空清单**(图上少画一行,不报错、不挡路)。
510
636
  */
511
- const EMPTY_LISTING = {
512
- path: "",
513
- home: "",
514
- crumbs: [],
515
- entries: [],
516
- truncated: false
637
+ /** 认一认某个值像不像一个根(认不出的行丢掉:图的干净比多画一行重要)。 */
638
+ function isRootRow(raw) {
639
+ if (typeof raw !== "object" || raw === null) return false;
640
+ const row = raw;
641
+ return typeof row.path === "string" && row.path !== "" && typeof row.kind === "string";
642
+ }
643
+ /** 信封 → 根清单(纯函数,**永不抛**):`{ok:true, value:{roots:[…]}}` 之外的形状一律当空。 */
644
+ function readRootsAnswer(raw) {
645
+ const answer = typeof raw === "object" && raw !== null ? raw : {};
646
+ if (answer.ok !== true) return [];
647
+ const value = typeof answer.value === "object" && answer.value !== null ? answer.value : {};
648
+ if (!Array.isArray(value.roots)) return [];
649
+ return value.roots.filter(isRootRow).map((row) => ({
650
+ path: row.path,
651
+ kind: row.kind
652
+ }));
653
+ }
654
+ /** 建一个「根清单取数器」:给调用口与三个名字,回一个 `() => Promise<RootRow[]>`。
655
+ *
656
+ * 界面按需调它(开图一次),不在渲染里调,也不缓存到这里——缓存是宿主半的事。 */
657
+ function createRootsSource(input) {
658
+ return async () => {
659
+ const call = input.getCall();
660
+ if (typeof call !== "function") return [];
661
+ try {
662
+ return readRootsAnswer(await call(input.base, input.endpoint, {
663
+ method: input.method,
664
+ payload: {}
665
+ }));
666
+ } catch {
667
+ return [];
668
+ }
669
+ };
670
+ }
671
+ //#endregion
672
+ //#region ../plugin-manager/dist/directory-browser-parts.js
673
+ /** 文件浏览器视图半的**零件**:样式表与四行控件(面包屑/根/一行子目录/新建行)。
674
+ *
675
+ * 为什么拆出来:整张对话框的组版与这些零件的变化频率不同——零件跟着「图上多一行/少一行」动,
676
+ * 组版跟着「对话框长什么样」动。拆开之后两边都在 350 行告警线内,而对外面不变
677
+ * (`directory-browser-ui.ts` 照旧转出组件与两个接线函数)。
678
+ *
679
+ * 本件与视图半同样**不认识宿主**:只认状态与文案。
680
+ */
681
+ /** 根的类型说明(悬停可见):**本件自己的词汇**,与取数方无关。 */
682
+ const ROOT_KIND_TEXT = {
683
+ fixed: "固定磁盘",
684
+ network: "网络驱动器",
685
+ removable: "可移动磁盘",
686
+ optical: "光驱",
687
+ other: "磁盘"
517
688
  };
518
689
  const S$1 = {
519
690
  scrim: {
@@ -579,6 +750,24 @@ window.__ModuleLoader__.load({
579
750
  opacity: .45,
580
751
  fontSize: 12
581
752
  },
753
+ roots: {
754
+ display: "flex",
755
+ flexWrap: "wrap",
756
+ alignItems: "center",
757
+ gap: 6,
758
+ padding: "0 12px 8px",
759
+ fontSize: 12
760
+ },
761
+ rootsLabel: {
762
+ opacity: .72,
763
+ marginRight: 2
764
+ },
765
+ rootButton: {
766
+ border: "1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.35))",
767
+ borderRadius: 6,
768
+ padding: "3px 8px",
769
+ opacity: 1
770
+ },
582
771
  pathRow: {
583
772
  display: "flex",
584
773
  gap: 6,
@@ -657,7 +846,8 @@ window.__ModuleLoader__.load({
657
846
  },
658
847
  error: { color: "var(--dsw-alias-label-error, #e66)" }
659
848
  };
660
- function crumbRow(state, labels, onEnter) {
849
+ /** 面包屑:从文件系统根到当前层,每一格都是跳转目标(`listing.crumbs` 由取数方给)。 */
850
+ function crumbRow(state, onEnter) {
661
851
  const listing = state.listing;
662
852
  if (listing === null || listing.crumbs.length === 0) return null;
663
853
  const parts = [];
@@ -676,10 +866,34 @@ window.__ModuleLoader__.load({
676
866
  },
677
867
  title: crumb.path,
678
868
  onClick: () => onEnter(crumb.path)
679
- }, index === 0 ? labels.up : crumb.name));
869
+ }, crumb.name));
680
870
  });
681
871
  return react.createElement("div", { style: S$1.crumbs }, parts);
682
872
  }
873
+ /** 可跳转的根那一行(Windows 上的盘符等)。**少于两个根就不画**:一个根没有「跳到别处」可言。 */
874
+ function rootsRow(state, labels, onEnter) {
875
+ if (state.roots.length < 2) return null;
876
+ const parts = [react.createElement("span", {
877
+ key: "roots-label",
878
+ style: S$1.rootsLabel
879
+ }, labels.roots)];
880
+ for (const root of state.roots) parts.push(react.createElement("button", {
881
+ key: root.path,
882
+ type: "button",
883
+ style: {
884
+ ...S$1.chromeButton,
885
+ ...S$1.rootButton
886
+ },
887
+ title: ROOT_KIND_TEXT[root.kind] ?? ROOT_KIND_TEXT.other,
888
+ onClick: () => onEnter(root.path)
889
+ }, root.path));
890
+ return react.createElement("div", {
891
+ style: S$1.roots,
892
+ role: "group",
893
+ "aria-label": labels.roots
894
+ }, parts);
895
+ }
896
+ /** 一行子目录:点名字进它,点「选」把它当目标(「选」再点一次取消)。 */
683
897
  function entryRow(entry, selected, labels, onToggleSelect, onEnter) {
684
898
  return react.createElement("div", {
685
899
  key: entry.path,
@@ -708,6 +922,7 @@ window.__ModuleLoader__.load({
708
922
  onClick: () => onToggleSelect(entry.path)
709
923
  }, selected ? "✓" : labels.select));
710
924
  }
925
+ /** 「新建文件夹」那一行(`creatingName === null` 时收起)。 */
711
926
  function createRow(labels, onName, onCancel, onCreate, creatingName) {
712
927
  if (creatingName === null) return null;
713
928
  return react.createElement("div", { style: {
@@ -733,6 +948,21 @@ window.__ModuleLoader__.load({
733
948
  onClick: onCancel
734
949
  }, labels.createCancel));
735
950
  }
951
+ //#endregion
952
+ //#region ../plugin-manager/dist/directory-browser-ui.js
953
+ /** 文件浏览器的视图半:把 `BrowseState` 画成一张对话框。**不认识宿主**,也不自己做 IO。
954
+ *
955
+ * 手写 `React.createElement`(本包 client 束禁 JSX,见 `tsdown.config.ts` 的既成口径)。
956
+ * 只读状态、只回调;进哪一层、选到什么由操作半(`directory-browser-state.ts`)算。
957
+ * 样式与四行控件在 `directory-browser-parts.ts`(那里跟着「多一行/少一行」动,本件只管组版)。
958
+ */
959
+ const EMPTY_LISTING = {
960
+ path: "",
961
+ home: "",
962
+ crumbs: [],
963
+ entries: [],
964
+ truncated: false
965
+ };
736
966
  /** 画一张文件浏览器对话框。`open=false` 时返回 null(本件不挂 portal,由调用方决定挂在哪)。 */
737
967
  function DirectoryBrowser(props) {
738
968
  const { open, state, labels } = props;
@@ -742,7 +972,8 @@ window.__ModuleLoader__.load({
742
972
  const listing = state.listing ?? EMPTY_LISTING;
743
973
  const hidden = hiddenCount(listing);
744
974
  const body = [];
745
- body.push(crumbRow(state, labels, props.onEnter));
975
+ body.push(crumbRow(state, props.onEnter));
976
+ body.push(rootsRow(state, labels, props.onEnter));
746
977
  body.push(react.createElement("div", { style: S$1.pathRow }, react.createElement("input", {
747
978
  style: S$1.input,
748
979
  value: state.draft,
@@ -823,11 +1054,15 @@ window.__ModuleLoader__.load({
823
1054
  /** 目录行接线的 React 半边:把操作半那组动作直接喂给组件。
824
1055
  *
825
1056
  * 操作半(`directory-browser-state.ts` 的 `createDirectoryRowBrowser`)不碰 React,
826
- * 这里只把它的 `actions` 摊进 props —— 于是「进哪一层」那套在 Node 里可测,这里只做接线。 */
1057
+ * 这里只把它的 `actions` 摊进 props —— 于是「进哪一层」那套在 Node 里可测,这里只做接线。
1058
+ *
1059
+ * `useSyncExternalStore` 是**必需**的:列举是异步落定的,不订阅的话这一帧画完就再没有重画,
1060
+ * 图上会永远停在「正在读取…」(#744 实测)。 */
827
1061
  function DirectoryBrowserFromRow(props) {
1062
+ const state = react.useSyncExternalStore(props.row.subscribe, props.row.state);
828
1063
  return DirectoryBrowser({
829
1064
  open: props.open,
830
- state: props.row.state(),
1065
+ state,
831
1066
  labels: props.labels,
832
1067
  ...props.row.actions
833
1068
  });
@@ -1223,23 +1458,6 @@ window.__ModuleLoader__.load({
1223
1458
  function directoryEntryMode(picker) {
1224
1459
  return picker === null ? "none" : pickerModeOf(picker);
1225
1460
  }
1226
- /** 开一行的应用内浏览器;返回 undefined=这条路供不了(调用方据此不画入口)。
1227
- *
1228
- * **状态由调用方持有**(`setBrowseRow`):本函数只造浏览器、开图,不碰 React 状态。 */
1229
- function openRowBrowser(input) {
1230
- if (input.picker === null || pickerModeOf(input.picker) !== "browse") return void 0;
1231
- const row = createDirectoryRowBrowser({
1232
- face: input.picker,
1233
- initialPath: input.path,
1234
- onPicked: (picked) => {
1235
- input.onChange(input.key, picked);
1236
- input.setBrowseRow(null);
1237
- },
1238
- onClosed: () => input.setBrowseRow(null)
1239
- });
1240
- input.setBrowseRow(row);
1241
- return row.open();
1242
- }
1243
1461
  /** 「选择文件夹」按钮的真装配函数(目录行渲染出来的按钮,onClick 就是它)。
1244
1462
  *
1245
1463
  * 回 Promise 是为了本地可判(用例直接 await);接进 React 时调用方 `void` 掉。 */
@@ -1337,34 +1555,44 @@ window.__ModuleLoader__.load({
1337
1555
  * 状态全部住在组件自己(hook 顺序与旧版同形):浏览器那一份只存「当前开着的那条」。 */
1338
1556
  const picker = pickerGone ? null : props.pickerSource();
1339
1557
  const [browseRow, setBrowseRow] = react.useState(null);
1340
- /** 目录行的入口动作:三态各一条路;都供不了就不给入口(`undefined` ⇒ Row 不画按钮)。 */
1341
- const onOpenRow = react.useCallback(async (key) => {
1342
- if (pickerModeOf(picker) === "native") {
1343
- setPicking(true);
1344
- setError(null);
1345
- const outcome = await pickDirectory(picker);
1346
- setPicking(false);
1347
- if (outcome.kind === "picked") onChange(key, outcome.path);
1348
- else if (outcome.kind === "unavailable") {
1349
- setError(outcome.message);
1350
- setPickerGone(true);
1351
- }
1352
- return;
1558
+ /** 唤起系统文件夹选择器那一条路(应用内浏览被拒时的兜底,也是命名空间只给 `pick` 时的唯一一条)。 */
1559
+ const openNative = react.useCallback(async (key) => {
1560
+ const face = pickerGone ? null : props.pickerSource();
1561
+ if (face === null) return;
1562
+ setPicking(true);
1563
+ setError(null);
1564
+ const outcome = await pickDirectory(face);
1565
+ setPicking(false);
1566
+ if (outcome.kind === "picked") onChange(key, outcome.path);
1567
+ else if (outcome.kind === "unavailable") {
1568
+ setError(outcome.message);
1569
+ setPickerGone(true);
1353
1570
  }
1571
+ }, [
1572
+ pickerGone,
1573
+ onChange,
1574
+ props.pickerSource
1575
+ ]);
1576
+ /** 目录行的入口动作:**先应用内浏览,宿主没给这条路再换系统对话框**(判断在共用件里)。
1577
+ *
1578
+ * 两条都供不了就不给入口(`undefined` 时 `rowEntry` 为 null ⇒ Row 不画按钮)。 */
1579
+ const onOpenRow = react.useCallback(async (key) => {
1580
+ setError(null);
1354
1581
  const opened = openRowBrowser({
1355
1582
  picker,
1356
- key,
1357
- path: draft[key] ?? "",
1358
- onChange,
1359
- setBrowseRow
1583
+ initialPath: draft[key] ?? "",
1584
+ onChange: (next) => onChange(key, next),
1585
+ onRow: setBrowseRow,
1586
+ refusalCode: DIRECTORY_PICKER_REFUSED,
1587
+ rootsSource: props.rootsSource
1360
1588
  });
1361
- if (opened === void 0) return;
1362
- setError(null);
1363
- await opened;
1589
+ if (opened === void 0 || await opened === "refused") await openNative(key);
1364
1590
  }, [
1365
1591
  picker,
1366
1592
  onChange,
1367
- draft
1593
+ draft,
1594
+ openNative,
1595
+ props.rootsSource
1368
1596
  ]);
1369
1597
  /** 给 Row 的三态入口:`none` 时给 null(不画按钮,文本框照旧)。 */
1370
1598
  const rowEntry = pickerModeOf(picker) === "none" ? null : {
@@ -1465,7 +1693,8 @@ window.__ModuleLoader__.load({
1465
1693
  selected: "已选",
1466
1694
  open: "选定这个目录",
1467
1695
  cancel: "取消",
1468
- willPick: "将选定:"
1696
+ willPick: "将选定:",
1697
+ roots: "其他磁盘:"
1469
1698
  }
1470
1699
  }) : null, notice !== null ? react.createElement("div", { style: S.okText }, notice) : null, error !== null ? react.createElement("div", { style: S.error }, error) : null, react.createElement(VersionLine, null));
1471
1700
  }
@@ -1480,7 +1709,13 @@ window.__ModuleLoader__.load({
1480
1709
  channel: RPC_CHANNEL
1481
1710
  }, () => react.createElement(MemoConfig, {
1482
1711
  getCall,
1483
- pickerSource: getPicker
1712
+ pickerSource: getPicker,
1713
+ rootsSource: createRootsSource({
1714
+ getCall,
1715
+ base: MANAGER_RPC_BASE,
1716
+ endpoint: MANAGER_RPC_ENDPOINT,
1717
+ method: MANAGER_ROOTS_METHOD
1718
+ })
1484
1719
  })));
1485
1720
  const ensureWorkTab = () => {
1486
1721
  try {