dsh-vscode-mode 0.25.0 → 0.25.1

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/README.md CHANGED
@@ -274,7 +274,9 @@ cmaps 支持中文渲染);工具条进入注释编辑:✎ 文本框 / 🖌
274
274
  (在集成终端中打开 / 运行测试 / Build 等)不显示。
275
275
  - **下载到本机**(仅远程访问时出现,落在「复制相对路径」与「重命名」之间):
276
276
  文件直接下载原文件;文件夹与根空白区弹出确认框,按需懒加载展开目录树、
277
- 逐级勾选文件后逐个下载(不打包,勾选几个就下几个)。判定口径与页面的
277
+ 逐级勾选文件后逐个下载(不打包,勾选几个就下几个)。点下载后先选保存文件夹
278
+ (Chrome/Edge 安全上下文下用目录选择器;Firefox/Safari 或非 https 回落浏览器
279
+ 默认下载目录并提示),文件按工作区相对路径写入。判定口径与页面的
278
280
  远程通道一致(`channelPrefix()` 非空),本机回环页面不显示。
279
281
  - 文件管理写操作走 `edrv.fsCreateFile / fsCreateDir / fsRename / fsDelete /
280
282
  fsCopy / fsMove`(工作区边界 + 同名拒绝 + 名称校验),删除带确认;重命名 /
@@ -576,13 +578,13 @@ profile 请替换为实际目标,不要把示例目录当成当前运行 profi
576
578
 
577
579
  ```bash
578
580
  # ① Git 安装(clone + prepare 构建;推荐打固定 tag)
579
- dsh plugin --profile web add github:Lenonss/DSH_VsCodeMode#v0.25.0
581
+ dsh plugin --profile web add github:Lenonss/DSH_VsCodeMode#v0.25.1
580
582
 
581
583
  # ② npm 注册表(发布到 npm 后)
582
- dsh plugin --profile web add dsh-vscode-mode@0.25.0
584
+ dsh plugin --profile web add dsh-vscode-mode@0.25.1
583
585
 
584
586
  # ③ GitHub Release tgz 直装(Release asset 可用后)
585
- dsh plugin --profile web add https://github.com/Lenonss/DSH_VsCodeMode/releases/download/v0.25.0/dsh-vscode-mode-0.25.0.tgz
587
+ dsh plugin --profile web add https://github.com/Lenonss/DSH_VsCodeMode/releases/download/v0.25.1/dsh-vscode-mode-0.25.1.tgz
586
588
  ```
587
589
 
588
590
  > **DSH 版本政策(0.17.0 起)**:六项 DSH peer 仍统一声明为 `*`,另以
@@ -962,6 +964,7 @@ Keep All / Undo All 却是亮的。修复:单文件 Keep / Undo 覆盖冲突
962
964
 
963
965
  | 版本 | 要点 |
964
966
  | --- | --- |
967
+ | v0.25.1 | 下载到本机支持选择保存文件夹(目录选择器),按工作区相对路径写入 |
965
968
  | v0.25.0 | 资源管理器右键「下载到本机」:远程访问时把工作区文件拉回浏览器所在机器 |
966
969
  | v0.24.0 | 经第三方远程访问插件时,文件管理器与 Monaco 恢复可用;加载失败提示按状态区分 |
967
970
  | v0.23.5 | 同一文件不再算成两条差异;新建文件按整文件新增渲染 |
@@ -972,8 +975,9 @@ Keep All / Undo All 却是亮的。修复:单文件 Keep / Undo 覆盖冲突
972
975
  | v0.23.0 | 快捷菜单:可拖动悬浮按钮 + 自定义菜单树 |
973
976
 
974
977
  <details>
975
- <summary>完整版本记录(v0.25.0 → v0.1.5x,点击展开)</summary>
978
+ <summary>完整版本记录(v0.25.1 → v0.1.5x,点击展开)</summary>
976
979
 
