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
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:
|
|
4
|
-
sha256:
|
|
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.
|
|
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": [
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 资源管理器「下载到本机」下载原语。
|
|
3
|
+
*
|
|
4
|
+
* 远程访问时工作区在远端机器上,浏览器拿不到本地落盘入口:本模块把
|
|
5
|
+
* 「相对路径 → 主机字节 → 浏览器下载」串成一条链,供右键菜单调用。
|
|
6
|
+
*
|
|
7
|
+
* 读取走 shared 的 readBinaryPreview:本机走 edrv.readBinary 原始字节,远程代理
|
|
8
|
+
* 会剥掉 x-edrv-* 响应头、自动回退 edrv.read 的 base64 分支(功能不降级,体积 +33%)。
|
|
9
|
+
*
|
|
10
|
+
* 约束:
|
|
11
|
+
* - 不打包、不新增主机 RPC;勾选多个文件时逐个下载原文件(串行,避免并发大文件
|
|
12
|
+
* 打爆 RPC 内存,也贴合「不打包」的产品决策)。
|
|
13
|
+
* - 单个文件失败不整批中断:失败条目记入结果,由调用方统一提示。
|
|
14
|
+
* 作者 ddj 2026年11月03号
|
|
15
|
+
*/
|
|
16
|
+
import { readBinaryPreview } from '../shared/rpc.js'
|
|
17
|
+
import { baseNameOf } from '../shared/fsNames.js'
|
|
18
|
+
import { rpc } from './rpc.js'
|
|
19
|
+
import type { SaveFallback, SaveSink } from './downloadSink.js'
|
|
20
|
+
import { pickSaveSink, writeFileToDir } from './downloadSink.js'
|
|
21
|
+
import type { TreeMenuTarget } from './sidebar/contextMenu.js'
|
|
22
|
+
import type { SidebarCtx } from './sidebar/types.js'
|
|
23
|
+
import { openDownloader } from './ui/DownloadPicker.js'
|
|
24
|
+
|
|
25
|
+
// --region 类型
|
|
26
|
+
|
|
27
|
+
/** 单个文件的下载结果(字节数供提示用)。 */
|
|
28
|
+
export interface DownloadedFile {
|
|
29
|
+
path: string
|
|
30
|
+
size: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 批量下载结果:成功路径保持入参顺序,失败保留主机原文。 */
|
|
34
|
+
export interface DownloadBatch {
|
|
35
|
+
ok: string[]
|
|
36
|
+
failed: Array<{ path: string; message: string }>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 批量下载选项。 */
|
|
40
|
+
export interface DownloadOpts {
|
|
41
|
+
sessionId?: string
|
|
42
|
+
/** 取消信号:只在下个文件开始前检查(不中断进行中的请求)。 */
|
|
43
|
+
signal?: AbortSignal
|
|
44
|
+
/** 每完成一个文件回调一次(done 含成功与失败)。 */
|
|
45
|
+
onProgress?: (done: number, total: number, path: string) => void
|
|
46
|
+
/** 落盘去处;缺省按浏览器默认下载目录(`<a download>`)。 */
|
|
47
|
+
sink?: SaveSink
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --endregion
|
|
51
|
+
|
|
52
|
+
// --region 单文件
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 触发浏览器下载(Blob + 隐藏 <a download>),与 Excel 导出同一落盘范式。
|
|
56
|
+
* @author ddj 2026年11月03号
|
|
57
|
+
* @param bytes 原始字节
|
|
58
|
+
* @param fileName 保存文件名(浏览器重名时自动加序号)
|
|
59
|
+
* @param mime MIME 类型;空值退化为 application/octet-stream
|
|
60
|
+
* @sideEffects 创建并点击一个游离 <a> 节点,60 秒后释放 objectURL
|
|
61
|
+
*/
|
|
62
|
+
export function downloadBytes(bytes: Uint8Array, fileName: string, mime: string): void {
|
|
63
|
+
const type = mime || 'application/octet-stream'
|
|
64
|
+
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }))
|
|
65
|
+
const anchor = document.createElement('a')
|
|
66
|
+
|
|
67
|
+
anchor.href = url
|
|
68
|
+
anchor.download = fileName
|
|
69
|
+
anchor.rel = 'noopener'
|
|
70
|
+
anchor.click()
|
|
71
|
+
|
|
72
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 读取单个文件的原始字节(二进制通道优先,base64 回退)。
|
|
77
|
+
* @author ddj 2026年11月03号
|
|
78
|
+
* @param path 工作区相对路径
|
|
79
|
+
* @param sessionId 会话 id(工作区解析用)
|
|
80
|
+
* @returns 字节 + MIME + 字节数(MIME 缺失按路径推断)
|
|
81
|
+
* @throws 主机拒绝或读取失败时抛出主机错误原文(如「文件过大(>32MB)…」)
|
|
82
|
+
*/
|
|
83
|
+
export async function fetchDownload(path: string, sessionId?: string): Promise<{ bytes: Uint8Array; mime: string; size: number }> {
|
|
84
|
+
const args = sessionId ? { sessionId, path } : { path }
|
|
85
|
+
const out = await readBinaryPreview(rpc, args)
|
|
86
|
+
if (!out.ok) throw new Error(out.error)
|
|
87
|
+
if (out.via === 'binary') return { bytes: out.bytes, mime: out.mime, size: out.size }
|
|
88
|
+
return { bytes: decodeBase64(out.content), mime: out.mime || 'application/octet-stream', size: out.size }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 下载单个文件并落盘(所选目录则按相对路径写入,否则交给浏览器 `<a download>`)。
|
|
93
|
+
* @author ddj 2026年11月03号
|
|
94
|
+
* @param path 工作区相对路径
|
|
95
|
+
* @param opts 会话 / 取消 / 落盘选项
|
|
96
|
+
* @returns 路径与字节数
|
|
97
|
+
* @throws 读取失败透传主机错误;目录写入失败透传底层错误
|
|
98
|
+
*/
|
|
99
|
+
export async function downloadOne(path: string, opts: DownloadOpts = {}): Promise<DownloadedFile> {
|
|
100
|
+
const file = await fetchDownload(path, opts.sessionId)
|
|
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
|
+
}
|
|
106
|
+
return { path, size: file.size }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --endregion
|
|
110
|
+
|
|
111
|
+
// --region 批量与入口
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 串行下载多个文件(逐个原文件,不打包)。
|
|
115
|
+
* @author ddj 2026年11月03号
|
|
116
|
+
* @param paths 工作区相对路径列表(按用户勾选顺序)
|
|
117
|
+
* @param opts 会话 / 取消 / 进度选项
|
|
118
|
+
* @returns 成功与失败清单;单项失败不中断后续
|
|
119
|
+
*/
|
|
120
|
+
export async function downloadMany(paths: string[], opts: DownloadOpts = {}): Promise<DownloadBatch> {
|
|
121
|
+
const ok: string[] = []
|
|
122
|
+
const failed: Array<{ path: string; message: string }> = []
|
|
123
|
+
|
|
124
|
+
for (const path of paths) {
|
|
125
|
+
if (opts.signal?.aborted) break
|
|
126
|
+
try {
|
|
127
|
+
await downloadOne(path, opts)
|
|
128
|
+
ok.push(path)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
failed.push({ path, message: textOfError(error) })
|
|
131
|
+
}
|
|
132
|
+
opts.onProgress?.(ok.length + failed.length, paths.length, path)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { ok, failed }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 右键菜单入口:文件直下,文件夹 / 工作区根打开勾选弹窗。
|
|
140
|
+
* @author ddj 2026年11月03号
|
|
141
|
+
* @param target 右键目标(根空白区为 path='' 的 directory)
|
|
142
|
+
* @param ctx 面板上下文(会话 id 与状态栏反馈)
|
|
143
|
+
* @sideEffects 触发浏览器下载或挂载弹窗;错误经 ctx.notify 透出
|
|
144
|
+
*/
|
|
145
|
+
export async function downloadTarget(target: TreeMenuTarget, ctx?: SidebarCtx): Promise<void> {
|
|
146
|
+
if (target.type === 'file') {
|
|
147
|
+
await runBatch([target.path], ctx)
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
openDownloader({
|
|
152
|
+
root: target.path,
|
|
153
|
+
sessionId: ctx?.sessionId,
|
|
154
|
+
onConfirm: (paths: string[]) => { void runBatch(paths, ctx) },
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 批量下载并统一反馈(先选保存目录,再逐文件落盘;开始一条、结束一条,避免逐文件刷屏)。
|
|
160
|
+
* @author ddj 2026年11月03号
|
|
161
|
+
* @param paths 勾选路径
|
|
162
|
+
* @param ctx 面板上下文
|
|
163
|
+
* @sideEffects 弹出目录选择器;用户取消则整批中止
|
|
164
|
+
*/
|
|
165
|
+
async function runBatch(paths: string[], ctx?: SidebarCtx): Promise<void> {
|
|
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))
|
|
175
|
+
if (paths.length > 1) ctx?.notify?.('正在下载 ' + paths.length + ' 个文件…')
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const out = await downloadMany(paths, { sessionId: ctx?.sessionId, sink })
|
|
179
|
+
ctx?.notify?.(batchText(out))
|
|
180
|
+
} catch (error) {
|
|
181
|
+
ctx?.notify?.('下载失败:' + textOfError(error))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
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
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 批量结果口播文案。
|
|
199
|
+
* @author ddj 2026年11月03号
|
|
200
|
+
* @param out 批量结果
|
|
201
|
+
* @returns 单行提示文案
|
|
202
|
+
*/
|
|
203
|
+
function batchText(out: DownloadBatch): string {
|
|
204
|
+
if (!out.failed.length) return '已下载 ' + out.ok.length + ' 个文件'
|
|
205
|
+
const first = out.failed[0]
|
|
206
|
+
const tail = '(' + first.path + ':' + first.message + ')'
|
|
207
|
+
if (!out.ok.length) return '下载失败:' + first.message + tail
|
|
208
|
+
return '已下载 ' + out.ok.length + ' 个,失败 ' + out.failed.length + ' 个' + tail
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --endregion
|
|
212
|
+
|
|
213
|
+
// --region 工具
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* base64 → 字节(回退通道解码;逐字节写入避免大文件二次拷贝放大)。
|
|
217
|
+
* @author ddj 2026年11月03号
|
|
218
|
+
* @param text base64 文本
|
|
219
|
+
* @returns 原始字节
|
|
220
|
+
*/
|
|
221
|
+
function decodeBase64(text: string): Uint8Array {
|
|
222
|
+
const binary = atob(text)
|
|
223
|
+
const bytes = new Uint8Array(binary.length)
|
|
224
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
225
|
+
return bytes
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 错误可读文案(Error.message 优先,其余 String 化)。
|
|
230
|
+
* @author ddj 2026年11月03号
|
|
231
|
+
* @param error 捕获值
|
|
232
|
+
* @returns 提示文案
|
|
233
|
+
*/
|
|
234
|
+
function textOfError(error: unknown): string {
|
|
235
|
+
return error instanceof Error ? error.message : String(error)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --endregion
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 「下载到本机」弹窗的目录树与勾选模型(纯逻辑)。
|
|
3
|
+
*
|
|
4
|
+
* 与 React 解耦:组件只读 rowList 渲染、只调这里的函数改状态,因此本文件可在
|
|
5
|
+
* node 环境单测(vitest 无可视 DOM)。状态全部不可变返回,便于 React 直接替换。
|
|
6
|
+
*
|
|
7
|
+
* 设计决策(产品已确认「目录树 + 勾选、不打包」):
|
|
8
|
+
* - 目录节点只负责展开,不带勾选框;勾选粒度是文件(避免「半选目录」的语义歧义)。
|
|
9
|
+
* - 懒加载:一次只 listDir 一层;「全选」只作用于已加载文件,不对未展开目录做
|
|
10
|
+
* 隐式整树拉取(否则 node_modules 级目录会把弹窗一次性拉爆)。
|
|
11
|
+
* 作者 ddj 2026年11月03号
|
|
12
|
+
*/
|
|
13
|
+
import type { TreeEntry } from '../shared/rpc.js'
|
|
14
|
+
|
|
15
|
+
/** 单文件下载上限,与主机二进制读取上限(BINARY_READ_CAP)保持一致。 */
|
|
16
|
+
export const DL_SIZE_CAP = 32 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
/** 树节点:只保留文件与目录(主机的 'other' 不进弹窗)。 */
|
|
19
|
+
export interface DlNode {
|
|
20
|
+
name: string
|
|
21
|
+
path: string
|
|
22
|
+
kind: 'file' | 'directory'
|
|
23
|
+
/** 主机的可选大小;缺失时界面不显示体积,也不做上限预判。 */
|
|
24
|
+
size?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 弹窗状态:目录树 + 展开态 + 加载态 + 勾选集合。 */
|
|
28
|
+
export interface DlState {
|
|
29
|
+
/** 弹窗根(工作区相对路径;'' = 工作区根目录)。 */
|
|
30
|
+
root: string
|
|
31
|
+
nodes: Map<string, DlNode>
|
|
32
|
+
/** 目录路径 → 已加载子项(按目录优先 + 名称排序)。 */
|
|
33
|
+
children: Map<string, string[]>
|
|
34
|
+
expanded: Set<string>
|
|
35
|
+
loading: Set<string>
|
|
36
|
+
selected: Set<string>
|
|
37
|
+
/** 目录路径 → 该层加载失败的主机原文。 */
|
|
38
|
+
errors: Map<string, string>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 一行渲染数据(深度优先铺平当前可见节点)。 */
|
|
42
|
+
export interface DlRow {
|
|
43
|
+
path: string
|
|
44
|
+
name: string
|
|
45
|
+
kind: 'file' | 'directory'
|
|
46
|
+
depth: number
|
|
47
|
+
expanded: boolean
|
|
48
|
+
checked: boolean
|
|
49
|
+
size?: number
|
|
50
|
+
loading: boolean
|
|
51
|
+
error?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 初始状态:根目录默认展开,等待首层 listDir 填充。
|
|
56
|
+
* @author ddj 2026年11月03号
|
|
57
|
+
* @param root 弹窗根(工作区相对路径,'' = 工作区根)
|
|
58
|
+
* @returns 空树状态
|
|
59
|
+
*/
|
|
60
|
+
export function createDlState(root: string): DlState {
|
|
61
|
+
return {
|
|
62
|
+
root,
|
|
63
|
+
nodes: new Map(),
|
|
64
|
+
children: new Map(),
|
|
65
|
+
expanded: new Set([root]),
|
|
66
|
+
loading: new Set(),
|
|
67
|
+
selected: new Set(),
|
|
68
|
+
errors: new Map(),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 合并某目录的 listDir 结果(替换该层子项,清理本次已不存在的旧子项)。
|
|
74
|
+
* @author ddj 2026年11月03号
|
|
75
|
+
* @param state 当前状态
|
|
76
|
+
* @param dirPath 目录路径('' = 工作区根)
|
|
77
|
+
* @param entries 主机返回的目录项
|
|
78
|
+
* @returns 新状态(loading / errors 中的该目录项被清除)
|
|
79
|
+
*/
|
|
80
|
+
export function mergeChildren(state: DlState, dirPath: string, entries: TreeEntry[]): DlState {
|
|
81
|
+
const nodes = new Map(state.nodes)
|
|
82
|
+
const children = new Map(state.children)
|
|
83
|
+
const expanded = new Set(state.expanded)
|
|
84
|
+
const loading = new Set(state.loading)
|
|
85
|
+
const selected = new Set(state.selected)
|
|
86
|
+
const errors = new Map(state.errors)
|
|
87
|
+
const previous = children.get(dirPath) ?? []
|
|
88
|
+
const nextPaths: string[] = []
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.type === 'other') continue
|
|
92
|
+
const kind: DlNode['kind'] = entry.type === 'directory' ? 'directory' : 'file'
|
|
93
|
+
nodes.set(entry.path, { name: entry.name, path: entry.path, kind, size: entry.size })
|
|
94
|
+
nextPaths.push(entry.path)
|
|
95
|
+
}
|
|
96
|
+
nextPaths.sort((left, right) => compareNodes(nodes, left, right))
|
|
97
|
+
children.set(dirPath, nextPaths)
|
|
98
|
+
loading.delete(dirPath)
|
|
99
|
+
errors.delete(dirPath)
|
|
100
|
+
|
|
101
|
+
// 本次列表里消失的旧子项整支清理:残留节点会让勾选命中幽灵路径,也会让弹窗显示已删文件。
|
|
102
|
+
for (const stale of previous) {
|
|
103
|
+
if (nextPaths.includes(stale)) continue
|
|
104
|
+
nodes.delete(stale)
|
|
105
|
+
selected.delete(stale)
|
|
106
|
+
children.delete(stale)
|
|
107
|
+
expanded.delete(stale)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { root: state.root, nodes, children, expanded, loading, selected, errors }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 标记某目录正在加载(展开时先出「加载中…」行)。
|
|
115
|
+
* @author ddj 2026年11月03号
|
|
116
|
+
* @param state 当前状态
|
|
117
|
+
* @param dirPath 目录路径
|
|
118
|
+
* @returns 新状态
|
|
119
|
+
*/
|
|
120
|
+
export function markLoading(state: DlState, dirPath: string): DlState {
|
|
121
|
+
const loading = new Set(state.loading)
|
|
122
|
+
loading.add(dirPath)
|
|
123
|
+
return { ...state, loading }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 标记某目录加载失败(保留错误原文供重试行显示)。
|
|
128
|
+
* @author ddj 2026年11月03号
|
|
129
|
+
* @param state 当前状态
|
|
130
|
+
* @param dirPath 目录路径
|
|
131
|
+
* @param message 主机错误原文
|
|
132
|
+
* @returns 新状态
|
|
133
|
+
*/
|
|
134
|
+
export function markError(state: DlState, dirPath: string, message: string): DlState {
|
|
135
|
+
const loading = new Set(state.loading)
|
|
136
|
+
const errors = new Map(state.errors)
|
|
137
|
+
loading.delete(dirPath)
|
|
138
|
+
errors.set(dirPath, message)
|
|
139
|
+
return { ...state, loading, errors }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 展开目录(不改 children,加载由调用方按 needLoad 判定)。
|
|
144
|
+
* @author ddj 2026年11月03号
|
|
145
|
+
* @param state 当前状态
|
|
146
|
+
* @param dirPath 目录路径
|
|
147
|
+
* @returns 新状态
|
|
148
|
+
*/
|
|
149
|
+
export function expandDir(state: DlState, dirPath: string): DlState {
|
|
150
|
+
const expanded = new Set(state.expanded)
|
|
151
|
+
expanded.add(dirPath)
|
|
152
|
+
return { ...state, expanded }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 收起目录。
|
|
157
|
+
* @author ddj 2026年11月03号
|
|
158
|
+
* @param state 当前状态
|
|
159
|
+
* @param dirPath 目录路径
|
|
160
|
+
* @returns 新状态
|
|
161
|
+
*/
|
|
162
|
+
export function collapseDir(state: DlState, dirPath: string): DlState {
|
|
163
|
+
const expanded = new Set(state.expanded)
|
|
164
|
+
expanded.delete(dirPath)
|
|
165
|
+
return { ...state, expanded }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 目录是否已展开。
|
|
170
|
+
* @author ddj 2026年11月03号
|
|
171
|
+
* @param state 当前状态
|
|
172
|
+
* @param dirPath 目录路径
|
|
173
|
+
* @returns 是否展开
|
|
174
|
+
*/
|
|
175
|
+
export function isExpanded(state: DlState, dirPath: string): boolean {
|
|
176
|
+
return state.expanded.has(dirPath)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 某目录是否已拉取过子项(决定展开时要不要发 listDir)。
|
|
181
|
+
* @author ddj 2026年11月03号
|
|
182
|
+
* @param state 当前状态
|
|
183
|
+
* @param dirPath 目录路径
|
|
184
|
+
* @returns 是否已加载
|
|
185
|
+
*/
|
|
186
|
+
export function hasLoaded(state: DlState, dirPath: string): boolean {
|
|
187
|
+
return state.children.has(dirPath)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 切换文件勾选(超过上限的文件不可勾选;目录与未知路径忽略)。
|
|
192
|
+
* @author ddj 2026年11月03号
|
|
193
|
+
* @param state 当前状态
|
|
194
|
+
* @param path 文件路径
|
|
195
|
+
* @returns 新状态
|
|
196
|
+
*/
|
|
197
|
+
export function toggleFile(state: DlState, path: string): DlState {
|
|
198
|
+
const node = state.nodes.get(path)
|
|
199
|
+
if (!node || node.kind !== 'file') return state
|
|
200
|
+
if (isOversized(node.size)) return state
|
|
201
|
+
const selected = new Set(state.selected)
|
|
202
|
+
if (selected.has(path)) selected.delete(path)
|
|
203
|
+
else selected.add(path)
|
|
204
|
+
return { ...state, selected }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 全选已加载文件(不含超限文件)。
|
|
209
|
+
* @author ddj 2026年11月03号
|
|
210
|
+
* @param state 当前状态
|
|
211
|
+
* @returns 新状态
|
|
212
|
+
*/
|
|
213
|
+
export function selectLoaded(state: DlState): DlState {
|
|
214
|
+
const selected = new Set<string>()
|
|
215
|
+
for (const path of loadedFiles(state)) {
|
|
216
|
+
const node = state.nodes.get(path)
|
|
217
|
+
if (node && !isOversized(node.size)) selected.add(path)
|
|
218
|
+
}
|
|
219
|
+
return { ...state, selected }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 清空勾选。
|
|
224
|
+
* @author ddj 2026年11月03号
|
|
225
|
+
* @param state 当前状态
|
|
226
|
+
* @returns 新状态
|
|
227
|
+
*/
|
|
228
|
+
export function clearSelected(state: DlState): DlState {
|
|
229
|
+
return { ...state, selected: new Set<string>() }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 已勾选文件(按路径排序,保证下载顺序稳定可测)。
|
|
234
|
+
* @author ddj 2026年11月03号
|
|
235
|
+
* @param state 当前状态
|
|
236
|
+
* @returns 路径列表
|
|
237
|
+
*/
|
|
238
|
+
export function selectedFiles(state: DlState): string[] {
|
|
239
|
+
return Array.from(state.selected).sort()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 已加载的文件路径(全选的作用域就是它)。
|
|
244
|
+
* @author ddj 2026年11月03号
|
|
245
|
+
* @param state 当前状态
|
|
246
|
+
* @returns 路径列表
|
|
247
|
+
*/
|
|
248
|
+
export function loadedFiles(state: DlState): string[] {
|
|
249
|
+
const out: string[] = []
|
|
250
|
+
for (const node of state.nodes.values()) {
|
|
251
|
+
if (node.kind === 'file') out.push(node.path)
|
|
252
|
+
}
|
|
253
|
+
return out.sort()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 当前可见行(深度优先;只展开已展开目录)。
|
|
258
|
+
* @author ddj 2026年11月03号
|
|
259
|
+
* @param state 当前状态
|
|
260
|
+
* @returns 渲染行列表
|
|
261
|
+
*/
|
|
262
|
+
export function rowList(state: DlState): DlRow[] {
|
|
263
|
+
const rows: DlRow[] = []
|
|
264
|
+
appendRows(state, state.root, 0, rows)
|
|
265
|
+
return rows
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 递归铺平子项(仅内部使用;深度受限于用户实际展开层数)。
|
|
270
|
+
* @author ddj 2026年11月03号
|
|
271
|
+
* @param state 当前状态
|
|
272
|
+
* @param dirPath 当前目录
|
|
273
|
+
* @param depth 缩进层级
|
|
274
|
+
* @param rows 输出累积
|
|
275
|
+
*/
|
|
276
|
+
function appendRows(state: DlState, dirPath: string, depth: number, rows: DlRow[]): void {
|
|
277
|
+
if (!state.expanded.has(dirPath)) return
|
|
278
|
+
const childPaths = state.children.get(dirPath) ?? []
|
|
279
|
+
for (const path of childPaths) {
|
|
280
|
+
const node = state.nodes.get(path)
|
|
281
|
+
if (!node) continue
|
|
282
|
+
const expanded = node.kind === 'directory' && state.expanded.has(path)
|
|
283
|
+
rows.push({
|
|
284
|
+
path,
|
|
285
|
+
name: node.name,
|
|
286
|
+
kind: node.kind,
|
|
287
|
+
depth,
|
|
288
|
+
expanded,
|
|
289
|
+
checked: state.selected.has(path),
|
|
290
|
+
size: node.size,
|
|
291
|
+
loading: state.loading.has(path),
|
|
292
|
+
error: state.errors.get(path),
|
|
293
|
+
})
|
|
294
|
+
if (node.kind === 'directory') appendRows(state, path, depth + 1, rows)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 体积可读文案(列表右侧提示用)。
|
|
300
|
+
* @author ddj 2026年11月03号
|
|
301
|
+
* @param size 字节数
|
|
302
|
+
* @returns 如 `512 B` / `12 KB` / `3.4 MB`
|
|
303
|
+
*/
|
|
304
|
+
export function formatSize(size: number): string {
|
|
305
|
+
if (!Number.isFinite(size) || size < 0) return ''
|
|
306
|
+
if (size < 1024) return size + ' B'
|
|
307
|
+
if (size < 1024 * 1024) return Math.round(size / 1024) + ' KB'
|
|
308
|
+
return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 体积是否超过单文件上限(主机 >32MB 会直接拒绝,界面提前拦一层)。
|
|
313
|
+
* @author ddj 2026年11月03号
|
|
314
|
+
* @param size 字节数(主机未提供体积时为 undefined ⇒ 不预判)
|
|
315
|
+
* @returns 是否超限
|
|
316
|
+
*/
|
|
317
|
+
export function isOversized(size: number | undefined): boolean {
|
|
318
|
+
return typeof size === 'number' && size > DL_SIZE_CAP
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 目录优先 + 名称排序的比较器(与资源管理器视觉一致)。
|
|
323
|
+
* @author ddj 2026年11月03号
|
|
324
|
+
* @param nodes 节点表
|
|
325
|
+
* @param left 左路径
|
|
326
|
+
* @param right 右路径
|
|
327
|
+
* @returns 比较结果
|
|
328
|
+
*/
|
|
329
|
+
function compareNodes(nodes: Map<string, DlNode>, left: string, right: string): number {
|
|
330
|
+
const one = nodes.get(left)
|
|
331
|
+
const two = nodes.get(right)
|
|
332
|
+
if (!one || !two) return 0
|
|
333
|
+
if (one.kind !== two.kind) return one.kind === 'directory' ? -1 : 1
|
|
334
|
+
return one.name.localeCompare(two.name)
|
|
335
|
+
}
|