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.
- package/README.md +15 -5
- package/lib/client.js +900 -48
- package/lib/client.js.map +2 -2
- package/package.json +1 -1
- package/src/client/downloadFile.ts +238 -0
- package/src/client/downloadPickerModel.ts +335 -0
- package/src/client/downloadSink.ts +162 -0
- package/src/client/sidebar/menuItems.ts +27 -0
- package/src/client/styles/editor.css +24 -0
- package/src/client/ui/DownloadPicker.ts +306 -0
- package/src/shared/remoteChannel.ts +11 -0
|
@@ -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号
|