980
+ - **v0.25.1**:下载到本机支持选择保存位置——点「下载」先弹系统目录选择器(File System Access API:Chrome/Edge、https 或 localhost 安全上下文),选一次目标文件夹后,文件按**工作区相对路径**逐个写入(文件夹下载自动建子目录,仍不打包)。用户取消选择则整批中止并提示「已取消下载」;Firefox/Safari、局域网 http、或目录写入权限被拒时自动回落浏览器默认下载目录,并分别提示具体原因,不静默改语义。确认框底栏按钮改为「选择位置并下载 N 个文件」并加一行说明;选择器在用户手势的同步栈内触发,不依赖额外点击。验证:typecheck 0 error;全量 288 文件、3733 通过 + 22 跳过;build 四 bundle 通过;本机冒烟覆盖 4 条分支(取消 / 拒绝授权回落 / 选中目录按 `tests/officialBackfill.test.ts` 相对路径写 4328 B / 文件夹流程写 `.codegraph/.gitignore` 229 B)。见 [0.25.1 发布说明](docs/release-0.25.1.md)。
977
981
  - **v0.25.0**:新增远程下载——经远程访问插件打开 DSH 时,工作区文件原本只能到远端机器上自己下载再传回,现在资源管理器右键菜单新增「下载到本机」(仅远程访问页可见,本机回环不出现):文件右键直接下载原文件;文件夹与根空白区弹出确认框,按需懒加载展开目录树、逐级勾选文件后逐个下载原文件(不打包)。取字节复用既有链路(`edrv.readBinary` 二进制直出,远程代理剥掉 `x-edrv-*` 时自动回落 `edrv.read` 的 base64 分支),点选的文件名与远端一致,超过 32MB 的文件在弹窗内标明并禁用、不静默失败;确认框复用 `ModalShell`(自动满足 `data-edrv-view` 浮层契约与 z-index 阶梯),目录整行可展开、点文件名也能勾选、加载失败行带重试。可见性判据与 v0.24.0 的远程通道同源(`src/shared/remoteChannel.ts` 新增 `isRemoteChannel()`,即 `channelPrefix()` 非空),非远程页面完全不出现该条目、打开菜单也不发请求。不新增 host 路由、不改鉴权、不动既有导出实现。验证:typecheck 0 error;全量 287 文件、3715 通过 + 22 跳过;`node --test tests/nativeOpen.test.mjs` 9/9;build 四 bundle 通过;本机 GUI 注入远程座位后冒烟:菜单出现且位置正确、单文件下载落盘名正确、根/文件夹弹窗展开 105 行、单选 1 / 全选已展开 82 / 清空 0、勾选 2 个文件逐个下载成功(`edrv.readBinary` ×2),干净刷新后控制台无新增报错;**真实远程设备(真插件 + 真配对)未验收,缺口保留**。见 [0.25.0 发布说明](docs/release-0.25.0.md)。
978
982
 
979
983
  - **v0.24.0**:修复经第三方远程访问插件(如 `@linxin666/dsh-remote-web-ui`)访问 DSH Web GUI 时,文件管理器报「DSH 拒绝了当前页面的访问」、编辑器报「Monaco loader 加载失败」。根因在宿主准入顺序:`connection.requestRejection()` 先用 `isTrustedApiRequest` 判 Host/Origin,不属于回环、监听地址或 `trustedHosts` 就**直接 403**,根本不看登录态(401 才是认证失败),而本插件的 `/edrv/rpc` 与 `/edrv/vendor/**` 都不在远程插件的改写白名单里,于是 RPC、Monaco 的脚本/CSS/字体/Worker、pdf.js 模块 import 与角标图片全部直连宿主被拒。现按远程通道选路径前缀:`window.__DSH_REMOTE_CHANNEL_BOOT__` 存在(远程插件在 `<head>` 解析期安装的座位对象)时,所有插件内部路径改挂其已接管的 `/remote` catch-all(`/remote/edrv/...`),本地与桌面入口保持 `/edrv/...` 原样。新增 `src/shared/remoteChannel.ts`(`channelPrefix` / `transportPath` / `transportUrl`,仍经 `appUrl` 继承应用基址与 `dsh-app:`),RPC、二进制预览、Monaco 基址、pdf.js 基址与角标图片统一走它;**服务端零改动**,不新增路由、不取消鉴权、不读也不写任何凭据,`/remote` 门禁仍要求已配对设备。另新增 `src/client/loadFailure.ts`:加载失败先 HEAD 探测真实状态,403(Host 围栏)/401(登录失效)/404(资源或路由缺失)/0(请求未到达宿主)分别给不同提示,且只带 pathname 与状态码;设备未配对(403 `code:'unpaired'`)直接提示去配对,不再把所有 Monaco 失败都说成「检查资源是否完整」。验证:typecheck 0 error;全量 284 文件、3691 通过 + 22 跳过;`node --test tests/nativeOpen.test.mjs` 9/9;build 四 bundle 通过;本机 GUI 全链 200 并确认无 cookie 仍 401;远程通道用本地回环代理复刻转发语义后,138/138 请求落 `/remote/edrv/…`、目录/读/写/保存与 Monaco+Worker 全部通;**真机 + 真插件的配对门禁与 TLS 未验收(缺口保留)**。见 [0.24.0 发布说明](docs/release-0.24.0.md)。
