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/src/client/rpc.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 作者 ddj 2026-08-20 / 2026-09-17
|
|
6
6
|
*/
|
|
7
7
|
import { svnNotice } from './svnNotice.js'
|
|
8
|
-
import {
|
|
8
|
+
import { channelPrefix, transportUrl } from '../shared/remoteChannel.js'
|
|
9
9
|
import { RPC_PATH } from '../shared/rpc.js'
|
|
10
10
|
import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
|
|
11
11
|
import type { LoggerLevel } from '../shared/logger.js'
|
|
@@ -55,21 +55,14 @@ export async function rpc<M extends RpcMethod>(method: M, args: RpcRequestMap[M]
|
|
|
55
55
|
|
|
56
56
|
/** @private @author ddj 2026年10月10号 @param method Typed method. @param args Request. @param signal Fetch lifetime. @returns Validated authenticated business response. @throws Transport/format errors. */
|
|
57
57
|
async function rpcFetch<M extends RpcMethod>(method: M, args: RpcRequestMap[M], signal?: AbortSignal): Promise<RpcResult<M>> {
|
|
58
|
-
const res = await fetch(
|
|
58
|
+
const res = await fetch(transportUrl(RPC_PATH), {
|
|
59
59
|
method: 'POST',
|
|
60
60
|
credentials: 'same-origin',
|
|
61
61
|
headers: { 'content-type': 'application/json' },
|
|
62
62
|
body: JSON.stringify({ method, args }),
|
|
63
63
|
signal,
|
|
64
64
|
})
|
|
65
|
-
if (!res.ok)
|
|
66
|
-
const message = res.status === 401 ? 'DSH 登录已失效,请重新打开已登录的应用页面'
|
|
67
|
-
: res.status === 403 ? 'DSH 拒绝了当前页面的访问'
|
|
68
|
-
: res.status === 413 ? '请求超过支持的文件大小'
|
|
69
|
-
: res.status === 503 ? 'DSH 认证服务尚未就绪'
|
|
70
|
-
: 'DSH 请求失败(HTTP ' + res.status + ')'
|
|
71
|
-
throw new Error(message)
|
|
72
|
-
}
|
|
65
|
+
if (!res.ok) throw new Error(await failureMessage(res))
|
|
73
66
|
if (!/^application\/json(?:\s*;|\s*$)/i.test(res.headers.get('content-type') ?? '')) {
|
|
74
67
|
throw new Error('DSH 返回了非 JSON 响应,请检查应用地址和代理配置')
|
|
75
68
|
}
|
|
@@ -80,6 +73,57 @@ async function rpcFetch<M extends RpcMethod>(method: M, args: RpcRequestMap[M],
|
|
|
80
73
|
return value as RpcResult<M>
|
|
81
74
|
}
|
|
82
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 非 2xx 响应 → 可读失败原因:403 再区分「远程设备未配对」与「Host 围栏拒绝」。
|
|
78
|
+
* @author ddj 2026年11月02号
|
|
79
|
+
* @param res 失败响应
|
|
80
|
+
* @returns 用户可读原因
|
|
81
|
+
*/
|
|
82
|
+
async function failureMessage(res: Response): Promise<string> {
|
|
83
|
+
if (res.status === 403) {
|
|
84
|
+
if (await unpairedDenied(res)) return '远程访问设备尚未配对:请在远程访问面板完成配对后重试(HTTP 403)'
|
|
85
|
+
return 'DSH 拒绝了当前页面的访问(HTTP 403,Host 校验未通过)' + channelNote()
|
|
86
|
+
}
|
|
87
|
+
if (res.status === 401) return 'DSH 登录已失效,请重新打开已登录的应用页面'
|
|
88
|
+
if (res.status === 404) return '插件路由不存在(HTTP 404),请确认 dsh-vscode-mode 与宿主版本匹配'
|
|
89
|
+
if (res.status === 413) return '请求超过支持的文件大小'
|
|
90
|
+
if (res.status === 503) return 'DSH 认证服务尚未就绪'
|
|
91
|
+
return 'DSH 请求失败(HTTP ' + res.status + ')'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 403 是否为远程访问通道的「未配对」拒绝(远程插件以 `{result:{ok:false,error:{code}}}` 回)。
|
|
96
|
+
* @author ddj 2026年11月02号
|
|
97
|
+
* @param res 403 响应
|
|
98
|
+
* @returns 是否为未配对拒绝
|
|
99
|
+
*/
|
|
100
|
+
async function unpairedDenied(res: Response): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
const body = (await res.clone().json()) as { result?: { error?: { code?: unknown } }; error?: { code?: unknown } } | null
|
|
103
|
+
const code = body?.result?.error?.code ?? body?.error?.code
|
|
104
|
+
return code === 'unpaired'
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 网络来源页面却未接入远程通道时的补充提示(本机页面不追加)。
|
|
112
|
+
* @author ddj 2026年11月02号
|
|
113
|
+
* @returns 提示后缀;无需提示时为空串
|
|
114
|
+
*/
|
|
115
|
+
function channelNote(): string {
|
|
116
|
+
if (channelPrefix() !== '') return ''
|
|
117
|
+
try {
|
|
118
|
+
if (typeof location === 'undefined') return ''
|
|
119
|
+
const host = location.hostname
|
|
120
|
+
if (host === 'localhost' || host === '::1' || host === '[::1]' || /^127(\.\d{1,3}){3}$/.test(host)) return ''
|
|
121
|
+
return ';本页未接入远程访问通道(远程访问插件未安装或未完成配对)'
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return ''
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
83
127
|
/**
|
|
84
128
|
* 诊断日志:开关关闭时直接返回(零 RPC 零落盘);开启时走 host edrv.debug 落盘。
|
|
85
129
|
* @author ddj 2026年08月26号 / 2026年09月17号
|
|
@@ -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 自身定位)。 */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* 作者 ddj 2026-08-20
|
|
6
6
|
*/
|
|
7
7
|
import React from 'react'
|
|
8
|
-
import {
|
|
8
|
+
import { transportUrl } from '../../shared/remoteChannel.js'
|
|
9
9
|
import { openEditorView, emitShowLauncher } from '../events.js'
|
|
10
10
|
import { diffSummary } from '../state/diffSummary.js'
|
|
11
11
|
import { subscribeReview } from '../state/reviewFeed.js'
|
|
@@ -44,6 +44,6 @@ export function DiffBadge(props) {
|
|
|
44
44
|
title: '差异审查:' + n + ' 个待处理文件;点击查看总览与历史',
|
|
45
45
|
style: { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, padding: 0, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer' },
|
|
46
46
|
},
|
|
47
|
-
React.createElement('img', { src:
|
|
47
|
+
React.createElement('img', { src: transportUrl('/edrv/assets/compare-select.png'), alt: '差异', style: { width: 22, height: 22, display: 'block' } }),
|
|
48
48
|
React.createElement('span', { style: { position: 'absolute', top: 1, right: 0, minWidth: 15, height: 15, padding: '0 3px', borderRadius: 8, background: 'var(--dsw-alias-state-warn-primary,#d97706)', color: '#fff', fontSize: 10, lineHeight: '15px', textAlign: 'center', fontWeight: 700, boxSizing: 'border-box' } }, String(n)))
|
|
49
49
|
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 「下载到本机」确认框(仅远程访问时由右键菜单打开)。
|
|
3
|
+
*
|
|
4
|
+
* 形态:ModalShell 居中卡片 + 懒加载目录树 + 文件勾选。产品决策是「不打包」:
|
|
5
|
+
* 这里只收集勾选,由调用方(downloadFile.ts)逐个下载原文件。
|
|
6
|
+
*
|
|
7
|
+
* 目录节点只负责展开、不带勾选框:勾选粒度是文件,避免半选目录的语义歧义;
|
|
8
|
+
* 「全选」只覆盖已展开(已加载)的文件,不会为了全选把整棵树拉一遍。
|
|
9
|
+
* 样式见 editor.css 的 `[data-edrv-view] .edrv-dl-*`;本组件不新建 portal,
|
|
10
|
+
* 因此自动满足 tests/overlayScope.test.ts 的 data-edrv-view 契约。
|
|
11
|
+
* 作者 ddj 2026年11月03号
|
|
12
|
+
*/
|
|
13
|
+
import React from 'react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
import { rpc } from '../rpc.js'
|
|
16
|
+
import type { TreeEntry } from '../../shared/rpc.js'
|
|
17
|
+
import { ModalShell } from './ModalShell.js'
|
|
18
|
+
import {
|
|
19
|
+
clearSelected, collapseDir, createDlState, expandDir, formatSize, hasLoaded, isOversized, markError, markLoading,
|
|
20
|
+
mergeChildren, rowList, selectLoaded, selectedFiles, toggleFile,
|
|
21
|
+
} from '../downloadPickerModel.js'
|
|
22
|
+
import type { DlRow, DlState } from '../downloadPickerModel.js'
|
|
23
|
+
|
|
24
|
+
/** 弹窗属性:只做「选文件」这件事,下载编排由调用方负责。 */
|
|
25
|
+
export interface DownloadPickerProps {
|
|
26
|
+
/** 弹窗根:工作区相对路径;'' = 整个工作区。 */
|
|
27
|
+
root: string
|
|
28
|
+
/** 会话 id(工作区解析用)。 */
|
|
29
|
+
sessionId?: string
|
|
30
|
+
/** 取消(Esc / 遮罩 / 取消按钮)。 */
|
|
31
|
+
onClose: () => void
|
|
32
|
+
/** 确认:传勾选的文件路径(至少 1 个)。 */
|
|
33
|
+
onConfirm: (paths: string[]) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 「下载到本机」确认框。
|
|
38
|
+
* @author ddj 2026年11月03号
|
|
39
|
+
* @param props 弹窗属性
|
|
40
|
+
* @returns 弹窗元素
|
|
41
|
+
*/
|
|
42
|
+
export function DownloadPicker(props: DownloadPickerProps): React.ReactElement {
|
|
43
|
+
const { root, sessionId, onClose, onConfirm } = props
|
|
44
|
+
const [state, setState] = React.useState<DlState>(() => createDlState(root))
|
|
45
|
+
const aliveRef = React.useRef(true)
|
|
46
|
+
|
|
47
|
+
React.useEffect(() => () => { aliveRef.current = false }, [])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 拉取某目录一层子项(懒加载的唯一入口)。
|
|
51
|
+
* @author ddj 2026年11月03号
|
|
52
|
+
* @param dirPath 目录路径('' = 工作区根)
|
|
53
|
+
*/
|
|
54
|
+
const loadDir = React.useCallback(async (dirPath: string): Promise<void> => {
|
|
55
|
+
setState((prev) => markLoading(prev, dirPath))
|
|
56
|
+
try {
|
|
57
|
+
const args = sessionId ? { sessionId, path: dirPath } : { path: dirPath }
|
|
58
|
+
const out = await rpc('edrv.listDir', args)
|
|
59
|
+
if (!aliveRef.current) return
|
|
60
|
+
if (!out.ok) {
|
|
61
|
+
setState((prev) => markError(prev, dirPath, out.error))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
setState((prev) => mergeChildren(prev, dirPath, out.entries))
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (!aliveRef.current) return
|
|
67
|
+
setState((prev) => markError(prev, dirPath, textOfError(error)))
|
|
68
|
+
}
|
|
69
|
+
}, [sessionId])
|
|
70
|
+
|
|
71
|
+
React.useEffect(() => { void loadDir(root) }, [loadDir, root])
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 展开 / 收起目录;首次展开才发 listDir。
|
|
75
|
+
* @author ddj 2026年11月03号
|
|
76
|
+
* @param path 目录路径
|
|
77
|
+
*/
|
|
78
|
+
function onToggleDir(path: string): void {
|
|
79
|
+
const expanded = state.expanded.has(path)
|
|
80
|
+
setState((prev) => (expanded ? collapseDir(prev, path) : expandDir(prev, path)))
|
|
81
|
+
if (expanded || hasLoaded(state, path) || state.loading.has(path)) return
|
|
82
|
+
void loadDir(path)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 勾选 / 取消勾选单个文件。
|
|
87
|
+
* @author ddj 2026年11月03号
|
|
88
|
+
* @param path 文件路径
|
|
89
|
+
*/
|
|
90
|
+
function onToggleFile(path: string): void {
|
|
91
|
+
setState((prev) => toggleFile(prev, path))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rows = rowList(state)
|
|
95
|
+
const picked = selectedFiles(state)
|
|
96
|
+
const empty = hasLoaded(state, root) && rows.length === 0 && !state.loading.has(root)
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 单行渲染(目录出三角、文件出勾选框;超限文件置灰不可勾)。
|
|
100
|
+
* @author ddj 2026年11月03号
|
|
101
|
+
* @param row 行数据
|
|
102
|
+
* @returns 行元素
|
|
103
|
+
*/
|
|
104
|
+
function renderRow(row: DlRow): React.ReactElement {
|
|
105
|
+
const indent = { paddingLeft: 6 + row.depth * 14 }
|
|
106
|
+
if (row.kind === 'directory') return renderDirRow(row, indent, onToggleDir, loadDir)
|
|
107
|
+
return renderFileRow(row, indent, onToggleFile)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return React.createElement(ModalShell, {
|
|
111
|
+
dialogClass: 'edrv-dl-dialog',
|
|
112
|
+
width: 'min(640px, calc(100vw - 40px))',
|
|
113
|
+
onClose,
|
|
114
|
+
},
|
|
115
|
+
React.createElement('h3', null, '下载到本机'),
|
|
116
|
+
React.createElement('div', { className: 'edrv-dl-sub' }, root ? '/' + root : '工作区根目录'),
|
|
117
|
+
renderTree({ state, root, rows, empty, loadDir, renderRow, onToggleDir, onToggleFile }),
|
|
118
|
+
renderFoot({
|
|
119
|
+
picked,
|
|
120
|
+
rows,
|
|
121
|
+
onSelectAll: () => setState(selectLoaded),
|
|
122
|
+
onClear: () => setState(clearSelected),
|
|
123
|
+
onClose,
|
|
124
|
+
onConfirm,
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 目录树区域属性(全部由组件传入,渲染函数本身不持有状态)。 */
|
|
130
|
+
interface TreeOpts {
|
|
131
|
+
state: DlState
|
|
132
|
+
root: string
|
|
133
|
+
rows: DlRow[]
|
|
134
|
+
empty: boolean
|
|
135
|
+
loadDir: (dirPath: string) => Promise<void>
|
|
136
|
+
renderRow: (row: DlRow) => React.ReactElement
|
|
137
|
+
onToggleDir: (path: string) => void
|
|
138
|
+
onToggleFile: (path: string) => void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 目录树区域:行列表 + 根加载态 + 根错误重试 + 空态。
|
|
143
|
+
* @author ddj 2026年11月03号
|
|
144
|
+
* @param opts 树区域属性
|
|
145
|
+
* @returns 树区域元素
|
|
146
|
+
*/
|
|
147
|
+
function renderTree(opts: TreeOpts): React.ReactElement {
|
|
148
|
+
const { state, root, rows, empty, loadDir, renderRow } = opts
|
|
149
|
+
return React.createElement('div', { className: 'edrv-dl-tree' },
|
|
150
|
+
rows.length ? rows.map(renderRow) : null,
|
|
151
|
+
state.loading.has(root) ? React.createElement('div', { className: 'edrv-dl-hint' }, '加载中…') : null,
|
|
152
|
+
state.errors.has(root) ? renderRetry(root, state.errors.get(root) || '', loadDir) : null,
|
|
153
|
+
empty ? React.createElement('div', { className: 'edrv-dl-hint' }, '此文件夹内没有可下载的文件') : null,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 底栏属性:已选数量、批量勾选动作与确认/取消回调。 */
|
|
158
|
+
interface FootOpts {
|
|
159
|
+
picked: string[]
|
|
160
|
+
rows: DlRow[]
|
|
161
|
+
onSelectAll: () => void
|
|
162
|
+
onClear: () => void
|
|
163
|
+
onClose: () => void
|
|
164
|
+
onConfirm: (paths: string[]) => void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 底栏:已选数量 + 全选已展开 / 清空 / 取消 / 下载。
|
|
169
|
+
* @author ddj 2026年11月03号
|
|
170
|
+
* @param opts 底栏属性
|
|
171
|
+
* @returns 底栏元素
|
|
172
|
+
*/
|
|
173
|
+
function renderFoot(opts: FootOpts): React.ReactElement {
|
|
174
|
+
const { picked, rows, onSelectAll, onClear, onClose, onConfirm } = opts
|
|
175
|
+
return React.createElement('div', { className: 'edrv-dl-foot' },
|
|
176
|
+
React.createElement('span', { className: 'edrv-dl-count' }, '已选 ' + picked.length + ' 个文件'),
|
|
177
|
+
React.createElement('button', { type: 'button', disabled: !rows.length, onClick: onSelectAll }, '全选已展开'),
|
|
178
|
+
React.createElement('button', { type: 'button', disabled: !picked.length, onClick: onClear }, '清空'),
|
|
179
|
+
React.createElement('button', { type: 'button', onClick: onClose }, '取消'),
|
|
180
|
+
React.createElement('button', {
|
|
181
|
+
type: 'button',
|
|
182
|
+
className: 'edrv-dl-go',
|
|
183
|
+
disabled: !picked.length,
|
|
184
|
+
onClick: () => onConfirm(picked),
|
|
185
|
+
}, '下载 ' + picked.length + ' 个文件'),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 目录行:三角展开 / 收起,名称后带加载或错误提示。
|
|
191
|
+
* @author ddj 2026年11月03号
|
|
192
|
+
* @param row 行数据
|
|
193
|
+
* @param indent 缩进样式
|
|
194
|
+
* @param onToggle 展开回调
|
|
195
|
+
* @param retry 重试加载回调
|
|
196
|
+
* @returns 目录行元素
|
|
197
|
+
*/
|
|
198
|
+
function renderDirRow(
|
|
199
|
+
row: DlRow,
|
|
200
|
+
indent: React.CSSProperties,
|
|
201
|
+
onToggle: (path: string) => void,
|
|
202
|
+
retry: (path: string) => Promise<void>,
|
|
203
|
+
): React.ReactElement {
|
|
204
|
+
// 整行可点(三角与名称都展开),避免只有三角能点导致误以为无子项
|
|
205
|
+
return React.createElement('div', {
|
|
206
|
+
key: row.path,
|
|
207
|
+
className: 'edrv-dl-row is-dir',
|
|
208
|
+
style: indent,
|
|
209
|
+
onClick: () => onToggle(row.path),
|
|
210
|
+
},
|
|
211
|
+
React.createElement('span', { className: 'edrv-dl-twist' }, row.expanded ? '▾' : '▸'),
|
|
212
|
+
React.createElement('span', { className: 'edrv-dl-name edrv-dl-dir' }, row.name),
|
|
213
|
+
row.loading ? React.createElement('span', { className: 'edrv-dl-hint' }, '加载中…') : null,
|
|
214
|
+
row.error ? renderRetry(row.path, row.error, retry) : null,
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 文件行:勾选框 + 名称 + 体积;超限文件禁用勾选并标注原因。
|
|
220
|
+
* @author ddj 2026年11月03号
|
|
221
|
+
* @param row 行数据
|
|
222
|
+
* @param indent 缩进样式
|
|
223
|
+
* @param onToggle 勾选回调
|
|
224
|
+
* @returns 文件行元素
|
|
225
|
+
*/
|
|
226
|
+
function renderFileRow(
|
|
227
|
+
row: DlRow,
|
|
228
|
+
indent: React.CSSProperties,
|
|
229
|
+
onToggle: (path: string) => void,
|
|
230
|
+
): React.ReactElement {
|
|
231
|
+
const oversized = isOversized(row.size)
|
|
232
|
+
return React.createElement('div', { key: row.path, className: 'edrv-dl-row', style: indent },
|
|
233
|
+
React.createElement('input', {
|
|
234
|
+
type: 'checkbox',
|
|
235
|
+
checked: row.checked,
|
|
236
|
+
disabled: oversized,
|
|
237
|
+
onChange: () => onToggle(row.path),
|
|
238
|
+
}),
|
|
239
|
+
React.createElement('span', { className: 'edrv-dl-name', onClick: () => onToggle(row.path) }, row.name),
|
|
240
|
+
oversized
|
|
241
|
+
? React.createElement('span', { className: 'edrv-dl-size is-large' }, '超过 32MB')
|
|
242
|
+
: (typeof row.size === 'number' ? React.createElement('span', { className: 'edrv-dl-size' }, formatSize(row.size)) : null),
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 错误行 + 重试按钮(保留主机错误原文)。
|
|
248
|
+
* @author ddj 2026年11月03号
|
|
249
|
+
* @param path 目录路径
|
|
250
|
+
* @param message 错误原文
|
|
251
|
+
* @param retry 重试回调
|
|
252
|
+
* @returns 提示行元素
|
|
253
|
+
*/
|
|
254
|
+
function renderRetry(path: string, message: string, retry: (path: string) => Promise<void>): React.ReactElement {
|
|
255
|
+
return React.createElement('div', { key: 'err:' + path, className: 'edrv-dl-hint is-error' },
|
|
256
|
+
React.createElement('span', null, message),
|
|
257
|
+
React.createElement('button', {
|
|
258
|
+
type: 'button',
|
|
259
|
+
onClick: (event: React.MouseEvent) => {
|
|
260
|
+
// 错误行嵌在目录行内,重试不应顺带切换该目录的展开态
|
|
261
|
+
event.stopPropagation()
|
|
262
|
+
void retry(path)
|
|
263
|
+
},
|
|
264
|
+
}, '重试'),
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 挂载弹窗:临时宿主 + createRoot,关闭时卸载并移除(与工作区选择弹窗同一范式)。
|
|
270
|
+
* @author ddj 2026年11月03号
|
|
271
|
+
* @param opts 根目录 / 会话 / 确认回调
|
|
272
|
+
* @sideEffects 向 body 追加一个宿主节点,关闭时移除
|
|
273
|
+
*/
|
|
274
|
+
export function openDownloader(opts: { root: string; sessionId?: string; onConfirm: (paths: string[]) => void }): void {
|
|
275
|
+
const host = document.createElement('div')
|
|
276
|
+
document.body.appendChild(host)
|
|
277
|
+
const mount = createRoot(host)
|
|
278
|
+
|
|
279
|
+
function close(): void {
|
|
280
|
+
mount.unmount()
|
|
281
|
+
host.remove()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function confirm(paths: string[]): void {
|
|
285
|
+
close()
|
|
286
|
+
opts.onConfirm(paths)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
mount.render(React.createElement(DownloadPicker, {
|
|
290
|
+
root: opts.root,
|
|
291
|
+
sessionId: opts.sessionId,
|
|
292
|
+
onClose: close,
|
|
293
|
+
onConfirm: confirm,
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 错误可读文案(Error.message 优先,其余 String 化)。
|
|
299
|
+
* @author ddj 2026年11月03号
|
|
300
|
+
* @param error 捕获值
|
|
301
|
+
* @returns 提示文案
|
|
302
|
+
*/
|
|
303
|
+
function textOfError(error: unknown): string {
|
|
304
|
+
return error instanceof Error ? error.message : String(error)
|
|
305
|
+
}
|
|
@@ -46,6 +46,7 @@ import { base64ToBytes, bytesToBase64, isPdfPath } from '../pdfPreview.js'
|
|
|
46
46
|
import { inNativePath, tryNativeOpen } from '../nativeOpenStore.js'
|
|
47
47
|
import { clearPendingNav, putPendingNav, readPendingNav } from '../pendingNav.js'
|
|
48
48
|
import { readBinaryPreview } from '../../shared/rpc.js'
|
|
49
|
+
import { monacoHint } from '../loadFailure.js'
|
|
49
50
|
import {
|
|
50
51
|
clearBaseline,
|
|
51
52
|
clearReadVersion,
|
|
@@ -4747,7 +4748,7 @@ export function EditorView(props) {
|
|
|
4747
4748
|
} else if (monacoErr) {
|
|
4748
4749
|
body = React.createElement('div', { className: 'edrv-empty' },
|
|
4749
4750
|
React.createElement('div', null, 'Monaco 编辑器加载失败:' + String(monacoErr)),
|
|
4750
|
-
React.createElement('div', { style: { fontSize: 11 } },
|
|
4751
|
+
React.createElement('div', { style: { fontSize: 11 } }, monacoHint(String(monacoErr))),
|
|
4751
4752
|
React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: () => { setMonacoErr(null); setLoadStage({ progress: 0, message: '准备重试 Monaco…' }) } }, '重试'))
|
|
4752
4753
|
} else if (!active) {
|
|
4753
4754
|
body = React.createElement('div', { className: 'edrv-empty' },
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 远程访问通道适配:插件内部路径 → 远程传输可达路径。
|
|
3
|
+
*
|
|
4
|
+
* 背景:宿主 connection 插件对 Host 不是 loopback / bind 地址 / trustedHosts 的请求一律回 403
|
|
5
|
+
* (Host 围栏先于任何 cookie 校验),因此 /edrv/rpc 与 /edrv/vendor/** 在局域网 IP 或隧道域名
|
|
6
|
+
* 页面上必然被拒。第三方远程访问插件(@linxin666/dsh-remote-web-ui)提供 /remote/<内部路径>
|
|
7
|
+
* 的 catch-all 代理:校验配对设备后回环重发到本进程,本插件自身的准入照旧执行。
|
|
8
|
+
*
|
|
9
|
+
* 该插件在 <head> 首部内联一段 classic script(解析期同步执行,早于任何 module script),并在
|
|
10
|
+
* window 上安装 seat 对象作为「本页已接入通道」的唯一标记。本模块据此选前缀:
|
|
11
|
+
* 有标记 ⇒ '/remote' + 原路径;无标记 ⇒ 原路径(本机、桌面 dsh-app:、未装该插件均不受影响)。
|
|
12
|
+
* 只决定 URL 前缀,不读取、不写入、不转发任何 cookie 或凭据。
|
|
13
|
+
*
|
|
14
|
+
* Purity rule: no node/react imports(与 appUrl.ts 一致)。
|
|
15
|
+
* @author ddj 2026年11月02号
|
|
16
|
+
*/
|
|
17
|
+
import { appUrl } from './appUrl.js'
|
|
18
|
+
|
|
19
|
+
/** 远程访问插件在解析期安装的通道 seat 全局名(其 REMOTE_CHANNEL_BOOT_GLOBAL)。 */
|
|
20
|
+
export const CHANNEL_SEAT = '__DSH_REMOTE_CHANNEL_BOOT__'
|
|
21
|
+
|
|
22
|
+
/** 远程访问插件接管的前缀:/remote/<内部路径> 由服务端回环转发。 */
|
|
23
|
+
export const CHANNEL_PREFIX = '/remote'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 当前页面的通道前缀。
|
|
27
|
+
* @author ddj 2026年11月02号
|
|
28
|
+
* @returns 本页已接入远程通道时为 '/remote',否则空串(保持既有本地路径)。
|
|
29
|
+
*/
|
|
30
|
+
export function channelPrefix(): string {
|
|
31
|
+
if (typeof window === 'undefined') return ''
|
|
32
|
+
try {
|
|
33
|
+
const seat = (window as unknown as Record<string, unknown>)[CHANNEL_SEAT]
|
|
34
|
+
return seat && typeof seat === 'object' ? CHANNEL_PREFIX : ''
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
}
|
|
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
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 给插件内部路径加通道前缀。
|
|
53
|
+
* @author ddj 2026年11月02号
|
|
54
|
+
* @param path 以 '/' 开头的插件内部路径(合法性由 {@link transportUrl} 内的 appUrl 兜底校验)
|
|
55
|
+
* @returns 带通道前缀的路径;本机页面原样返回
|
|
56
|
+
*/
|
|
57
|
+
export function transportPath(path: string): string {
|
|
58
|
+
return channelPrefix() + path
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 把插件内部路径解析为当前页面可请求的地址(继承通道前缀、反向代理前缀与桌面协议)。
|
|
63
|
+
* @author ddj 2026年11月02号
|
|
64
|
+
* @param path 以 '/' 开头的插件内部路径
|
|
65
|
+
* @param base 测试用显式 document base;缺省同 appUrl(document.baseURI → location.href)
|
|
66
|
+
* @returns 应用地址;无浏览器环境时返回原根路径
|
|
67
|
+
*/
|
|
68
|
+
export function transportUrl(path: string, base?: string): string {
|
|
69
|
+
return appUrl(transportPath(path), base)
|
|
70
|
+
}
|
package/src/shared/rpc.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiI
|
|
|
24
24
|
import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdatePoll, SvnUpdateResult, SvnUpdateState } from './svn.js'
|
|
25
25
|
import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, DapConfigSnippet, DapConfigSource, DapDebugConfig, DapFrameView, DapPhase, DapPollResult, DapProcessInfo, DapScopeView, DapVariableView } from './dap.js'
|
|
26
26
|
import { DAP_ACTIONS } from './dap.js'
|
|
27
|
-
import {
|
|
27
|
+
import { transportUrl } from './remoteChannel.js'
|
|
28
28
|
import type { LoggerLevel } from './logger.js'
|
|
29
29
|
import type { SvnViewRequest, SvnViewPayload } from './svnExplorer.js'
|
|
30
30
|
import type { DecideReview, ReviewDetail, ReviewPage, ReviewRequest } from './review.js'
|
|
@@ -153,7 +153,7 @@ export async function readBinaryPreview(
|
|
|
153
153
|
*/
|
|
154
154
|
async function tryBinaryRead(args: RpcRequestMap['edrv.readBinary']): Promise<PreviewRead | null> {
|
|
155
155
|
try {
|
|
156
|
-
const res = await fetch(
|
|
156
|
+
const res = await fetch(transportUrl(RPC_PATH), {
|
|
157
157
|
method: 'POST',
|
|
158
158
|
headers: { 'content-type': 'application/json' },
|
|
159
159
|
body: JSON.stringify({ method: 'edrv.readBinary', args }),
|