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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.23.5",
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 { appUrl } from '../../shared/appUrl.js'
9
+ import { transportUrl } from '../../shared/remoteChannel.js'
10
+ import { loadFailureText, probeStatus } from '../loadFailure.js'
10
11
 
11
- export const MONACO_BASE = appUrl('/edrv/vendor/monaco/vs')
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 = (event) => {
158
- // 保留首次失败真实原因(网络/HTTP 层事件),不让后续 TypeError 覆盖
159
- const hint = event && event.type ? '(' + event.type + ')' : ''
160
- fail(new Error('Monaco loader 加载失败' + hint))
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 { appUrl } from '../../shared/appUrl.js'
10
+ import { transportUrl } from '../../shared/remoteChannel.js'
11
+ import { loadFailureText, probeStatus } from '../loadFailure.js'
11
12
 
12
- /** vendor 根,保留官方桌面协议及反向代理前缀。 */
13
- export const PDFJS_BASE = appUrl('/edrv/vendor/pdfjs')
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 = () => fail(new Error('pdf.js loader 脚本加载失败(检查 assets/vendor/pdfjs 完整 / /edrv/vendor 路由可达)'))
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
  }