package/lib/client.js CHANGED
@@ -411139,6 +411139,107 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411139
411139
  function clearFileClip() {
411140
411140
  clip = null;
411141
411141
  }
411142
+ //#endregion
411143
+ //#region src/client/downloadSink.ts
411144
+ /**
411145
+ * 让用户选择保存目录。
411146
+ * @author ddj 2026年11月03号
411147
+ * @returns 所选目录 / 回落原因 / 'cancelled'(用户取消,整批终止)
411148
+ * @sideEffects 弹出系统目录选择器,成功后可能触发一次写权限请求
411149
+ */
411150
+ async function pickSaveSink() {
411151
+ if (globalThis.isSecureContext === false) return {
411152
+ kind: "browser",
411153
+ reason: "insecure"
411154
+ };
411155
+ const picker = pickerOf();
411156
+ if (!picker) return {
411157
+ kind: "browser",
411158
+ reason: "unsupported"
411159
+ };
411160
+ let dir;
411161
+ try {
411162
+ dir = await picker({
411163
+ id: "vsm-download",
411164
+ mode: "readwrite",
411165
+ startIn: "downloads"
411166
+ });
411167
+ } catch (error) {
411168
+ return isAbort(error) ? "cancelled" : {
411169
+ kind: "browser",
411170
+ reason: "denied"
411171
+ };
411172
+ }
411173
+ return await ensureWritable(dir) ? {
411174
+ kind: "dir",
411175
+ dir
411176
+ } : {
411177
+ kind: "browser",
411178
+ reason: "denied"
411179
+ };
411180
+ }
411181
+ /**
411182
+ * 取回 `showDirectoryPicker`(不安全上下文视为没有)。
411183
+ * @author ddj 2026年11月03号
411184
+ * @returns 绑定到 globalThis 的选目录函数;不可用为 undefined
411185
+ */
411186
+ function pickerOf() {
411187
+ const win = globalThis;
411188
+ if (win.isSecureContext === false) return void 0;
411189
+ return typeof win.showDirectoryPicker === "function" ? win.showDirectoryPicker.bind(win) : void 0;
411190
+ }
411191
+ /**
411192
+ * 确认目录可写(部分浏览器选择后仍需显式请求一次 readwrite 权限)。
411193
+ * @author ddj 2026年11月03号
411194
+ * @param dir 目录句柄
411195
+ * @returns 已获得写权限为 true
411196
+ * @throws 权限查询本身抛错时向上透传(由调用方按回落处理)
411197
+ */
411198
+ async function ensureWritable(dir) {
411199
+ if (!dir.queryPermission) return true;
411200
+ if (await dir.queryPermission({ mode: "readwrite" }) === "granted") return true;
411201
+ if (!dir.requestPermission) return false;
411202
+ return await dir.requestPermission({ mode: "readwrite" }) === "granted";
411203
+ }
411204
+ /**
411205
+ * 把字节写到所选目录下的相对路径处(自动逐级建子目录)。
411206
+ * @author ddj 2026年11月03号
411207
+ * @param dir 目标目录句柄
411208
+ * @param relPath 工作区相对路径(分隔符 `/`;越界段 `..` 会被丢弃)
411209
+ * @param bytes 原始字节
411210
+ * @sideEffects 在用户所选目录内创建目录与文件并覆盖同名文件
411211
+ * @throws 磁盘写入失败或文件名非法时透传底层错误
411212
+ */
411213
+ async function writeFileToDir(dir, relPath, bytes) {
411214
+ const parts = pathSegments(relPath);
411215
+ const fileName = parts.pop() ?? "download";
411216
+ let parent = dir;
411217
+ for (const part of parts) parent = await parent.getDirectoryHandle(part, { create: true });
411218
+ const writable = await (await parent.getFileHandle(fileName, { create: true })).createWritable();
411219
+ try {
411220
+ await writable.write(bytes);
411221
+ } finally {
411222
+ await writable.close();
411223
+ }
411224
+ }
411225
+ /**
411226
+ * 相对路径切段(过滤空段、`.` 与越界 `..`,兼容反斜杠)。
411227
+ * @author ddj 2026年11月03号
411228
+ * @param relPath 工作区相对路径
411229
+ * @returns 安全的路径段(末段为文件名)
411230
+ */
411231
+ function pathSegments(relPath) {
411232
+ return relPath.split(/[\\/]+/).map((part) => part.trim()).filter((part) => part !== "" && part !== "." && part !== "..");
411233
+ }
411234
+ /**
411235
+ * 判断是否用户取消(AbortError),不依赖 DOMException 构造函数的存在。
411236
+ * @author ddj 2026年11月03号
411237
+ * @param error 捕获值
411238
+ * @returns 取消为 true
411239
+ */
411240
+ function isAbort(error) {
411241
+ return typeof error === "object" && error !== null && error.name === "AbortError";
411242
+ }
411142
411243
  /**
411143
411244
  * 初始状态:根目录默认展开,等待首层 listDir 填充。
411144
411245
  * @author ddj 2026年11月03号
@@ -411513,7 +411614,7 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411513
411614
  dialogClass: "edrv-dl-dialog",
411514
411615
  width: "min(640px, calc(100vw - 40px))",
411515
411616
  onClose
411516
- }, react$1.default.createElement("h3", null, "下载到本机"), react$1.default.createElement("div", { className: "edrv-dl-sub" }, root ? "/" + root : "工作区根目录"), renderTree({
411617
+ }, react$1.default.createElement("h3", null, "下载到本机"), react$1.default.createElement("div", { className: "edrv-dl-sub" }, root ? "/" + root : "工作区根目录"), react$1.default.createElement("div", { className: "edrv-dl-hint" }, "点下载后会先选择保存文件夹,文件按相对路径写入"), renderTree({
411517
411618
  state,
411518
411619
  root,
411519
411620
  rows,
@@ -411565,7 +411666,7 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411565
411666
  className: "edrv-dl-go",
411566
411667
  disabled: !picked.length,
411567
411668
  onClick: () => onConfirm(picked)
411568
- }, "下载 " + picked.length + " 个文件"));
411669
+ }, "选择位置并下载 " + picked.length + " 个文件"));
411569
411670
  }
411570
411671
  /**
411571
411672
  * 目录行:三角展开 / 收起,名称后带加载或错误提示。
@@ -411723,16 +411824,17 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411723
411824
  };
411724
411825
  }
411725
411826
  /**
411726
- * 下载单个文件并交给浏览器落盘。
411827
+ * 下载单个文件并落盘(所选目录则按相对路径写入,否则交给浏览器 `<a download>`)。
411727
411828
  * @author ddj 2026年11月03号
411728
411829
  * @param path 工作区相对路径
411729
- * @param opts 会话 / 取消选项
411830
+ * @param opts 会话 / 取消 / 落盘选项
411730
411831
  * @returns 路径与字节数
411731
- * @throws 读取失败透传主机错误
411832
+ * @throws 读取失败透传主机错误;目录写入失败透传底层错误
411732
411833
  */
