dsh-vscode-mode 0.23.5 → 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 +125 -25
- package/lib/client.js +978 -63
- package/lib/client.js.map +2 -2
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/downloadFile.ts +208 -0
- package/src/client/downloadPickerModel.ts +335 -0
- package/src/client/loadFailure.ts +84 -0
- package/src/client/monaco/loader.ts +9 -6
- package/src/client/pdf/pdfLoader.ts +10 -4
- package/src/client/rpc.ts +54 -10
- package/src/client/sidebar/menuItems.ts +27 -0
- package/src/client/styles/editor.css +24 -0
- package/src/client/ui/DiffBadge.ts +2 -2
- package/src/client/ui/DownloadPicker.ts +305 -0
- package/src/client/ui/EditorView.ts +2 -1
- package/src/shared/remoteChannel.ts +70 -0
- package/src/shared/rpc.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
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,208 @@
|
|
|
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 { TreeMenuTarget } from './sidebar/contextMenu.js'
|
|
20
|
+
import type { SidebarCtx } from './sidebar/types.js'
|
|
21
|
+
import { openDownloader } from './ui/DownloadPicker.js'
|
|
22
|
+
|
|
23
|
+
// --region 类型
|
|
24
|
+
|
|
25
|
+
/** 单个文件的下载结果(字节数供提示用)。 */
|
|
26
|
+
export interface DownloadedFile {
|
|
27
|
+
path: string
|
|
28
|
+
size: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 批量下载结果:成功路径保持入参顺序,失败保留主机原文。 */
|
|
32
|
+
export interface DownloadBatch {
|
|
33
|
+
ok: string[]
|
|
34
|
+
failed: Array<{ path: string; message: string }>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 批量下载选项。 */
|
|
38
|
+
export interface DownloadOpts {
|
|
39
|
+
sessionId?: string
|
|
40
|
+
/** 取消信号:只在下个文件开始前检查(不中断进行中的请求)。 */
|
|
41
|
+
signal?: AbortSignal
|
|
42
|
+
/** 每完成一个文件回调一次(done 含成功与失败)。 */
|
|
43
|
+
onProgress?: (done: number, total: number, path: string) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --endregion
|
|
47
|
+
|
|
48
|
+
// --region 单文件
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 触发浏览器下载(Blob + 隐藏 <a download>),与 Excel 导出同一落盘范式。
|
|
52
|
+
* @author ddj 2026年11月03号
|
|
53
|
+
* @param bytes 原始字节
|
|
54
|
+
* @param fileName 保存文件名(浏览器重名时自动加序号)
|
|
55
|
+
* @param mime MIME 类型;空值退化为 application/octet-stream
|
|
56
|
+
* @sideEffects 创建并点击一个游离 <a> 节点,60 秒后释放 objectURL
|
|
57
|
+
*/
|
|
58
|
+
export function downloadBytes(bytes: Uint8Array, fileName: string, mime: string): void {
|
|
59
|
+
const type = mime || 'application/octet-stream'
|
|
60
|
+
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }))
|
|
61
|
+
const anchor = document.createElement('a')
|
|
62
|
+
|
|
63
|
+
anchor.href = url
|
|
64
|
+
anchor.download = fileName
|
|
65
|
+
anchor.rel = 'noopener'
|
|
66
|
+
anchor.click()
|
|
67
|
+
|
|
68
|
+
setTimeout(() => URL.revokeObjectURL(url), 60000)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 读取单个文件的原始字节(二进制通道优先,base64 回退)。
|
|
73
|
+
* @author ddj 2026年11月03号
|
|
74
|
+
* @param path 工作区相对路径
|
|
75
|
+
* @param sessionId 会话 id(工作区解析用)
|
|
76
|
+
* @returns 字节 + MIME + 字节数(MIME 缺失按路径推断)
|
|
77
|
+
* @throws 主机拒绝或读取失败时抛出主机错误原文(如「文件过大(>32MB)…」)
|
|
78
|
+
*/
|
|
79
|
+
export async function fetchDownload(path: string, sessionId?: string): Promise<{ bytes: Uint8Array; mime: string; size: number }> {
|
|
80
|
+
const args = sessionId ? { sessionId, path } : { path }
|
|
81
|
+
const out = await readBinaryPreview(rpc, args)
|
|
82
|
+
if (!out.ok) throw new Error(out.error)
|
|
83
|
+
if (out.via === 'binary') return { bytes: out.bytes, mime: out.mime, size: out.size }
|
|
84
|
+
return { bytes: decodeBase64(out.content), mime: out.mime || 'application/octet-stream', size: out.size }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 下载单个文件并交给浏览器落盘。
|
|
89
|
+
* @author ddj 2026年11月03号
|
|
90
|
+
* @param path 工作区相对路径
|
|
91
|
+
* @param opts 会话 / 取消选项
|
|
92
|
+
* @returns 路径与字节数
|
|
93
|
+
* @throws 读取失败透传主机错误
|
|
94
|
+
*/
|
|
95
|
+
export async function downloadOne(path: string, opts: DownloadOpts = {}): Promise<DownloadedFile> {
|
|
96
|
+
const file = await fetchDownload(path, opts.sessionId)
|
|
97
|
+
downloadBytes(file.bytes, baseNameOf(path) || 'download', file.mime)
|
|
98
|
+
return { path, size: file.size }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --endregion
|
|
102
|
+
|
|
103
|
+
// --region 批量与入口
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 串行下载多个文件(逐个原文件,不打包)。
|
|
107
|
+
* @author ddj 2026年11月03号
|
|
108
|
+
* @param paths 工作区相对路径列表(按用户勾选顺序)
|
|
109
|
+
* @param opts 会话 / 取消 / 进度选项
|
|
110
|
+
* @returns 成功与失败清单;单项失败不中断后续
|
|
111
|
+
*/
|
|
112
|
+
export async function downloadMany(paths: string[], opts: DownloadOpts = {}): Promise<DownloadBatch> {
|
|
113
|
+
const ok: string[] = []
|
|
114
|
+
const failed: Array<{ path: string; message: string }> = []
|
|
115
|
+
|
|
116
|
+
for (const path of paths) {
|
|
117
|
+
if (opts.signal?.aborted) break
|
|
118
|
+
try {
|
|
119
|
+
await downloadOne(path, opts)
|
|
120
|
+
ok.push(path)
|
|
121
|
+
} catch (error) {
|
|
122
|
+
failed.push({ path, message: textOfError(error) })
|
|
123
|
+
}
|
|
124
|
+
opts.onProgress?.(ok.length + failed.length, paths.length, path)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { ok, failed }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 右键菜单入口:文件直下,文件夹 / 工作区根打开勾选弹窗。
|
|
132
|
+
* @author ddj 2026年11月03号
|
|
133
|
+
* @param target 右键目标(根空白区为 path='' 的 directory)
|
|
134
|
+
* @param ctx 面板上下文(会话 id 与状态栏反馈)
|
|
135
|
+
* @sideEffects 触发浏览器下载或挂载弹窗;错误经 ctx.notify 透出
|
|
136
|
+
*/
|
|
137
|
+
export async function downloadTarget(target: TreeMenuTarget, ctx?: SidebarCtx): Promise<void> {
|
|
138
|
+
if (target.type === 'file') {
|
|
139
|
+
await runBatch([target.path], ctx)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
openDownloader({
|
|
144
|
+
root: target.path,
|
|
145
|
+
sessionId: ctx?.sessionId,
|
|
146
|
+
onConfirm: (paths: string[]) => { void runBatch(paths, ctx) },
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 批量下载并统一反馈(开始一条、结束一条,避免逐文件刷屏)。
|
|
152
|
+
* @author ddj 2026年11月03号
|
|
153
|
+
* @param paths 勾选路径
|
|
154
|
+
* @param ctx 面板上下文
|
|
155
|
+
*/
|
|
156
|
+
async function runBatch(paths: string[], ctx?: SidebarCtx): Promise<void> {
|
|
157
|
+
if (!paths.length) return
|
|
158
|
+
if (paths.length > 1) ctx?.notify?.('正在下载 ' + paths.length + ' 个文件…')
|
|
159
|
+
try {
|
|
160
|
+
const out = await downloadMany(paths, { sessionId: ctx?.sessionId })
|
|
161
|
+
ctx?.notify?.(batchText(out))
|
|
162
|
+
} catch (error) {
|
|
163
|
+
ctx?.notify?.('下载失败:' + textOfError(error))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 批量结果口播文案。
|
|
169
|
+
* @author ddj 2026年11月03号
|
|
170
|
+
* @param out 批量结果
|
|
171
|
+
* @returns 单行提示文案
|
|
172
|
+
*/
|
|
173
|
+
function batchText(out: DownloadBatch): string {
|
|
174
|
+
if (!out.failed.length) return '已下载 ' + out.ok.length + ' 个文件'
|
|
175
|
+
const first = out.failed[0]
|
|
176
|
+
const tail = '(' + first.path + ':' + first.message + ')'
|
|
177
|
+
if (!out.ok.length) return '下载失败:' + first.message + tail
|
|
178
|
+
return '已下载 ' + out.ok.length + ' 个,失败 ' + out.failed.length + ' 个' + tail
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --endregion
|
|
182
|
+
|
|
183
|
+
// --region 工具
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* base64 → 字节(回退通道解码;逐字节写入避免大文件二次拷贝放大)。
|
|
187
|
+
* @author ddj 2026年11月03号
|
|
188
|
+
* @param text base64 文本
|
|
189
|
+
* @returns 原始字节
|
|
190
|
+
*/
|
|
191
|
+
function decodeBase64(text: string): Uint8Array {
|
|
192
|
+
const binary = atob(text)
|
|
193
|
+
const bytes = new Uint8Array(binary.length)
|
|
194
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
195
|
+
return bytes
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 错误可读文案(Error.message 优先,其余 String 化)。
|
|
200
|
+
* @author ddj 2026年11月03号
|
|
201
|
+
* @param error 捕获值
|
|
202
|
+
* @returns 提示文案
|
|
203
|
+
*/
|
|
204
|
+
function textOfError(error: unknown): string {
|
|
205
|
+
return error instanceof Error ? error.message : String(error)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// --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
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 加载失败诊断(把「加载失败」拆成可定位的四类原因)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:宿主对非 loopback/bind/trustedHosts 的 Host 一律回 403(Host 围栏),
|
|
5
|
+
* 认证失效回 401,路由或资源缺失回 404,另有纯网络失败。此前 Monaco/pdf.js 的失败面板
|
|
6
|
+
* 一律提示「检查资源是否完整」,远程访问时会把 403 误报成资源缺失,误导排查方向。
|
|
7
|
+
*
|
|
8
|
+
* 文案只输出 pathname 与状态码,不含 origin、查询串、cookie 或任何凭据。
|
|
9
|
+
* @author ddj 2026年11月02号
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 状态码 0:请求未完成(网络错误 / 被拦截 / 离线)。 */
|
|
13
|
+
const NETWORK_FAILED = 0
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 探测资源地址的真实响应状态。
|
|
17
|
+
* @author ddj 2026年11月02号
|
|
18
|
+
* @param url 已解析的绝对或根路径地址
|
|
19
|
+
* @returns HTTP 状态码;请求未完成时返回 0
|
|
20
|
+
*/
|
|
21
|
+
export async function probeStatus(url: string): Promise<number> {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch(url, { method: 'HEAD', credentials: 'same-origin' })
|
|
24
|
+
return res.status
|
|
25
|
+
} catch (e) {
|
|
26
|
+
return NETWORK_FAILED
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 取地址的 pathname(只用于诊断文案,避免暴露 origin 与查询串)。
|
|
32
|
+
* @author ddj 2026年11月02号
|
|
33
|
+
* @param url 绝对或根路径地址
|
|
34
|
+
* @returns pathname;无法解析时原样返回
|
|
35
|
+
*/
|
|
36
|
+
function pathOf(url: string): string {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(url, 'http://localhost').pathname
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return url
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 状态码 → 可操作的原因说明(四类互不相同)。
|
|
46
|
+
* @author ddj 2026年11月02号
|
|
47
|
+
* @param status HTTP 状态码,0 表示请求未完成
|
|
48
|
+
* @returns 原因说明
|
|
49
|
+
*/
|
|
50
|
+
export function statusHint(status: number): string {
|
|
51
|
+
if (status === NETWORK_FAILED) return '请求未到达宿主(网络不可达、被浏览器或代理拦截、或宿主已停止)'
|
|
52
|
+
if (status === 403) return '宿主拒绝了当前页面(Host/Origin 校验未通过;远程访问需经远程访问插件的通道)'
|
|
53
|
+
if (status === 401) return '宿主登录态失效,请重新打开已登录的应用页面'
|
|
54
|
+
if (status === 404) return '路由或资源不存在(插件资源缺失或插件版本与宿主不匹配)'
|
|
55
|
+
return '宿主返回了非预期状态'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 组装加载失败文案:标签 + 资源路径 + 状态 + 原因。
|
|
60
|
+
* @author ddj 2026年11月02号
|
|
61
|
+
* @param label 失败对象(如 'Monaco loader 加载失败')
|
|
62
|
+
* @param url 加载地址
|
|
63
|
+
* @param status 探测到的状态码(0=网络错误)
|
|
64
|
+
* @returns 用户可读的诊断文案
|
|
65
|
+
*/
|
|
66
|
+
export function loadFailureText(label: string, url: string, status: number): string {
|
|
67
|
+
const outcome = status === NETWORK_FAILED ? '请求未完成' : '返回 HTTP ' + status
|
|
68
|
+
return label + ':' + pathOf(url) + ' ' + outcome + ';' + statusHint(status)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Monaco 失败面板提示:按错误内容给出对应排查方向(不再一律提示「检查资源是否完整」)。
|
|
73
|
+
* @author ddj 2026年11月02号
|
|
74
|
+
* @param message 编辑器记录的失败原因
|
|
75
|
+
* @returns 排查建议
|
|
76
|
+
*/
|
|
77
|
+
export function monacoHint(message: string): string {
|
|
78
|
+
const text = String(message ?? '')
|
|
79
|
+
if (/403/.test(text)) return '宿主拒绝了本次请求:远程访问需经远程访问插件的通道(本页已自动接入);本机访问请用 127.0.0.1 或 localhost 打开。'
|
|
80
|
+
if (/401/.test(text)) return '宿主登录态失效:请重新打开已登录的应用页面后再试。'
|
|
81
|
+
if (/404/.test(text)) return 'Monaco 静态资源未找到:插件包 assets/vendor/monaco 可能不完整,或插件版本与宿主不匹配。'
|
|
82
|
+
if (/请求未完成|Failed to fetch|NetworkError/i.test(text)) return '请求未能到达宿主:请检查网络、代理与宿主进程状态。'
|
|
83
|
+
return '请展开下方详情,并在浏览器控制台确认失败请求的地址与状态码。'
|
|
84
|
+
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { applyOfficial, registerThemes } from './theme.js'
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { transportUrl } from '../../shared/remoteChannel.js'
|
|
10
|
+
import { loadFailureText, probeStatus } from '../loadFailure.js'
|
|
10
11
|
|
|
11
|
-
export const MONACO_BASE =
|
|
12
|
+
export const MONACO_BASE = transportUrl('/edrv/vendor/monaco/vs')
|
|
12
13
|
let monacoPromise = null
|
|
13
14
|
let monacoStage = { phase: 'idle', progress: 0, message: '准备加载 Monaco…' }
|
|
14
15
|
const stageListeners = new Set()
|
|
@@ -154,10 +155,12 @@ export function loadMonaco(onProgress) {
|
|
|
154
155
|
s.src = MONACO_BASE + '/loader.js'
|
|
155
156
|
s.dataset.edrvMonacoLoader = '1'
|
|
156
157
|
s.onload = boot
|
|
157
|
-
s.onerror = (
|
|
158
|
-
// 保留首次失败真实原因(网络/HTTP
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
s.onerror = async () => {
|
|
159
|
+
// 保留首次失败真实原因(网络/HTTP 层),不让后续 TypeError 覆盖;
|
|
160
|
+
// 探测真实状态码,把 403(Host 围栏)/401/404/网络错误区分开,避免一律提示「资源不完整」
|
|
161
|
+
const loaderUrl = MONACO_BASE + '/loader.js'
|
|
162
|
+
const status = await probeStatus(loaderUrl)
|
|
163
|
+
fail(new Error(loadFailureText('Monaco loader 加载失败', loaderUrl, status)))
|
|
161
164
|
}
|
|
162
165
|
hideNodeGlobals()
|
|
163
166
|
document.head.appendChild(s)
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* 作者 ddj 2026-09-22
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { transportUrl } from '../../shared/remoteChannel.js'
|
|
11
|
+
import { loadFailureText, probeStatus } from '../loadFailure.js'
|
|
11
12
|
|
|
12
|
-
/** vendor
|
|
13
|
-
export const PDFJS_BASE =
|
|
13
|
+
/** vendor 根,保留官方桌面协议、反向代理前缀与远程访问通道前缀。 */
|
|
14
|
+
export const PDFJS_BASE = transportUrl('/edrv/vendor/pdfjs')
|
|
14
15
|
/** pdf.js Worker 地址(GlobalWorkerOptions.workerSrc)。 */
|
|
15
16
|
export const PDFJS_WORKER_SRC = PDFJS_BASE + '/build/pdf.worker.mjs'
|
|
16
17
|
/** CJK 字符映射目录(中文 PDF 渲染必需,尾斜杠固定)。 */
|
|
@@ -76,7 +77,12 @@ export function loadPdfjs() {
|
|
|
76
77
|
'document.querySelector("script[data-edrv-pdfjs-loader]")?.dispatchEvent(new Event("edrv-pdfjs-ready"));'
|
|
77
78
|
s.dataset.edrvPdfjsLoader = '1'
|
|
78
79
|
s.onload = boot
|
|
79
|
-
s.onerror = () =>
|
|
80
|
+
s.onerror = async () => {
|
|
81
|
+
// 探测真实状态码:403(Host 围栏)/401/404/网络错误各自给不同排查方向
|
|
82
|
+
const moduleUrl = PDFJS_BASE + '/build/pdf.mjs'
|
|
83
|
+
const status = await probeStatus(moduleUrl)
|
|
84
|
+
fail(new Error(loadFailureText('pdf.js 模块加载失败', moduleUrl, status)))
|
|
85
|
+
}
|
|
80
86
|
document.head.appendChild(s)
|
|
81
87
|
})
|
|
82
88
|
}
|