dsh-vscode-mode 0.1.15 → 0.1.17
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 +30 -3
- package/lib/client.js +169 -35
- package/lib/client.js.map +1 -1
- package/lib/index.js +550 -197
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compat.ts +91 -0
- package/src/client/fileOpeners.ts +2 -1
- package/src/client/index.ts +18 -10
- package/src/client/openPathRouter.ts +5 -5
- package/src/client/settingsContext.ts +7 -17
- package/src/client/styles/mcp.css +17 -0
- package/src/client/ui/McpSettings.ts +68 -2
- package/src/compat.ts +147 -0
- package/src/devForm.ts +133 -0
- package/src/fileOpenSettings.ts +58 -6
- package/src/index.ts +33 -5
- package/src/mcp.ts +8 -9
- package/src/mcpIsolation.ts +6 -12
- package/src/routes.ts +26 -7
- package/src/rpc.ts +13 -0
- package/src/shared/compat.ts +27 -0
- package/src/shared/rpc.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 兼容层:设置桥选择 / slot 安全注册 / openPath 链式补丁 / 外部插件常量。
|
|
3
|
+
* 纯逻辑与薄 ctx 胶水,node 环境可单测(不依赖 DOM)。
|
|
4
|
+
* 作者 ddj 2026年08月24号
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** 本插件包名(与 host compat.PLUGIN_NAME 对应)。 */
|
|
8
|
+
export const PLUGIN_NAME = 'dsh-vscode-mode'
|
|
9
|
+
/** 外部侧栏插件名(dsh-web-ui-all 家族的文件打开能力提供方)。 */
|
|
10
|
+
export const SIDEBAR_PLUGIN = 'dsh-better-sidebar'
|
|
11
|
+
|
|
12
|
+
/** 设置 scope 的跨组件上下文形状(与官方 settingsScope 绑定的最小面)。 */
|
|
13
|
+
export interface SettingsScopeLike {
|
|
14
|
+
getSnapshot: () => { status?: string; value?: { fileOpenTool?: unknown }; writable?: boolean }
|
|
15
|
+
subscribe: (listener: () => void) => () => void
|
|
16
|
+
set: (field: string, value: unknown) => Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 设置桥三级优先:webUiSettings(linxin666 兼容桥)→ settingsScope(官方)→ 无。
|
|
21
|
+
* 桥绑定抛错时降级到下一级,不让单个桥异常拖垮设置装配。
|
|
22
|
+
* @author ddj 2026年08月24号
|
|
23
|
+
* @param ctx 客户端服务上下文
|
|
24
|
+
* @returns 激活的桥名称与绑定 scope
|
|
25
|
+
*/
|
|
26
|
+
export function pickSettingsBinder(ctx: { get: (name: string) => unknown }): { service: string; scope: SettingsScopeLike | undefined } {
|
|
27
|
+
for (const service of ['webUiSettings', 'settingsScope']) {
|
|
28
|
+
try {
|
|
29
|
+
const binder = ctx.get(service) as { bind?: (spec: { namespace: string }) => SettingsScopeLike } | undefined
|
|
30
|
+
const scope = binder?.bind?.({ namespace: PLUGIN_NAME })
|
|
31
|
+
if (scope) return { service, scope }
|
|
32
|
+
} catch {
|
|
33
|
+
/* 桥异常时降级到下一级 */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { service: 'none', scope: undefined }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** slot 注册描述(对齐 ctx.slots.register 的形状)。 */
|
|
40
|
+
export interface SlotSpec {
|
|
41
|
+
name: string
|
|
42
|
+
id: string
|
|
43
|
+
order?: number
|
|
44
|
+
label?: string
|
|
45
|
+
inject?: (sessionId: string) => object
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 跨版本 slot 安全注册:封装 ctx.slots.inject 等待声明模式(新老 DSH 通用),
|
|
50
|
+
* 服务缺失或注册抛错时降级为警告,不让单条 slot 拖垮装配。
|
|
51
|
+
* 注意:方法必须挂在服务对象上调用(slots.register 是依赖 this.records 的类方法,
|
|
52
|
+
* 解构后 this 丢失会静默失败),故禁止解构。
|
|
53
|
+
* @author ddj 2026年08月24号
|
|
54
|
+
* @param ctx 客户端上下文(slots 服务)
|
|
55
|
+
* @param spec slot 描述
|
|
56
|
+
* @param render 渲染函数
|
|
57
|
+
*/
|
|
58
|
+
export function registerSlotSafely(ctx: { slots?: { inject?: (name: string, register: () => unknown) => unknown; register?: (...args: unknown[]) => unknown } }, spec: SlotSpec, render: (props: unknown) => unknown): void {
|
|
59
|
+
const slots = ctx?.slots as { inject: (name: string, register: () => unknown) => unknown; register: (...args: unknown[]) => unknown } | undefined
|
|
60
|
+
if (!slots || typeof slots.inject !== 'function' || typeof slots.register !== 'function') {
|
|
61
|
+
console.warn('[' + PLUGIN_NAME + '] slots 服务不可用,跳过 slot ' + spec.name)
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
slots.inject(spec.name, () => slots.register(spec, render))
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.warn('[' + PLUGIN_NAME + '] slot ' + spec.name + ' 注册失败(' + String(error) + '),已跳过')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 链式补丁对象方法:保存原实现、装入包装实现,dispose 恢复。
|
|
73
|
+
* 恢复带归属校验(仅当当前实现仍是本包装时还原),多插件补丁按栈序互不踩踏。
|
|
74
|
+
* @author ddj 2026年08月24号
|
|
75
|
+
* @param owner 目标对象
|
|
76
|
+
* @param key 方法名
|
|
77
|
+
* @param wrapper 包装实现(original 为补丁前的实现)
|
|
78
|
+
* @returns 恢复函数(幂等)
|
|
79
|
+
*/
|
|
80
|
+
export function patchMethod<T extends object, K extends keyof T>(
|
|
81
|
+
owner: T,
|
|
82
|
+
key: K,
|
|
83
|
+
wrapper: (original: T[K], ...args: never[]) => unknown,
|
|
84
|
+
): () => void {
|
|
85
|
+
const original = owner[key]
|
|
86
|
+
const patched = ((...args: never[]) => wrapper(original, ...args)) as T[K]
|
|
87
|
+
owner[key] = patched
|
|
88
|
+
return () => {
|
|
89
|
+
if (owner[key] === patched) owner[key] = original
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* dsh-vscode-mode client — 文件打开器注册表与自动选择。
|
|
3
3
|
* @author ddj 2026年08月24号
|
|
4
4
|
*/
|
|
5
|
+
import { SIDEBAR_PLUGIN } from './compat.js'
|
|
5
6
|
|
|
6
7
|
export const AUTO_OPEN_TOOL = 'auto'
|
|
7
8
|
export const SYSTEM_OPEN_TOOL = 'system'
|
|
8
9
|
export const VSCODE_OPEN_TOOL = 'dsh-vscode-mode'
|
|
9
|
-
export const SIDEBAR_OPEN_TOOL =
|
|
10
|
+
export const SIDEBAR_OPEN_TOOL = SIDEBAR_PLUGIN
|
|
10
11
|
|
|
11
12
|
export interface FileOpenContext {
|
|
12
13
|
sessionId?: string
|
package/src/client/index.ts
CHANGED
|
@@ -20,7 +20,9 @@ import { rpc } from './rpc.js'
|
|
|
20
20
|
import { createFileOpenerRegistry, scanSidebar, type FileOpenContext } from './fileOpeners.js'
|
|
21
21
|
import type { FileOpenerRegistry } from './fileOpeners.js'
|
|
22
22
|
import { installOpenPathRouter, vscodeOpener, autoValue } from './openPathRouter.js'
|
|
23
|
-
import { SettingsContext
|
|
23
|
+
import { SettingsContext } from './settingsContext.js'
|
|
24
|
+
import { SIDEBAR_PLUGIN, pickSettingsBinder, registerSlotSafely } from './compat.js'
|
|
25
|
+
import type { CompatAdapter } from '../shared/compat.js'
|
|
24
26
|
|
|
25
27
|
export const inject = ['slots', 'timer', 'locale', 'connection', 'remote', 'workspaces', 'sessions', 'settingsScope', 'webUiSettings']
|
|
26
28
|
|
|
@@ -36,9 +38,17 @@ export function apply(ctx: any): void {
|
|
|
36
38
|
const workspaces = ctx.get('workspaces')
|
|
37
39
|
const sessions = ctx.get('sessions')
|
|
38
40
|
const originalOpenPath = workspaces?.openPath
|
|
39
|
-
const
|
|
41
|
+
const binder = pickSettingsBinder(ctx)
|
|
42
|
+
const settings = binder.scope
|
|
40
43
|
let selected = autoValue('auto')
|
|
41
44
|
|
|
45
|
+
/** 客户端侧兼容摘要(与 host 报告合并展示;惰性求值保证 HMR 后仍新鲜)。 */
|
|
46
|
+
const compatSummary = (): CompatAdapter[] => [
|
|
47
|
+
{ name: '设置桥', active: settings !== undefined, note: settings !== undefined ? '使用 ' + binder.service + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用)' },
|
|
48
|
+
{ name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode 打开器优先,失败回退系统打开' },
|
|
49
|
+
{ name: '侧边栏打开器(' + SIDEBAR_PLUGIN + ')', active: registry.get(SIDEBAR_PLUGIN) !== undefined, note: registry.get(SIDEBAR_PLUGIN) !== undefined ? '已注册(优先级 80)' : '未检测到侧边栏打开能力' },
|
|
50
|
+
]
|
|
51
|
+
|
|
42
52
|
ctx.provide('fileOpeners', registry)
|
|
43
53
|
ctx.effect(() => registry.register(vscodeOpener()), 'vscode-mode: file opener')
|
|
44
54
|
ctx.effect(() => registry.register({
|
|
@@ -76,25 +86,23 @@ export function apply(ctx: any): void {
|
|
|
76
86
|
}
|
|
77
87
|
|
|
78
88
|
// 中央「文件编辑」页签:类 VSCode 编辑器(顶部=文件页签+搜索框,差异 UI=文件底部圆角悬浮框)
|
|
79
|
-
ctx
|
|
89
|
+
registerSlotSafely(ctx, {
|
|
80
90
|
name: 'conversation.view',
|
|
81
91
|
id: 'edrv-editor',
|
|
82
92
|
order: 5,
|
|
83
93
|
label: '文件编辑',
|
|
84
94
|
inject: (sessionId: string) => ({ sessionId }),
|
|
85
|
-
}, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule })))
|
|
95
|
+
}, (props: unknown) => React.createElement(EditorView, Object.assign({}, props, { schedule })))
|
|
86
96
|
|
|
87
97
|
// header 差异角标:仅当前工作区存在差异时渲染
|
|
88
|
-
ctx
|
|
89
|
-
|
|
90
|
-
(props: unknown) => React.createElement(DiffBadge, Object.assign({}, props)),
|
|
91
|
-
))
|
|
98
|
+
registerSlotSafely(ctx, { name: 'conversation.session.header.utilities', id: 'edrv-diff-badge', order: 90, label: '差异' },
|
|
99
|
+
(props: unknown) => React.createElement(DiffBadge, Object.assign({}, props)))
|
|
92
100
|
|
|
93
101
|
// 设置页中的 VSCodeMode 专属页签,MCP 管理作为该页签的内部子 Tab。
|
|
94
|
-
ctx
|
|
102
|
+
registerSlotSafely(ctx, {
|
|
95
103
|
name: 'settings.section',
|
|
96
104
|
id: 'vscode-mode',
|
|
97
105
|
order: 30,
|
|
98
106
|
label: 'VSCodeMode',
|
|
99
|
-
}, () => React.createElement(SettingsContext.Provider, { value: settings }, React.createElement(McpSettings, { openerRegistry: registry })))
|
|
107
|
+
}, () => React.createElement(SettingsContext.Provider, { value: settings }, React.createElement(McpSettings, { openerRegistry: registry, compatSummary })))
|
|
100
108
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — DSH 对话文件链接的统一打开路由。
|
|
3
|
-
*
|
|
3
|
+
* openPath 补丁经 compat.patchMethod 安装(链式恢复,多插件补丁互不踩踏)。
|
|
4
|
+
* 作者 ddj 2026年08月24号
|
|
4
5
|
*/
|
|
6
|
+
import { patchMethod } from './compat.js'
|
|
5
7
|
import { openEditorView } from './events.js'
|
|
6
8
|
import { AUTO_OPEN_TOOL, VSCODE_OPEN_TOOL, type FileOpenContext, type FileOpenerRegistry, isAvailable, selectOpener } from './fileOpeners.js'
|
|
7
9
|
|
|
@@ -20,8 +22,7 @@ export interface OpenPathRouterOptions {
|
|
|
20
22
|
* @returns 恢复原方法的 disposer
|
|
21
23
|
*/
|
|
22
24
|
export function installOpenPathRouter(options: OpenPathRouterOptions): () => void {
|
|
23
|
-
const
|
|
24
|
-
const open = async (path: string): Promise<void> => {
|
|
25
|
+
const open = async (original: (path: string) => Promise<void>, path: string): Promise<void> => {
|
|
25
26
|
const context = options.context()
|
|
26
27
|
const opener = selectOpener(options.registry, options.selected())
|
|
27
28
|
if (!opener || !isAvailable(opener)) return original.call(options.workspaces, path)
|
|
@@ -33,8 +34,7 @@ export function installOpenPathRouter(options: OpenPathRouterOptions): () => voi
|
|
|
33
34
|
else throw error
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
|
-
options.workspaces
|
|
37
|
-
return () => { options.workspaces.openPath = original }
|
|
37
|
+
return patchMethod(options.workspaces, 'openPath', open as never)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -1,31 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-vscode-mode client — 设置 scope 的跨组件上下文。
|
|
3
|
-
*
|
|
3
|
+
* 绑定逻辑(webUiSettings 兼容桥 → 官方 settingsScope 降级)统一由 compat 层提供。
|
|
4
|
+
* 作者 ddj 2026年08月24号
|
|
4
5
|
*/
|
|
5
6
|
import React from 'react'
|
|
7
|
+
import { pickSettingsBinder, type SettingsScopeLike } from './compat.js'
|
|
6
8
|
|
|
7
|
-
export
|
|
8
|
-
getSnapshot: () => { status?: string; value?: { fileOpenTool?: unknown }; writable?: boolean }
|
|
9
|
-
subscribe: (listener: () => void) => () => void
|
|
10
|
-
set: (field: string, value: unknown) => Promise<void>
|
|
11
|
-
}
|
|
9
|
+
export type { SettingsScopeLike }
|
|
12
10
|
|
|
13
11
|
export const SettingsContext = React.createContext<SettingsScopeLike | undefined>(undefined)
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
|
-
* 从 Cordis 服务绑定 VSCodeMode 设置 scope
|
|
17
|
-
* @author ddj 2026年08月24号
|
|
18
|
-
* @param ctx 客户端上下文
|
|
19
|
-
* @returns 设置 scope 或 undefined
|
|
20
|
-
*/
|
|
21
|
-
/**
|
|
22
|
-
* 绑定官方设置 scope,优先使用 web-ui 兼容桥。
|
|
14
|
+
* 从 Cordis 服务绑定 VSCodeMode 设置 scope(兼容桥优先,官方次之)。
|
|
23
15
|
* @author ddj 2026年08月24号
|
|
24
16
|
* @param ctx 客户端服务上下文
|
|
25
|
-
* @returns 设置 scope
|
|
17
|
+
* @returns 设置 scope 或 undefined
|
|
26
18
|
*/
|
|
27
19
|
export function getSettingsScope(ctx: { get: (name: string) => unknown }): SettingsScopeLike | undefined {
|
|
28
|
-
|
|
29
|
-
const binder = service as { bind?: (spec: { namespace: string }) => SettingsScopeLike } | undefined
|
|
30
|
-
return binder?.bind?.({ namespace: 'dsh-vscode-mode' })
|
|
20
|
+
return pickSettingsBinder(ctx).scope
|
|
31
21
|
}
|
|
@@ -93,3 +93,20 @@
|
|
|
93
93
|
.vsm-mcp-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
|
|
94
94
|
.vsm-mcp-dialog-actions button{padding:8px 14px;border:1px solid var(--dsw-alias-border-l2,#555);border-radius:6px;background:none;color:var(--dsw-alias-label-primary,#eee);cursor:pointer;font-size:13px}
|
|
95
95
|
.vsm-mcp-dialog-actions button:hover{background:var(--dsw-alias-interactive-bg-hover,#ffffff14)}
|
|
96
|
+
|
|
97
|
+
/* 兼容性小节 */
|
|
98
|
+
.vsm-compat-h3{margin:16px 0 2px;font-size:14px;font-weight:600;color:var(--dsw-alias-label-secondary,#c8c8c8)}
|
|
99
|
+
.vsm-compat-item{display:flex;align-items:baseline;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:1px solid var(--dsw-alias-border-l1,#333);font-size:13px;color:var(--dsw-alias-label-primary,#eee)}
|
|
100
|
+
.vsm-compat-item.ok .vsm-compat-name::before{content:"● ";color:var(--dsw-alias-state-success-primary,#38bd75)}
|
|
101
|
+
.vsm-compat-item.warn .vsm-compat-name::before{content:"● ";color:var(--dsw-alias-state-warn-primary,#e5b93f)}
|
|
102
|
+
.vsm-compat-name{font-weight:500;min-width:0}
|
|
103
|
+
.vsm-compat-note{color:var(--dsw-alias-label-tertiary,#9aa5b1);font-size:12px;text-align:right;min-width:0}
|
|
104
|
+
|
|
105
|
+
/* 开发形态开关 */
|
|
106
|
+
.vsm-devform-note{display:block;margin-top:4px;font-size:11px;color:var(--dsw-alias-label-tertiary,#9aa5b1);word-break:break-all}
|
|
107
|
+
.vsm-devform-actions{display:flex;flex-direction:column;align-items:flex-end;gap:6px;min-width:0}
|
|
108
|
+
.vsm-devform-msg{color:var(--dsw-alias-state-warn-primary,#e5b93f);font-size:11px;text-align:right}
|
|
109
|
+
.vsm-general-row button{border:1px solid var(--dsw-alias-border-l2,#555);border-radius:6px;padding:6px 12px;background:none;color:var(--dsw-alias-label-primary,#eee);cursor:pointer;font-size:12px;white-space:nowrap}
|
|
110
|
+
.vsm-general-row button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#ffffff14)}
|
|
111
|
+
.vsm-general-row button:disabled{opacity:.55;cursor:default}
|
|
112
|
+
.vsm-general-row button.vsm-devform-close{color:var(--dsw-alias-state-error-primary,#f07878);border-color:var(--dsw-alias-state-error-secondary,#d9534f55)}
|
|
@@ -119,11 +119,14 @@ function ProjectGroup({ project, busy, onAdd, onRefresh, onToggle, onRemove }) {
|
|
|
119
119
|
return React.createElement('section', { className: 'vsm-project' }, head, body)
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
122
|
+
/** 通用设置:选择当前对话文件链接的打开器 + 开发形态开关。 */
|
|
123
123
|
function GeneralSettings({ registry }) {
|
|
124
124
|
const [tool, setTool] = React.useState(AUTO_OPEN_TOOL)
|
|
125
125
|
const [busy, setBusy] = React.useState(false)
|
|
126
126
|
const [error, setError] = React.useState('')
|
|
127
|
+
const [devForm, setDevForm] = React.useState(null)
|
|
128
|
+
const [devBusy, setDevBusy] = React.useState(false)
|
|
129
|
+
const [devMessage, setDevMessage] = React.useState('')
|
|
127
130
|
const settings = React.useContext(SettingsContext)
|
|
128
131
|
const snapshot = settings?.getSnapshot?.()
|
|
129
132
|
const loading = !snapshot || snapshot.status === 'loading'
|
|
@@ -137,25 +140,87 @@ function GeneralSettings({ registry }) {
|
|
|
137
140
|
onChange()
|
|
138
141
|
return settings?.subscribe?.(onChange)
|
|
139
142
|
}, [settings])
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
rpc('vscode.devFormGet', {}).then((result) => {
|
|
145
|
+
if (result.ok) setDevForm(result.devForm)
|
|
146
|
+
}).catch(() => { /* 开发形态查询失败静默 */ })
|
|
147
|
+
}, [])
|
|
140
148
|
const save = (value) => {
|
|
141
149
|
setTool(value); setBusy(true); setError('')
|
|
142
150
|
if (!settings?.set) { setError('设置服务不可用'); setBusy(false); return }
|
|
143
151
|
settings.set('fileOpenTool', value).catch((e) => setError(String(e))).finally(() => setBusy(false))
|
|
144
152
|
}
|
|
153
|
+
const closeDevForm = () => {
|
|
154
|
+
if (!window.confirm('关闭开发形态:插件将切换为正式版安装(版本依赖 + 删除工作区链接),pnpm 装配后需重启 DSH 生效。确认关闭?')) return
|
|
155
|
+
setDevBusy(true)
|
|
156
|
+
setDevMessage('')
|
|
157
|
+
rpc('vscode.devFormSet', { enabled: false }).then((result) => {
|
|
158
|
+
if (!result.ok) { setDevMessage('关闭失败:' + String(result.error)); return }
|
|
159
|
+
setDevForm(result.devForm)
|
|
160
|
+
setDevMessage(result.restart ? '已切换为正式版,重启 DSH 后生效。' : '已关闭开发形态。')
|
|
161
|
+
}).catch((e) => setDevMessage('关闭失败:' + String(e))).finally(() => setDevBusy(false))
|
|
162
|
+
}
|
|
145
163
|
const options = [{ id: AUTO_OPEN_TOOL, label: '自动选择', description: '按当前已注册工具自动选择' }, ...availableOpeners(registry).map((item) => ({ id: item.id, label: item.label, description: item.description }))]
|
|
146
164
|
const currentOption = options.some((item) => item.id === tool) ? tool : AUTO_OPEN_TOOL
|
|
165
|
+
const devFormRow = devForm?.enabled ? React.createElement('div', { className: 'vsm-general-row' },
|
|
166
|
+
React.createElement('span', null, '开发形态', React.createElement('small', { className: 'vsm-devform-note' }, '工作区链接安装:' + devForm.path)),
|
|
167
|
+
React.createElement('div', { className: 'vsm-devform-actions' },
|
|
168
|
+
devMessage && React.createElement('small', { className: 'vsm-devform-msg' }, devMessage),
|
|
169
|
+
React.createElement('button', { className: 'vsm-devform-close', disabled: devBusy, onClick: closeDevForm }, devBusy ? '切换中…' : '关闭开发形态'),
|
|
170
|
+
),
|
|
171
|
+
) : null
|
|
147
172
|
return React.createElement('section', { className: 'vsm-general-page' },
|
|
148
173
|
React.createElement('h2', null, '通用'),
|
|
149
174
|
React.createElement('p', null, '配置 DSH 对话中文件链接的打开工具。工具由当前运行时自动扫描。'),
|
|
150
175
|
unavailable && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, '设置服务暂不可用,当前使用自动选择。'),
|
|
151
176
|
error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
|
|
152
177
|
React.createElement('label', { className: 'vsm-general-row' }, React.createElement('span', null, '文件链接使用工具'), React.createElement('select', { value: currentOption, disabled: loading || unavailable || notReady || busy || snapshot?.writable === false, onChange: (event) => save(event.target.value) }, options.map((item) => React.createElement('option', { key: item.id, value: item.id }, item.label))), loading && React.createElement('small', null, '读取中…')),
|
|
178
|
+
devFormRow,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 兼容性小节:host 报告(RPC compat)+ 客户端本地适配项。 */
|
|
183
|
+
function CompatSection({ getSummary }) {
|
|
184
|
+
const [report, setReport] = React.useState(null)
|
|
185
|
+
const [error, setError] = React.useState('')
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
let alive = true
|
|
188
|
+
rpc('compat', {}).then((result) => {
|
|
189
|
+
if (!alive) return
|
|
190
|
+
if (!result.ok) setError(String(result.error))
|
|
191
|
+
else setReport(result.report)
|
|
192
|
+
}).catch((e) => { if (alive) setError(String(e)) })
|
|
193
|
+
return () => { alive = false }
|
|
194
|
+
}, [])
|
|
195
|
+
const renderItems = (list) => (list ?? []).map((item) => React.createElement('div', { key: item.name, className: 'vsm-compat-item ' + (item.active ? 'ok' : 'warn') },
|
|
196
|
+
React.createElement('span', { className: 'vsm-compat-name' }, item.name),
|
|
197
|
+
React.createElement('span', { className: 'vsm-compat-note' }, item.note ?? (item.active ? '正常' : '未生效')),
|
|
198
|
+
))
|
|
199
|
+
const warnings = report?.warnings ?? []
|
|
200
|
+
const devForm = report?.devForm
|
|
201
|
+
return React.createElement('section', { className: 'vsm-general-page' },
|
|
202
|
+
React.createElement('h2', null, '兼容性'),
|
|
203
|
+
React.createElement('p', null, '检测与其他插件 / DSH 版本的适配状态与已知问题。' + (report?.pluginVersion ? '(插件版本 ' + report.pluginVersion + ')' : '')),
|
|
204
|
+
error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
|
|
205
|
+
devForm?.enabled && React.createElement(React.Fragment, null,
|
|
206
|
+
React.createElement('h3', { className: 'vsm-compat-h3' }, '开发形态'),
|
|
207
|
+
React.createElement('div', { className: 'vsm-compat-item ok' }, React.createElement('span', { className: 'vsm-compat-name' }, '开发形态开启(工作区链接安装)'), React.createElement('span', { className: 'vsm-compat-note' }, devForm.path)),
|
|
208
|
+
),
|
|
209
|
+
React.createElement('h3', { className: 'vsm-compat-h3' }, '外部插件与服务'),
|
|
210
|
+
renderItems(report?.external),
|
|
211
|
+
React.createElement('h3', { className: 'vsm-compat-h3' }, '客户端适配'),
|
|
212
|
+
renderItems(getSummary?.() ?? []),
|
|
213
|
+
React.createElement('h3', { className: 'vsm-compat-h3' }, '护栏'),
|
|
214
|
+
renderItems(report?.guards),
|
|
215
|
+
React.createElement('h3', { className: 'vsm-compat-h3' }, '警告'),
|
|
216
|
+
warnings.length ? warnings.map((w, index) => React.createElement('div', { key: index, className: 'vsm-mcp-error vsm-mcp-banner' }, w)) : React.createElement('div', { className: 'vsm-compat-item ok' }, React.createElement('span', { className: 'vsm-compat-name' }, '未检测到兼容性问题'), React.createElement('span', { className: 'vsm-compat-note' }, '')),
|
|
153
217
|
)
|
|
154
218
|
}
|
|
155
219
|
|
|
156
220
|
/** MCP 管理主面板。 */
|
|
157
221
|
export function McpSettings(props) {
|
|
158
222
|
const openerRegistry = props?.openerRegistry ?? { list: () => [] }
|
|
223
|
+
const compatSummary = props?.compatSummary ?? (() => [])
|
|
159
224
|
const [servers, setServers] = React.useState<MpcServer[]>([])
|
|
160
225
|
const [projects, setProjects] = React.useState<MpcProject[]>([])
|
|
161
226
|
const [loading, setLoading] = React.useState(true)
|
|
@@ -252,11 +317,12 @@ export function McpSettings(props) {
|
|
|
252
317
|
else if (tab === 'general') body = React.createElement(GeneralSettings, { registry: openerRegistry })
|
|
253
318
|
else if (tab === 'mine') body = React.createElement('div', null, servers.length ? servers.map((s) => React.createElement(ServerCard, { key: s.id, server: s, onRefresh: refreshGlobal, onToggle: toggleGlobal, onRemove: removeGlobal })) : React.createElement('div', { className: 'vsm-mcp-empty' }, '还没有配置全局 MCP'))
|
|
254
319
|
else if (tab === 'projects') body = projectBody
|
|
320
|
+
else if (tab === 'compat') body = React.createElement(CompatSection, { getSummary: compatSummary })
|
|
255
321
|
else body = marketBody
|
|
256
322
|
const form = showForm ? React.createElement(McpForm, { title: '添加全局 MCP', draft, busy, edit, save: saveGlobal, close: () => setShowForm(false) }) : projectForm ? React.createElement(McpForm, { title: '添加项目 MCP · ' + projectForm.title, draft, busy, edit, save: saveProject, close: () => setProjectForm(null) }) : null
|
|
257
323
|
return React.createElement('section', { className: 'vsm-mcp-page' },
|
|
258
324
|
React.createElement('header', { className: 'vsm-mcp-header' }, React.createElement('div', null, React.createElement('h2', null, 'VSCodeMode'), React.createElement('p', null, '管理当前 profile 与各项目的 Model Context Protocol 服务。')), React.createElement('button', { className: 'vsm-primary', onClick: () => { setDraft(EMPTY); setShowForm(true) } }, '+ 添加全局 MCP')),
|
|
259
|
-
React.createElement('nav', { className: 'vsm-mcp-tabs' }, React.createElement('button', { className: tab === 'general' ? 'active' : '', onClick: () => setTab('general') }, '通用'), React.createElement('button', { className: tab === 'mine' ? 'active' : '', onClick: () => setTab('mine') }, '我的 MCP'), React.createElement('button', { className: tab === 'projects' ? 'active' : '', onClick: () => setTab('projects') }, '项目 MCP'), React.createElement('button', { className: tab === 'market' ? 'active' : '', onClick: () => setTab('market') }, 'MCP 市场')),
|
|
325
|
+
React.createElement('nav', { className: 'vsm-mcp-tabs' }, React.createElement('button', { className: tab === 'general' ? 'active' : '', onClick: () => setTab('general') }, '通用'), React.createElement('button', { className: tab === 'mine' ? 'active' : '', onClick: () => setTab('mine') }, '我的 MCP'), React.createElement('button', { className: tab === 'projects' ? 'active' : '', onClick: () => setTab('projects') }, '项目 MCP'), React.createElement('button', { className: tab === 'market' ? 'active' : '', onClick: () => setTab('market') }, 'MCP 市场'), React.createElement('button', { className: tab === 'compat' ? 'active' : '', onClick: () => setTab('compat') }, '兼容性')),
|
|
260
326
|
error && React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' }, error),
|
|
261
327
|
body,
|
|
262
328
|
form,
|
package/src/compat.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode host — 兼容层:与其他插件 / DSH 版本的统一探测、适配、护栏与自诊断。
|
|
3
|
+
* 职责:身份常量(包名 / MCP 条目前缀 / 路由前缀)、外部插件探测、路由冲突与重复装配检测、
|
|
4
|
+
* 兼容性报告构建(RPC edrv.compat 与启动日志共用)。
|
|
5
|
+
* 注意:与 mcp.ts 存在模块环(compat 提供身份常量、mcp 提供 entriesOf),
|
|
6
|
+
* 双方仅在函数体内交叉使用导出,模块求值期无依赖,ESM 安全。
|
|
7
|
+
* 作者 ddj 2026-08-24
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import { entriesOf } from './mcp.js'
|
|
13
|
+
import { loadSettingsDeps } from './fileOpenSettings.js'
|
|
14
|
+
import { readDevForm } from './devForm.js'
|
|
15
|
+
import type { CompatAdapter, CompatReport } from './shared/compat.js'
|
|
16
|
+
import type { Ctx } from './store.js'
|
|
17
|
+
|
|
18
|
+
export const PLUGIN_NAME = 'dsh-vscode-mode'
|
|
19
|
+
export const MCP_PACKAGE = '@deepseek-ai/dsh-mcp-client'
|
|
20
|
+
/** 项目级 MCP 的 loader entry id 固定前缀(区分 profile 全局 MCP;loader id 不能用 ':',故用 '.')。 */
|
|
21
|
+
export const PROJECT_ENTRY_PREFIX = 'vsm-mcp.'
|
|
22
|
+
/** 旧版项目 MCP 条目前缀(升级残留识别,不显示为全局 MCP)。 */
|
|
23
|
+
export const LEGACY_PROJECT_PREFIX = 'vsm-mcp:'
|
|
24
|
+
/** webServer 路由前缀(RPC 精确路由 + 静态资源前缀共用)。 */
|
|
25
|
+
export const ROUTE_PREFIX = '/edrv'
|
|
26
|
+
|
|
27
|
+
/** 判断新旧格式项目 MCP 条目 id(旧残留不被视为全局 MCP)。 */
|
|
28
|
+
export function isProjectEntryId(id: string): boolean {
|
|
29
|
+
return id.startsWith(PROJECT_ENTRY_PREFIX) || id.startsWith(LEGACY_PROJECT_PREFIX)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 从项目条目 id 解析 workspace hash(新旧格式通用)。 */
|
|
33
|
+
export function entryHash(id: string): string | undefined {
|
|
34
|
+
if (id.startsWith(PROJECT_ENTRY_PREFIX)) return id.slice(PROJECT_ENTRY_PREFIX.length).split('.')[0]
|
|
35
|
+
if (id.startsWith(LEGACY_PROJECT_PREFIX)) return id.slice(LEGACY_PROJECT_PREFIX.length).split(':')[0]
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 读取随包 package.json 版本号(缺失/解析失败降级为空串)。 */
|
|
40
|
+
export function pluginVersionOf(): string {
|
|
41
|
+
try {
|
|
42
|
+
const file = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
43
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
|
|
44
|
+
return typeof pkg.version === 'string' ? pkg.version : ''
|
|
45
|
+
} catch {
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 探测外部插件/服务适配状态(depsAvailable 由调用方注入,便于测试)。 */
|
|
51
|
+
export function detectExternal(ctx: Ctx, depsAvailable: boolean): CompatAdapter[] {
|
|
52
|
+
const mcpCount = entriesOf(ctx).length
|
|
53
|
+
const settings = ctx.get('settings') as { describe?: unknown; update?: unknown } | undefined
|
|
54
|
+
const hasSettings = Boolean(settings?.describe || settings?.update)
|
|
55
|
+
return [
|
|
56
|
+
{ name: MCP_PACKAGE, active: mcpCount > 0, note: mcpCount > 0 ? mcpCount + ' 个 MCP 服务条目' : '未检测到 MCP 条目(MCP 管理页显示为空)' },
|
|
57
|
+
{ name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装' : '未安装:fileOpenTool 持久化降级为配置值' },
|
|
58
|
+
{ name: 'settings 服务', active: hasSettings, note: hasSettings ? '可读写设置' : '不可用(设置读写走配置回退)' },
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 本插件已注册的路由(kind → path 集合),冲突检测时排除自身,避免自误报。 */
|
|
63
|
+
const ownRoutes = { exact: new Set<string>(), prefixes: new Set<string>() }
|
|
64
|
+
|
|
65
|
+
/** 清空本插件路由记录(registerRoutes 装配开始时调用,热重载不残留)。 */
|
|
66
|
+
export function resetOwnRoutes(): void {
|
|
67
|
+
ownRoutes.exact.clear()
|
|
68
|
+
ownRoutes.prefixes.clear()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 记录本插件路由(routes.ts 注册成功时调用)。 */
|
|
72
|
+
export function noteOwnRoute(kind: 'exact' | 'prefix', path: string): void {
|
|
73
|
+
const table = kind === 'exact' ? ownRoutes.exact : ownRoutes.prefixes
|
|
74
|
+
table.add(path)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 检测路由前缀占用与重叠(排除本插件自有路由;内部表不可读视为无冲突)。 */
|
|
78
|
+
export function routeConflict(web: unknown, prefix: string): string | null {
|
|
79
|
+
try {
|
|
80
|
+
const w = web as { exact?: Map<unknown, unknown>; prefixes?: Map<unknown, unknown> } | undefined
|
|
81
|
+
const exact = w?.exact
|
|
82
|
+
const prefixes = w?.prefixes
|
|
83
|
+
if (exact instanceof Map) {
|
|
84
|
+
for (const path of exact.keys()) {
|
|
85
|
+
const other = String(path)
|
|
86
|
+
if (other.startsWith(prefix + '/') && !ownRoutes.exact.has(other)) {
|
|
87
|
+
return '精确路由 ' + other + ' 与 ' + prefix + ' 前缀重叠(可能来自热重载残留或其他插件)'
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (prefixes instanceof Map) {
|
|
92
|
+
for (const path of prefixes.keys()) {
|
|
93
|
+
const other = String(path)
|
|
94
|
+
if (other !== prefix && (other.startsWith(prefix + '/') || prefix.startsWith(other + '/')) && !ownRoutes.prefixes.has(other)) {
|
|
95
|
+
return '路由前缀 ' + prefix + ' 与 ' + other + ' 重叠'
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
/* 内部表不可读时放弃检查 */
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 检测本插件是否被重复装配(bundle 层 + 用户层重复 insert 的自诊断)。 */
|
|
106
|
+
export function duplicateEntries(ctx: Ctx): string[] {
|
|
107
|
+
const loader = ctx.get('loader') as { entries?: () => Iterable<{ id?: unknown; options?: { name?: unknown } }> } | undefined
|
|
108
|
+
if (!loader || typeof loader.entries !== 'function') return []
|
|
109
|
+
const own = [...loader.entries()].filter((entry) => String(entry.options?.name ?? entry.id ?? '') === PLUGIN_NAME)
|
|
110
|
+
if (own.length > 1) {
|
|
111
|
+
return ['检测到 ' + own.length + ' 个 ' + PLUGIN_NAME + ' loader 条目(重复装配)。请删除 profile 用户层 cordis.patch.yml 的重复 insert(或运行 dev_fix_patch 清理)']
|
|
112
|
+
}
|
|
113
|
+
return []
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 护栏适配状态:路由前缀唯一性 + 插件装配唯一性。 */
|
|
117
|
+
export function detectGuards(ctx: Ctx): CompatAdapter[] {
|
|
118
|
+
const web = ctx.get('webServer')
|
|
119
|
+
const conflict = web ? routeConflict(web, ROUTE_PREFIX) : null
|
|
120
|
+
const duplicates = duplicateEntries(ctx)
|
|
121
|
+
return [
|
|
122
|
+
{ name: '路由前缀 ' + ROUTE_PREFIX, active: !conflict, note: conflict ?? '无冲突' },
|
|
123
|
+
{ name: '插件装配唯一性', active: duplicates.length === 0, note: duplicates[0] ?? '唯一装配' },
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 构建完整兼容性报告(RPC 与启动日志共用)。
|
|
129
|
+
* @author ddj 2026年08月24号
|
|
130
|
+
* @param ctx DSH host 上下文
|
|
131
|
+
* @param options 测试注入:depsAvailable 跳过动态导入、version 固定版本号
|
|
132
|
+
* @returns 兼容性报告
|
|
133
|
+
*/
|
|
134
|
+
export async function buildReport(
|
|
135
|
+
ctx: Ctx,
|
|
136
|
+
options?: { depsAvailable?: boolean; version?: string },
|
|
137
|
+
): Promise<CompatReport> {
|
|
138
|
+
const deps = options?.depsAvailable ?? (await loadSettingsDeps()) !== null
|
|
139
|
+
const external = detectExternal(ctx, deps)
|
|
140
|
+
const guards = detectGuards(ctx)
|
|
141
|
+
const warnings: string[] = []
|
|
142
|
+
if (!deps) warnings.push('未安装 @deepseek-ai/dsh-settings:文件打开工具设置持久化不可用(降级为配置值)')
|
|
143
|
+
for (const guard of guards) {
|
|
144
|
+
if (!guard.active && guard.note) warnings.push(guard.note)
|
|
145
|
+
}
|
|
146
|
+
return { pluginVersion: options?.version ?? pluginVersionOf(), external, guards, warnings, devForm: readDevForm() }
|
|
147
|
+
}
|