411733
411834
  async function downloadOne(path, opts = {}) {
411734
411835
  const file = await fetchDownload(path, opts.sessionId);
411735
- downloadBytes(file.bytes, baseNameOf$1(path) || "download", file.mime);
411836
+ if (opts.sink?.kind === "dir") await writeFileToDir(opts.sink.dir, path, file.bytes);
411837
+ else downloadBytes(file.bytes, baseNameOf$1(path) || "download", file.mime);
411736
411838
  return {
411737
411839
  path,
411738
411840
  size: file.size
@@ -411787,22 +411889,43 @@ td.UniverTableCell, th.UniverTableCell{mso-padding-alt:0cm 5.4pt 0cm 5.4pt;}
411787
411889
  });
411788
411890
  }
411789
411891
  /**
411790
- * 批量下载并统一反馈(开始一条、结束一条,避免逐文件刷屏)。
411892
+ * 批量下载并统一反馈(先选保存目录,再逐文件落盘;开始一条、结束一条,避免逐文件刷屏)。
411791
411893
  * @author ddj 2026年11月03号
411792
411894
  * @param paths 勾选路径
411793
411895
  * @param ctx 面板上下文
411896
+ * @sideEffects 弹出目录选择器;用户取消则整批中止
411794
411897
  */
411795
411898
  async function runBatch(paths, ctx) {
411796
411899
  if (!paths.length) return;
411900
+ const sink = await pickSaveSink();
411901
+ if (sink === "cancelled") {
411902
+ ctx?.notify?.("已取消下载");
411903
+ return;
411904
+ }
411905
+ if (sink.kind === "browser") ctx?.notify?.(fallbackText(sink.reason));
411797
411906
  if (paths.length > 1) ctx?.notify?.("正在下载 " + paths.length + " 个文件…");
411798
411907
  try {
411799
- const out = await downloadMany(paths, { sessionId: ctx?.sessionId });
411908
+ const out = await downloadMany(paths, {
411909
+ sessionId: ctx?.sessionId,
411910
+ sink
411911
+ });
411800
411912
  ctx?.notify?.(batchText(out));
411801
411913
  } catch (error) {
411802
411914
  ctx?.notify?.("下载失败:" + textOfError(error));
411803
411915
  }
411804
411916
  }
