dsh-vscode-mode 0.24.0 → 0.25.0
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 +11 -5
- package/lib/client.js +777 -48
- package/lib/client.js.map +2 -2
- package/package.json +1 -1
- package/src/client/downloadFile.ts +208 -0
- package/src/client/downloadPickerModel.ts +335 -0
- package/src/client/sidebar/menuItems.ts +27 -0
- package/src/client/styles/editor.css +24 -0
- package/src/client/ui/DownloadPicker.ts +305 -0
- package/src/shared/remoteChannel.ts +11 -0
|
@@ -0,0 +1,305 @@
|
|
|
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
|
+
renderTree({ state, root, rows, empty, loadDir, renderRow, onToggleDir, onToggleFile }),
|
|
118
|
+
renderFoot({
|
|
119
|
+
picked,
|
|
120
|
+
rows,
|
|
121
|
+
onSelectAll: () => setState(selectLoaded),
|
|
122
|
+
onClear: () => setState(clearSelected),
|
|
123
|
+
onClose,
|
|
124
|
+
onConfirm,
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 目录树区域属性(全部由组件传入,渲染函数本身不持有状态)。 */
|
|
130
|
+
interface TreeOpts {
|
|
131
|
+
state: DlState
|
|
132
|
+
root: string
|
|
133
|
+
rows: DlRow[]
|
|
134
|
+
empty: boolean
|
|
135
|
+
loadDir: (dirPath: string) => Promise<void>
|
|
136
|
+
renderRow: (row: DlRow) => React.ReactElement
|
|
137
|
+
onToggleDir: (path: string) => void
|
|
138
|
+
onToggleFile: (path: string) => void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 目录树区域:行列表 + 根加载态 + 根错误重试 + 空态。
|
|
143
|
+
* @author ddj 2026年11月03号
|
|
144
|
+
* @param opts 树区域属性
|
|
145
|
+
* @returns 树区域元素
|
|
146
|
+
*/
|
|
147
|
+
function renderTree(opts: TreeOpts): React.ReactElement {
|
|
148
|
+
const { state, root, rows, empty, loadDir, renderRow } = opts
|
|
149
|
+
return React.createElement('div', { className: 'edrv-dl-tree' },
|
|
150
|
+
rows.length ? rows.map(renderRow) : null,
|
|
151
|
+
state.loading.has(root) ? React.createElement('div', { className: 'edrv-dl-hint' }, '加载中…') : null,
|
|
152
|
+
state.errors.has(root) ? renderRetry(root, state.errors.get(root) || '', loadDir) : null,
|
|
153
|
+
empty ? React.createElement('div', { className: 'edrv-dl-hint' }, '此文件夹内没有可下载的文件') : null,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 底栏属性:已选数量、批量勾选动作与确认/取消回调。 */
|
|
158
|
+
interface FootOpts {
|
|
159
|
+
picked: string[]
|
|
160
|
+
rows: DlRow[]
|
|
161
|
+
onSelectAll: () => void
|
|
162
|
+
onClear: () => void
|
|
163
|
+
onClose: () => void
|
|
164
|
+
onConfirm: (paths: string[]) => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 底栏:已选数量 + 全选已展开 / 清空 / 取消 / 下载。
|
|
169
|
+
* @author ddj 2026年11月03号
|
|
170
|
+
* @param opts 底栏属性
|
|
171
|
+
* @returns 底栏元素
|
|
172
|
+
*/
|
|
173
|
+
function renderFoot(opts: FootOpts): React.ReactElement {
|
|
174
|
+
const { picked, rows, onSelectAll, onClear, onClose, onConfirm } = opts
|
|
175
|
+
return React.createElement('div', { className: 'edrv-dl-foot' },
|
|
176
|
+
React.createElement('span', { className: 'edrv-dl-count' }, '已选 ' + picked.length + ' 个文件'),
|
|
177
|
+
React.createElement('button', { type: 'button', disabled: !rows.length, onClick: onSelectAll }, '全选已展开'),
|
|
178
|
+
React.createElement('button', { type: 'button', disabled: !picked.length, onClick: onClear }, '清空'),
|
|
179
|
+
React.createElement('button', { type: 'button', onClick: onClose }, '取消'),
|
|
180
|
+
React.createElement('button', {
|
|
181
|
+
type: 'button',
|
|
182
|
+
className: 'edrv-dl-go',
|
|
183
|
+
disabled: !picked.length,
|
|
184
|
+
onClick: () => onConfirm(picked),
|
|
185
|
+
}, '下载 ' + picked.length + ' 个文件'),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 目录行:三角展开 / 收起,名称后带加载或错误提示。
|
|
191
|
+
* @author ddj 2026年11月03号
|
|
192
|
+
* @param row 行数据
|
|
193
|
+
* @param indent 缩进样式
|
|
194
|
+
* @param onToggle 展开回调
|
|
195
|
+
* @param retry 重试加载回调
|
|
196
|
+
* @returns 目录行元素
|
|
197
|
+
*/
|
|
198
|
+
function renderDirRow(
|
|
199
|
+
row: DlRow,
|
|
200
|
+
indent: React.CSSProperties,
|
|
201
|
+
onToggle: (path: string) => void,
|
|
202
|
+
retry: (path: string) => Promise<void>,
|
|
203
|
+
): React.ReactElement {
|
|
204
|
+
// 整行可点(三角与名称都展开),避免只有三角能点导致误以为无子项
|
|
205
|
+
return React.createElement('div', {
|
|
206
|
+
key: row.path,
|
|
207
|
+
className: 'edrv-dl-row is-dir',
|
|
208
|
+
style: indent,
|
|
209
|
+
onClick: () => onToggle(row.path),
|
|
210
|
+
},
|
|
211
|
+
React.createElement('span', { className: 'edrv-dl-twist' }, row.expanded ? '▾' : '▸'),
|
|
212
|
+
React.createElement('span', { className: 'edrv-dl-name edrv-dl-dir' }, row.name),
|
|
213
|
+
row.loading ? React.createElement('span', { className: 'edrv-dl-hint' }, '加载中…') : null,
|
|
214
|
+
row.error ? renderRetry(row.path, row.error, retry) : null,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 文件行:勾选框 + 名称 + 体积;超限文件禁用勾选并标注原因。
|
|
220
|
+
* @author ddj 2026年11月03号
|
|
221
|
+
* @param row 行数据
|
|
222
|
+
* @param indent 缩进样式
|
|
223
|
+
* @param onToggle 勾选回调
|
|
224
|
+
* @returns 文件行元素
|
|
225
|
+
*/
|
|
226
|
+
function renderFileRow(
|
|
227
|
+
row: DlRow,
|
|
228
|
+
indent: React.CSSProperties,
|
|
229
|
+
onToggle: (path: string) => void,
|
|
230
|
+
): React.ReactElement {
|
|
231
|
+
const oversized = isOversized(row.size)
|
|
232
|
+
return React.createElement('div', { key: row.path, className: 'edrv-dl-row', style: indent },
|
|
233
|
+
React.createElement('input', {
|
|
234
|
+
type: 'checkbox',
|
|
235
|
+
checked: row.checked,
|
|
236
|
+
disabled: oversized,
|
|
237
|
+
onChange: () => onToggle(row.path),
|
|
238
|
+
}),
|
|
239
|
+
React.createElement('span', { className: 'edrv-dl-name', onClick: () => onToggle(row.path) }, row.name),
|
|
240
|
+
oversized
|
|
241
|
+
? React.createElement('span', { className: 'edrv-dl-size is-large' }, '超过 32MB')
|
|
242
|
+
: (typeof row.size === 'number' ? React.createElement('span', { className: 'edrv-dl-size' }, formatSize(row.size)) : null),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 错误行 + 重试按钮(保留主机错误原文)。
|
|
248
|
+
* @author ddj 2026年11月03号
|
|
249
|
+
* @param path 目录路径
|
|
250
|
+
* @param message 错误原文
|
|
251
|
+
* @param retry 重试回调
|
|
252
|
+
* @returns 提示行元素
|
|
253
|
+
*/
|
|
254
|
+
function renderRetry(path: string, message: string, retry: (path: string) => Promise<void>): React.ReactElement {
|
|
255
|
+
return React.createElement('div', { key: 'err:' + path, className: 'edrv-dl-hint is-error' },
|
|
256
|
+
React.createElement('span', null, message),
|
|
257
|
+
React.createElement('button', {
|
|
258
|
+
type: 'button',
|
|
259
|
+
onClick: (event: React.MouseEvent) => {
|
|
260
|
+
// 错误行嵌在目录行内,重试不应顺带切换该目录的展开态
|
|
261
|
+
event.stopPropagation()
|
|
262
|
+
void retry(path)
|
|
263
|
+
},
|
|
264
|
+
}, '重试'),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 挂载弹窗:临时宿主 + createRoot,关闭时卸载并移除(与工作区选择弹窗同一范式)。
|
|
270
|
+
* @author ddj 2026年11月03号
|
|
271
|
+
* @param opts 根目录 / 会话 / 确认回调
|
|
272
|
+
* @sideEffects 向 body 追加一个宿主节点,关闭时移除
|
|
273
|
+
*/
|
|
274
|
+
export function openDownloader(opts: { root: string; sessionId?: string; onConfirm: (paths: string[]) => void }): void {
|
|
275
|
+
const host = document.createElement('div')
|
|
276
|
+
document.body.appendChild(host)
|
|
277
|
+
const mount = createRoot(host)
|
|
278
|
+
|
|
279
|
+
function close(): void {
|
|
280
|
+
mount.unmount()
|
|
281
|
+
host.remove()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function confirm(paths: string[]): void {
|
|
285
|
+
close()
|
|
286
|
+
opts.onConfirm(paths)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
mount.render(React.createElement(DownloadPicker, {
|
|
290
|
+
root: opts.root,
|
|
291
|
+
sessionId: opts.sessionId,
|
|
292
|
+
onClose: close,
|
|
293
|
+
onConfirm: confirm,
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 错误可读文案(Error.message 优先,其余 String 化)。
|
|
299
|
+
* @author ddj 2026年11月03号
|
|
300
|
+
* @param error 捕获值
|
|
301
|
+
* @returns 提示文案
|
|
302
|
+
*/
|
|
303
|
+
function textOfError(error: unknown): string {
|
|
304
|
+
return error instanceof Error ? error.message : String(error)
|
|
305
|
+
}
|
|
@@ -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号
|