dsh-vscode-mode 0.24.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.
@@ -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
@@ -23,6 +23,8 @@ import { rpc } from '../rpc.js'
23
23
  import { absoluteOf, relativeOf } from '../tabActions.js'
24
24
  import { copyText } from '../copyText.js'
25
25
  import { clearFileClip, fileClipOf, setFileClip } from '../fileClipboard.js'
26
+ import { downloadTarget } from '../downloadFile.js'
27
+ import { isRemoteChannel } from '../../shared/remoteChannel.js'
26
28
  import type { TreeMenuItem } from './contextMenu.js'
27
29
  import type { SidebarCtx } from './types.js'
28
30
  import type { TreeMenuTarget } from './contextMenu.js'
@@ -39,6 +41,8 @@ const ORDER_COPY = 80
39
41
  const ORDER_PASTE = 90
40
42
  const ORDER_COPY_PATH = 110
41
43
  const ORDER_COPY_REL = 120
44
+ /** 远程下载(仅远程访问可见)落在复制路径段之后、重命名段之前,不新起视觉块。 */
45
+ const ORDER_DOWNLOAD = 125
42
46
  const ORDER_RENAME = 140
43
47
  const ORDER_DELETE = 150
44
48
  const SVN_ORDER_BASE = 200
@@ -454,6 +458,28 @@ function editGroupItems(): TreeMenuItem[] {
454
458
 
455
459
  // --endregion
456
460
 
461
+ // --region 远程下载(仅远程访问可见)
462
+
463
+ /**
464
+ * 「下载到本机」菜单项:远程访问时把工作区文件拉回浏览器所在机器。
465
+ * 可见性唯一判据是通道 seat({@link isRemoteChannel})——本机回环页面完全看不到该项,
466
+ * 也不因打开菜单发出任何请求;文件目标直下,文件夹 / 根空白区弹勾选弹窗。
467
+ * @author ddj 2026年11月03号
468
+ * @returns 菜单项数组(固定 1 项,显隐交给 visible)
469
+ */
470
+ function downloadItems(): TreeMenuItem[] {
471
+ return [{
472
+ id: 'remote-download',
473
+ label: (target) => (target.type === 'file' ? '下载到本机' : '下载到本机…'),
474
+ order: ORDER_DOWNLOAD,
475
+ group: GROUP_COPY_PATH,
476
+ visible: () => isRemoteChannel(),
477
+ run: (target, ctx) => { void downloadTarget(target, ctx) },
478
+ }]
479
+ }
480
+
481
+ // --endregion
482
+
457
483
  // --region SVN 组(动作清单来自 shared/svnActions.ts)
458
484
 
459
485
  /** 目标路径对应的变更条目(不在清单里返回 undefined)。 */
@@ -559,6 +585,7 @@ export function createDefaultFileMenuItems(): TreeMenuItem[] {
559
585
  ...refGroupItems(),
560
586
  ...clipGroupItems(),
561
587
  ...copyPathItems(),
588
+ ...downloadItems(),
562
589
  ...editGroupItems(),
563
590
  ...svnMenuItems(),
564
591
  ]
@@ -899,6 +899,30 @@
899
899
 
900
900
 
901
901
 
902
+ /* 远程下载确认框(仅远程访问时由资源管理器右键打开):懒加载目录树 + 文件勾选。
903
+ 卡片与遮罩由 ModalShell 提供(vsm-mcp-dialog / edrv-modal-mask,z-index 190),
904
+ 此处只补树列表与底栏布局。⚠️ 规则一律以 [data-edrv-view] 开头,否则整块失配。 */
905
+ [data-edrv-view] .edrv-dl-dialog { display: flex; flex-direction: column; gap: 10px; }
906
+ [data-edrv-view] .edrv-dl-sub { margin: -6px 0 2px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #9aa5b1); word-break: break-all; }
907
+ [data-edrv-view] .edrv-dl-tree { display: flex; flex-direction: column; gap: 1px; min-height: 120px; max-height: min(48vh, 420px); overflow: auto; padding: 4px; border: 1px solid var(--dsw-alias-border-l1, #3a3a3a); border-radius: 8px; background: var(--dsw-alias-bg-layer-1, #262626); }
908
+ [data-edrv-view] .edrv-dl-row { display: flex; align-items: center; gap: 6px; min-height: 26px; padding: 2px 6px; border-radius: 6px; font-size: 13px; color: var(--dsw-alias-label-primary, #e6e6e6); }
909
+ [data-edrv-view] .edrv-dl-row:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .06)); }
910
+ [data-edrv-view] .edrv-dl-row.is-dir { cursor: pointer; }
911
+ [data-edrv-view] .edrv-dl-twist { flex: 0 0 auto; width: 18px; height: 18px; color: var(--dsw-alias-label-secondary, #aaa); font-size: 11px; line-height: 18px; text-align: center; }
912
+ [data-edrv-view] .edrv-dl-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
913
+ [data-edrv-view] .edrv-dl-dir { font-weight: 600; }
914
+ [data-edrv-view] .edrv-dl-size { flex: 0 0 auto; font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
915
+ [data-edrv-view] .edrv-dl-size.is-large { color: var(--dsw-alias-state-warn-primary, #e5b93f); }
916
+ [data-edrv-view] .edrv-dl-hint { padding: 3px 8px; font-size: 11px; color: var(--dsw-alias-label-tertiary, #9aa5b1); }
917
+ [data-edrv-view] .edrv-dl-hint.is-error { display: flex; align-items: center; gap: 8px; color: var(--dsw-alias-state-error-primary, #f07878); }
918
+ [data-edrv-view] .edrv-dl-hint button { padding: 2px 8px; border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 5px; background: none; color: var(--dsw-alias-label-primary, #eee); font-size: 11px; cursor: pointer; }
919
+ [data-edrv-view] .edrv-dl-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
920
+ [data-edrv-view] .edrv-dl-count { margin-right: auto; font-size: 12px; color: var(--dsw-alias-label-secondary, #aaa); }
921
+ [data-edrv-view] .edrv-dl-foot button { padding: 6px 12px; border: 1px solid var(--dsw-alias-border-l2, #555); border-radius: 6px; background: none; color: var(--dsw-alias-label-primary, #eee); font-size: 12px; cursor: pointer; }
922
+ [data-edrv-view] .edrv-dl-foot button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(255, 255, 255, .06)); }
923
+ [data-edrv-view] .edrv-dl-foot button:disabled { opacity: .55; cursor: default; }
924
+ [data-edrv-view] .edrv-dl-go { border-color: var(--dsw-alias-brand-primary, #6d55ff); }
925
+
902
926
  /* 调试(DAP):断点圆点 / 停帧行 / 浮动调试工具条。
903
927
  圆点挂在 Monaco glyphMargin(glyphMarginClassName),行背景为 isWholeLine className;
904
928
  工具条绝对定位于编辑区右上(editorArea relative 由 .edrv-editor-area 自身定位)。 */
@@ -0,0 +1,306 @@
1
+ /**
2
+ * dsh-vscode-mode client — 「下载到本机」确认框(仅远程访问时由右键菜单打开)。
3
+ *
4
+ * 形态:ModalShell 居中卡片 + 懒加载目录树 + 文件勾选。产品决策是「不打包」:
5
+ * 这里只收集勾选,由调用方(downloadFile.ts)逐个下载原文件。
6
+ *
7
+ * 目录节点只负责展开、不带勾选框:勾选粒度是文件,避免半选目录的语义歧义;
8
+ * 「全选」只覆盖已展开(已加载)的文件,不会为了全选把整棵树拉一遍。
9
+ * 样式见 editor.css 的 `[data-edrv-view] .edrv-dl-*`;本组件不新建 portal,
10
+ * 因此自动满足 tests/overlayScope.test.ts 的 data-edrv-view 契约。
11
+ * 作者 ddj 2026年11月03号
12
+ */
13
+ import React from 'react'
14
+ import { createRoot } from 'react-dom/client'
15
+ import { rpc } from '../rpc.js'
16
+ import type { TreeEntry } from '../../shared/rpc.js'
17
+ import { ModalShell } from './ModalShell.js'
18
+ import {
19
+ clearSelected, collapseDir, createDlState, expandDir, formatSize, hasLoaded, isOversized, markError, markLoading,
20
+ mergeChildren, rowList, selectLoaded, selectedFiles, toggleFile,
21
+ } from '../downloadPickerModel.js'
22
+ import type { DlRow, DlState } from '../downloadPickerModel.js'
23
+
24
+ /** 弹窗属性:只做「选文件」这件事,下载编排由调用方负责。 */
25
+ export interface DownloadPickerProps {
26
+ /** 弹窗根:工作区相对路径;'' = 整个工作区。 */
27
+ root: string
28
+ /** 会话 id(工作区解析用)。 */
29
+ sessionId?: string
30
+ /** 取消(Esc / 遮罩 / 取消按钮)。 */
31
+ onClose: () => void
32
+ /** 确认:传勾选的文件路径(至少 1 个)。 */
33
+ onConfirm: (paths: string[]) => void
34
+ }
35
+
36
+ /**
37
+ * 「下载到本机」确认框。
38
+ * @author ddj 2026年11月03号
39
+ * @param props 弹窗属性
40
+ * @returns 弹窗元素
41
+ */
42
+ export function DownloadPicker(props: DownloadPickerProps): React.ReactElement {
43
+ const { root, sessionId, onClose, onConfirm } = props
44
+ const [state, setState] = React.useState<DlState>(() => createDlState(root))
45
+ const aliveRef = React.useRef(true)
46
+
47
+ React.useEffect(() => () => { aliveRef.current = false }, [])
48
+
49
+ /**
50
+ * 拉取某目录一层子项(懒加载的唯一入口)。
51
+ * @author ddj 2026年11月03号
52
+ * @param dirPath 目录路径('' = 工作区根)
53
+ */
54
+ const loadDir = React.useCallback(async (dirPath: string): Promise<void> => {
55
+ setState((prev) => markLoading(prev, dirPath))
56
+ try {
57
+ const args = sessionId ? { sessionId, path: dirPath } : { path: dirPath }
58
+ const out = await rpc('edrv.listDir', args)
59
+ if (!aliveRef.current) return
60
+ if (!out.ok) {
61
+ setState((prev) => markError(prev, dirPath, out.error))
62
+ return
63
+ }
64
+ setState((prev) => mergeChildren(prev, dirPath, out.entries))
65
+ } catch (error) {
66
+ if (!aliveRef.current) return
67
+ setState((prev) => markError(prev, dirPath, textOfError(error)))
68
+ }
69
+ }, [sessionId])
70
+
71
+ React.useEffect(() => { void loadDir(root) }, [loadDir, root])
72
+
73
+ /**
74
+ * 展开 / 收起目录;首次展开才发 listDir。
75
+ * @author ddj 2026年11月03号
76
+ * @param path 目录路径
77
+ */
78
+ function onToggleDir(path: string): void {
79
+ const expanded = state.expanded.has(path)
80
+ setState((prev) => (expanded ? collapseDir(prev, path) : expandDir(prev, path)))
81
+ if (expanded || hasLoaded(state, path) || state.loading.has(path)) return
82
+ void loadDir(path)
83
+ }
84
+
85
+ /**
86
+ * 勾选 / 取消勾选单个文件。
87
+ * @author ddj 2026年11月03号
88
+ * @param path 文件路径
89
+ */
90
+ function onToggleFile(path: string): void {
91
+ setState((prev) => toggleFile(prev, path))
92
+ }
93
+
94
+ const rows = rowList(state)
95
+ const picked = selectedFiles(state)
96
+ const empty = hasLoaded(state, root) && rows.length === 0 && !state.loading.has(root)
97
+
98
+ /**
99
+ * 单行渲染(目录出三角、文件出勾选框;超限文件置灰不可勾)。
100
+ * @author ddj 2026年11月03号
101
+ * @param row 行数据
102
+ * @returns 行元素
103
+ */
104
+ function renderRow(row: DlRow): React.ReactElement {
105
+ const indent = { paddingLeft: 6 + row.depth * 14 }
106
+ if (row.kind === 'directory') return renderDirRow(row, indent, onToggleDir, loadDir)
107
+ return renderFileRow(row, indent, onToggleFile)
108
+ }
109
+
110
+ return React.createElement(ModalShell, {
111
+ dialogClass: 'edrv-dl-dialog',
112
+ width: 'min(640px, calc(100vw - 40px))',
113
+ onClose,
114
+ },
115
+ React.createElement('h3', null, '下载到本机'),
116
+ React.createElement('div', { className: 'edrv-dl-sub' }, root ? '/' + root : '工作区根目录'),
117
+ React.createElement('div', { className: 'edrv-dl-hint' }, '点下载后会先选择保存文件夹,文件按相对路径写入'),
118
+ renderTree({ state, root, rows, empty, loadDir, renderRow, onToggleDir, onToggleFile }),
119
+ renderFoot({
120
+ picked,
121
+ rows,
122
+ onSelectAll: () => setState(selectLoaded),
123
+ onClear: () => setState(clearSelected),
124
+ onClose,
125
+ onConfirm,
126
+ }),
127
+ )
128
+ }
129
+
130
+ /** 目录树区域属性(全部由组件传入,渲染函数本身不持有状态)。 */
131
+ interface TreeOpts {
132
+ state: DlState
133
+ root: string
134
+ rows: DlRow[]
135
+ empty: boolean
136
+ loadDir: (dirPath: string) => Promise<void>
137
+ renderRow: (row: DlRow) => React.ReactElement
138
+ onToggleDir: (path: string) => void
139
+ onToggleFile: (path: string) => void
140
+ }
141
+
142
+ /**
143
+ * 目录树区域:行列表 + 根加载态 + 根错误重试 + 空态。
144
+ * @author ddj 2026年11月03号
145
+ * @param opts 树区域属性
146
+ * @returns 树区域元素
147
+ */
148
+ function renderTree(opts: TreeOpts): React.ReactElement {
149
+ const { state, root, rows, empty, loadDir, renderRow } = opts
150
+ return React.createElement('div', { className: 'edrv-dl-tree' },
151
+ rows.length ? rows.map(renderRow) : null,
152
+ state.loading.has(root) ? React.createElement('div', { className: 'edrv-dl-hint' }, '加载中…') : null,
153
+ state.errors.has(root) ? renderRetry(root, state.errors.get(root) || '', loadDir) : null,
154
+ empty ? React.createElement('div', { className: 'edrv-dl-hint' }, '此文件夹内没有可下载的文件') : null,
155
+ )
156
+ }
157
+
158
+ /** 底栏属性:已选数量、批量勾选动作与确认/取消回调。 */
159
+ interface FootOpts {
160
+ picked: string[]
161
+ rows: DlRow[]
162
+ onSelectAll: () => void
163
+ onClear: () => void
164
+ onClose: () => void
165
+ onConfirm: (paths: string[]) => void
166
+ }
167
+
168
+ /**
169
+ * 底栏:已选数量 + 全选已展开 / 清空 / 取消 / 下载。
170
+ * @author ddj 2026年11月03号
171
+ * @param opts 底栏属性
172
+ * @returns 底栏元素
173
+ */
174
+ function renderFoot(opts: FootOpts): React.ReactElement {
175
+ const { picked, rows, onSelectAll, onClear, onClose, onConfirm } = opts
176
+ return React.createElement('div', { className: 'edrv-dl-foot' },
177
+ React.createElement('span', { className: 'edrv-dl-count' }, '已选 ' + picked.length + ' 个文件'),
178
+ React.createElement('button', { type: 'button', disabled: !rows.length, onClick: onSelectAll }, '全选已展开'),
179
+ React.createElement('button', { type: 'button', disabled: !picked.length, onClick: onClear }, '清空'),
180
+ React.createElement('button', { type: 'button', onClick: onClose }, '取消'),
181
+ React.createElement('button', {
182
+ type: 'button',
183
+ className: 'edrv-dl-go',
184
+ disabled: !picked.length,
185
+ onClick: () => onConfirm(picked),
186
+ }, '选择位置并下载 ' + picked.length + ' 个文件'),
187
+ )
188
+ }
189
+
190
+ /**
191
+ * 目录行:三角展开 / 收起,名称后带加载或错误提示。
192
+ * @author ddj 2026年11月03号
193
+ * @param row 行数据
194
+ * @param indent 缩进样式
195
+ * @param onToggle 展开回调
196
+ * @param retry 重试加载回调
197
+ * @returns 目录行元素
198
+ */
199
+ function renderDirRow(
200
+ row: DlRow,
201
+ indent: React.CSSProperties,
202
+ onToggle: (path: string) => void,
203
+ retry: (path: string) => Promise<void>,
204
+ ): React.ReactElement {
205
+ // 整行可点(三角与名称都展开),避免只有三角能点导致误以为无子项
206
+ return React.createElement('div', {
207
+ key: row.path,
208
+ className: 'edrv-dl-row is-dir',
209
+ style: indent,
210
+ onClick: () => onToggle(row.path),
211
+ },
212
+ React.createElement('span', { className: 'edrv-dl-twist' }, row.expanded ? '▾' : '▸'),
213
+ React.createElement('span', { className: 'edrv-dl-name edrv-dl-dir' }, row.name),
214
+ row.loading ? React.createElement('span', { className: 'edrv-dl-hint' }, '加载中…') : null,
215
+ row.error ? renderRetry(row.path, row.error, retry) : null,
216
+ )
217
+ }
218
+
219
+ /**
220
+ * 文件行:勾选框 + 名称 + 体积;超限文件禁用勾选并标注原因。
221
+ * @author ddj 2026年11月03号
222
+ * @param row 行数据
223
+ * @param indent 缩进样式
224
+ * @param onToggle 勾选回调
225
+ * @returns 文件行元素
226
+ */
227
+ function renderFileRow(
228
+ row: DlRow,
229
+ indent: React.CSSProperties,
230
+ onToggle: (path: string) => void,
231
+ ): React.ReactElement {
232
+ const oversized = isOversized(row.size)
233
+ return React.createElement('div', { key: row.path, className: 'edrv-dl-row', style: indent },
234
+ React.createElement('input', {
235
+ type: 'checkbox',
236
+ checked: row.checked,
237
+ disabled: oversized,
238
+ onChange: () => onToggle(row.path),
239
+ }),
240
+ React.createElement('span', { className: 'edrv-dl-name', onClick: () => onToggle(row.path) }, row.name),
241
+ oversized
242
+ ? React.createElement('span', { className: 'edrv-dl-size is-large' }, '超过 32MB')
243
+ : (typeof row.size === 'number' ? React.createElement('span', { className: 'edrv-dl-size' }, formatSize(row.size)) : null),
244
+ )
245
+ }
246
+
247
+ /**
248
+ * 错误行 + 重试按钮(保留主机错误原文)。
249
+ * @author ddj 2026年11月03号
250
+ * @param path 目录路径
251
+ * @param message 错误原文
252
+ * @param retry 重试回调
253
+ * @returns 提示行元素
254
+ */
255
+ function renderRetry(path: string, message: string, retry: (path: string) => Promise<void>): React.ReactElement {
256
+ return React.createElement('div', { key: 'err:' + path, className: 'edrv-dl-hint is-error' },
257
+ React.createElement('span', null, message),
258
+ React.createElement('button', {
259
+ type: 'button',
260
+ onClick: (event: React.MouseEvent) => {
261
+ // 错误行嵌在目录行内,重试不应顺带切换该目录的展开态
262
+ event.stopPropagation()
263
+ void retry(path)
264
+ },
265
+ }, '重试'),
266
+ )
267
+ }
268
+
269
+ /**
270
+ * 挂载弹窗:临时宿主 + createRoot,关闭时卸载并移除(与工作区选择弹窗同一范式)。
271
+ * @author ddj 2026年11月03号
272
+ * @param opts 根目录 / 会话 / 确认回调
273
+ * @sideEffects 向 body 追加一个宿主节点,关闭时移除
274
+ */
275
+ export function openDownloader(opts: { root: string; sessionId?: string; onConfirm: (paths: string[]) => void }): void {
276
+ const host = document.createElement('div')
277
+ document.body.appendChild(host)
278
+ const mount = createRoot(host)
279
+
280
+ function close(): void {
281
+ mount.unmount()
282
+ host.remove()
283
+ }
284
+
285
+ function confirm(paths: string[]): void {
286
+ close()
287
+ opts.onConfirm(paths)
288
+ }
289
+
290
+ mount.render(React.createElement(DownloadPicker, {
291
+ root: opts.root,
292
+ sessionId: opts.sessionId,
293
+ onClose: close,
294
+ onConfirm: confirm,
295
+ }))
296
+ }
297
+
298
+ /**
299
+ * 错误可读文案(Error.message 优先,其余 String 化)。
300
+ * @author ddj 2026年11月03号
301
+ * @param error 捕获值
302
+ * @returns 提示文案
303
+ */
304
+ function textOfError(error: unknown): string {
305
+ return error instanceof Error ? error.message : String(error)
306
+ }
@@ -37,6 +37,17 @@ export function channelPrefix(): string {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * 本页是否经远程访问通道接入(远程 vs 本机回环)。
42
+ * 与 {@link import('../client/rpc.js').channelNote} 同口径:只认通道 seat,不解析地址。
43
+ * 用于「仅远程可用」能力的显隐,例如资源管理器的「下载到本机」。
44
+ * @author ddj 2026年11月03号
45
+ * @returns 已接入远程通道为 true;本机、桌面 dsh-app:、未装该插件均为 false
46
+ */
47
+ export function isRemoteChannel(): boolean {
48
+ return channelPrefix() !== ''
49
+ }
50
+
40
51
  /**
41
52
  * 给插件内部路径加通道前缀。
42
53
  * @author ddj 2026年11月02号