411805
411917
  /**
411918
+ * 目录选择不可用时的提示文案(说明文件去了浏览器默认下载目录)。
411919
+ * @author ddj 2026年11月03号
411920
+ * @param reason 回落原因
411921
+ * @returns 单行提示文案
411922
+ */
411923
+ function fallbackText(reason) {
411924
+ if (reason === "unsupported") return "浏览器不支持选择保存目录,已保存到默认下载目录";
411925
+ if (reason === "insecure") return "当前页面不是安全上下文(需 https 或 localhost),已保存到默认下载目录";
411926
+ return "未获得目录写入权限,已保存到默认下载目录";
411927
+ }
411928
+ /**
411806
411929
  * 批量结果口播文案。
411807
411930
  * @author ddj 2026年11月03号
411808
411931
  * @param out 批量结果
package/lib/client.js.map CHANGED
@@ -1,4 +1,4 @@
1
1
  [diffend] Oversized file quarantined before diffing.
2
2
  name: package/lib/client.js.map
3
- size: 35747807 bytes
4
- sha256: 00397bc527477797630d5f114826604a5a5c212d3a8c79fe87a95140beb0b254
3
+ size: 35757528 bytes
4
+ sha256: 0d31913373f82b945772a46dce1f7840a2e71d3cf643543434c05dfef727fd81
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.25.0",
3
+ "version": "0.25.1",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -16,6 +16,8 @@
16
16
  import { readBinaryPreview } from '../shared/rpc.js'
17
17
  import { baseNameOf } from '../shared/fsNames.js'
18
18
  import { rpc } from './rpc.js'
19
+ import type { SaveFallback, SaveSink } from './downloadSink.js'
20
+ import { pickSaveSink, writeFileToDir } from './downloadSink.js'
19
21
  import type { TreeMenuTarget } from './sidebar/contextMenu.js'
20
22
  import type { SidebarCtx } from './sidebar/types.js'
21
23
  import { openDownloader } from './ui/DownloadPicker.js'
@@ -41,6 +43,8 @@ export interface DownloadOpts {
41
43
  signal?: AbortSignal
42
44
  /** 每完成一个文件回调一次(done 含成功与失败)。 */
43
45
  onProgress?: (done: number, total: number, path: string) => void
46
+ /** 落盘去处;缺省按浏览器默认下载目录(`<a download>`)。 */
47
+ sink?: SaveSink
44
48
  }
45
49
 
46
50
  // --endregion
@@ -85,16 +89,20 @@ export async function fetchDownload(path: string, sessionId?: string): Promise<{
85
89
  }
86
90
 
87
91
  /**
88
- * 下载单个文件并交给浏览器落盘。
92
+ * 下载单个文件并落盘(所选目录则按相对路径写入,否则交给浏览器 `<a download>`)。
89
93
  * @author ddj 2026年11月03号
90
94
  * @param path 工作区相对路径
91
- * @param opts 会话 / 取消选项
95
+ * @param opts 会话 / 取消 / 落盘选项
92
96
  * @returns 路径与字节数
93
- * @throws 读取失败透传主机错误
97
+ * @throws 读取失败透传主机错误;目录写入失败透传底层错误
94
98
  */
95
99
  export async function downloadOne(path: string, opts: DownloadOpts = {}): Promise<DownloadedFile> {
96
100
  const file = await fetchDownload(path, opts.sessionId)
97
- downloadBytes(file.bytes, baseNameOf(path) || 'download', file.mime)
101
+ if (opts.sink?.kind === 'dir') {
102
+ await writeFileToDir(opts.sink.dir, path, file.bytes)
103
+ } else {
104
+ downloadBytes(file.bytes, baseNameOf(path) || 'download', file.mime)
105
+ }
98
106
  return { path, size: file.size }
99
107
  }
100
108
 
@@ -148,22 +156,44 @@ export async function downloadTarget(target: TreeMenuTarget, ctx?: SidebarCtx):
148
156
  }
149
157
 
150
158
  /**
151
- * 批量下载并统一反馈(开始一条、结束一条,避免逐文件刷屏)。
159
+ * 批量下载并统一反馈(先选保存目录,再逐文件落盘;开始一条、结束一条,避免逐文件刷屏)。
152
160
  * @author ddj 2026年11月03号
153
161
  * @param paths 勾选路径
154
162
  * @param ctx 面板上下文
163
+ * @sideEffects 弹出目录选择器;用户取消则整批中止
155
164
  */
156
165
  async function runBatch(paths: string[], ctx?: SidebarCtx): Promise<void> {
157
166
  if (!paths.length) return
167
+
168
+ // 选择器必须在用户手势的同步栈里触发,故放在第一个 await 之前。
169
+ const sink = await pickSaveSink()
170
+ if (sink === 'cancelled') {
171
+ ctx?.notify?.('已取消下载')
172
+ return
173
+ }
174
+ if (sink.kind === 'browser') ctx?.notify?.(fallbackText(sink.reason))
158
175
  if (paths.length > 1) ctx?.notify?.('正在下载 ' + paths.length + ' 个文件…')
176
+
159
177
  try {
160
- const out = await downloadMany(paths, { sessionId: ctx?.sessionId })
178
+ const out = await downloadMany(paths, { sessionId: ctx?.sessionId, sink })
161
179
  ctx?.notify?.(batchText(out))
162
180
  } catch (error) {
163
181
  ctx?.notify?.('下载失败:' + textOfError(error))
164
182
  }
165
183
  }
166
184
 
185
+ /**
186
+ * 目录选择不可用时的提示文案(说明文件去了浏览器默认下载目录)。
187
+ * @author ddj 2026年11月03号
188
+ * @param reason 回落原因
189
+ * @returns 单行提示文案
190
+ */
191
+ function fallbackText(reason: SaveFallback): string {
192
+ if (reason === 'unsupported') return '浏览器不支持选择保存目录,已保存到默认下载目录'
193
+ if (reason === 'insecure') return '当前页面不是安全上下文(需 https 或 localhost),已保存到默认下载目录'
194
+ return '未获得目录写入权限,已保存到默认下载目录'
195
+ }
196
+
167
197
  /**
168
198
  * 批量结果口播文案。
169
199
  * @author ddj 2026年11月03号
@@ -0,0 +1,162 @@
1
+ /**
2
+ * dsh-vscode-mode client — 下载落盘位置(目录选择器)。
3
+ *
4
+ * 浏览器原生 `<a download>` 由浏览器决定保存位置,页面拿不到路径;本模块用
5
+ * File System Access API 的 `showDirectoryPicker` 让用户在点「下载」时选一次目标文件夹,
6
+ * 之后把文件按工作区相对路径逐个写进去(文件夹下载自动建子目录,仍不打包)。
7
+ *
8
+ * 约束:
9
+ * - 选择器必须在用户手势的同步栈里调用,故入口函数都把 `pickSaveSink()` 放在第一个 await;
10
+ * - 需要安全上下文(https / localhost)与 Chromium 系浏览器,Firefox / Safari / 局域网 http
11
+ * 一律回落到浏览器默认下载目录,并在界面上说明原因,不静默改语义;
12
+ * - 用户取消选择(AbortError)与「不可用」区分开:前者终止整批,后者继续下载。
13
+ * 作者 ddj 2026年11月03号
14
+ */
15
+
16
+ // --region 类型
17
+
18
+ /** 落盘去处:写入用户所选目录,或回落到浏览器默认下载目录。 */
19
+ export type SaveSink =
20
+ | { kind: 'dir'; dir: SinkDir }
21
+ | { kind: 'browser'; reason: SaveFallback }
22
+
23
+ /** 回落到浏览器下载的原因(调用方据此给出提示文案)。 */
24
+ export type SaveFallback = 'unsupported' | 'insecure' | 'denied'
25
+
26
+ /** 可写文件的句柄(File System Access API 的子集)。 */
27
+ export interface SinkWritable {
28
+ write(data: Uint8Array): Promise<void>
29
+ close(): Promise<void>
30
+ }
31
+
32
+ /** 文件句柄(仅用到创建可写流)。 */
33
+ export interface SinkFileHandle {
34
+ createWritable(): Promise<SinkWritable>
35
+ }
36
+
37
+ /** 目录句柄(仅用到列目录与建子目录/文件)。 */
38
+ export interface SinkDir {
39
+ name: string
40
+ getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<SinkDir>
41
+ getFileHandle(name: string, opts?: { create?: boolean }): Promise<SinkFileHandle>
42
+ queryPermission?(opts: { mode: 'readwrite' }): Promise<string>
43
+ requestPermission?(opts: { mode: 'readwrite' }): Promise<string>
44
+ }
45
+
46
+ /** `window.showDirectoryPicker` 的最小签名。 */
47
+ type DirPicker = (opts?: { id?: string; mode?: string; startIn?: string }) => Promise<SinkDir>
48
+
49
+ // --endregion
50
+
51
+ // --region 选择器
52
+
53
+ /**
54
+ * 当前浏览器/上下文是否可能弹出目录选择器(安全上下文 + API 存在)。
55
+ * @author ddj 2026年11月03号
56
+ * @returns 可用为 true;不安全上下文或缺 API 为 false
57
+ */
58
+ export function canPickDirectory(): boolean {
59
+ return pickerOf() !== undefined
60
+ }
61
+
62
+ /**
63
+ * 让用户选择保存目录。
64
+ * @author ddj 2026年11月03号
65
+ * @returns 所选目录 / 回落原因 / 'cancelled'(用户取消,整批终止)
66
+ * @sideEffects 弹出系统目录选择器,成功后可能触发一次写权限请求
67
+ */
68
+ export async function pickSaveSink(): Promise<SaveSink | 'cancelled'> {
69
+ const win = globalThis as { isSecureContext?: boolean; showDirectoryPicker?: DirPicker }
70
+ if (win.isSecureContext === false) return { kind: 'browser', reason: 'insecure' }
71
+
72
+ const picker = pickerOf()
73
+ if (!picker) return { kind: 'browser', reason: 'unsupported' }
74
+
75
+ let dir: SinkDir
76
+ try {
77
+ dir = await picker({ id: 'vsm-download', mode: 'readwrite', startIn: 'downloads' })
78
+ } catch (error) {
79
+ return isAbort(error) ? 'cancelled' : { kind: 'browser', reason: 'denied' }
80
+ }
81
+
82
+ return (await ensureWritable(dir)) ? { kind: 'dir', dir } : { kind: 'browser', reason: 'denied' }
83
+ }
84
+
85
+ /**
86
+ * 取回 `showDirectoryPicker`(不安全上下文视为没有)。
87
+ * @author ddj 2026年11月03号
88
+ * @returns 绑定到 globalThis 的选目录函数;不可用为 undefined
89
+ */
90
+ function pickerOf(): DirPicker | undefined {
91
+ const win = globalThis as { isSecureContext?: boolean; showDirectoryPicker?: DirPicker }
92
+ if (win.isSecureContext === false) return undefined
93
+ return typeof win.showDirectoryPicker === 'function' ? win.showDirectoryPicker.bind(win) : undefined
94
+ }
95
+
96
+ /**
97
+ * 确认目录可写(部分浏览器选择后仍需显式请求一次 readwrite 权限)。
98
+ * @author ddj 2026年11月03号
99
+ * @param dir 目录句柄
100
+ * @returns 已获得写权限为 true
101
+ * @throws 权限查询本身抛错时向上透传(由调用方按回落处理)
102
+ */
103
+ async function ensureWritable(dir: SinkDir): Promise<boolean> {
104
+ if (!dir.queryPermission) return true
105
+ if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') return true
106
+ if (!dir.requestPermission) return false
107
+ return (await dir.requestPermission({ mode: 'readwrite' })) === 'granted'
108
+ }
109
+
110
+ // --endregion
111
+
112
+ // --region 写入
113
+
114
+ /**
115
+ * 把字节写到所选目录下的相对路径处(自动逐级建子目录)。
116
+ * @author ddj 2026年11月03号
117
+ * @param dir 目标目录句柄
118
+ * @param relPath 工作区相对路径(分隔符 `/`;越界段 `..` 会被丢弃)
119
+ * @param bytes 原始字节
120
+ * @sideEffects 在用户所选目录内创建目录与文件并覆盖同名文件
121
+ * @throws 磁盘写入失败或文件名非法时透传底层错误
122
+ */
123
+ export async function writeFileToDir(dir: SinkDir, relPath: string, bytes: Uint8Array): Promise<void> {
124
+ const parts = pathSegments(relPath)
125
+ const fileName = parts.pop() ?? 'download'
126
+
127
+ let parent = dir
128
+ for (const part of parts) parent = await parent.getDirectoryHandle(part, { create: true })
129
+
130
+ const handle = await parent.getFileHandle(fileName, { create: true })
131
+ const writable = await handle.createWritable()
132
+ try {
133
+ await writable.write(bytes)
134
+ } finally {
135
+ await writable.close()
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 相对路径切段(过滤空段、`.` 与越界 `..`,兼容反斜杠)。
141
+ * @author ddj 2026年11月03号
142
+ * @param relPath 工作区相对路径
143
+ * @returns 安全的路径段(末段为文件名)
144
+ */
145
+ function pathSegments(relPath: string): string[] {
146
+ return relPath
147
+ .split(/[\\/]+/)
148
+ .map((part) => part.trim())
149
+ .filter((part) => part !== '' && part !== '.' && part !== '..')
150
+ }
151
+
152
+ /**
153
+ * 判断是否用户取消(AbortError),不依赖 DOMException 构造函数的存在。
154
+ * @author ddj 2026年11月03号
155
+ * @param error 捕获值
156
+ * @returns 取消为 true
157
+ */
158
+ function isAbort(error: unknown): boolean {
159
+ return typeof error === 'object' && error !== null && (error as { name?: string }).name === 'AbortError'
160
+ }
161
+
162
+ // --endregion
@@ -114,6 +114,7 @@ export function DownloadPicker(props: DownloadPickerProps): React.ReactElement {
114
114
  },
115
115
  React.createElement('h3', null, '下载到本机'),
116
116
  React.createElement('div', { className: 'edrv-dl-sub' }, root ? '/' + root : '工作区根目录'),
117
+ React.createElement('div', { className: 'edrv-dl-hint' }, '点下载后会先选择保存文件夹,文件按相对路径写入'),
117
118
  renderTree({ state, root, rows, empty, loadDir, renderRow, onToggleDir, onToggleFile }),
118
119
  renderFoot({
119
120
  picked,
@@ -182,7 +183,7 @@ function renderFoot(opts: FootOpts): React.ReactElement {
182
183
  className: 'edrv-dl-go',
183
184
  disabled: !picked.length,
184
185
  onClick: () => onConfirm(picked),
185
- }, '下载 ' + picked.length + ' 个文件'),
186
+ }, '选择位置并下载 ' + picked.length + ' 个文件'),
186
187
  )
187
188
  }
188
189