dsh-vscode-mode 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -112
- package/assets/shell/dsh-open.cs +65 -149
- package/assets/shell/dsh-open.mjs +241 -0
- package/assets/shell/dsh-open.ps1 +28 -47
- package/assets/shell/dsh-open.sh +25 -66
- package/lib/client.js +30185 -29711
- package/lib/client.js.map +1 -1
- package/lib/index.js +3889 -2336
- package/lib/index.js.map +1 -1
- package/package.json +9 -8
- package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
- package/src/childEnv.ts +19 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +6 -2
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +17 -15
- package/src/client/keybindings.ts +18 -1
- package/src/client/monaco/loader.ts +3 -1
- package/src/client/openFlow.ts +36 -28
- package/src/client/openReceipt.ts +64 -0
- package/src/client/panelCommands.ts +101 -0
- package/src/client/pdf/pdfLoader.ts +4 -2
- package/src/client/receiptState.ts +120 -0
- package/src/client/rpc.ts +30 -4
- package/src/client/shortcutsOfficial.ts +39 -4
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/styles/mcp.css +1 -19
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +237 -55
- package/src/client/ui/McpSettings.ts +32 -7
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +10 -9
- package/src/client/ui/commandCatalog.ts +5 -4
- package/src/client/useOpenReceipt.ts +128 -0
- package/src/compat.ts +5 -3
- package/src/dap/manager.ts +46 -13
- package/src/devForm.ts +252 -92
- package/src/externalHandoff.ts +93 -52
- package/src/fileOpenSettings.ts +1 -24
- package/src/hostImport.ts +19 -0
- package/src/httpGuard.ts +101 -0
- package/src/index.ts +106 -97
- package/src/integrate.ts +56 -50
- package/src/lsp/transport.ts +84 -127
- package/src/mcp.ts +58 -9
- package/src/mcpIsolation.ts +211 -102
- package/src/mcpProject.ts +220 -133
- package/src/mcpRuntime.ts +343 -0
- package/src/openBridge.ts +69 -0
- package/src/openInbox.ts +207 -0
- package/src/paths.ts +8 -5
- package/src/processTree.ts +63 -0
- package/src/reveal.ts +5 -3
- package/src/routes.ts +156 -154
- package/src/rpc.ts +14 -13
- package/src/shared/appUrl.ts +19 -0
- package/src/shared/externalOpen.ts +71 -0
- package/src/shared/integration.ts +2 -0
- package/src/shared/keybindings.ts +8 -5
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/svn.ts +6 -8
- package/src/svnPlanTool.ts +51 -0
- package/src/unityBridge.ts +26 -3
- package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
- package/unity/com.dsh.editor/README.md +16 -25
- package/unity/com.dsh.editor/package.json +2 -2
- package/src/client/ui/ShortcutSettings.ts +0 -294
package/src/devForm.ts
CHANGED
|
@@ -1,133 +1,293 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
* 关闭 = 改回版本依赖 + 删 junction + pnpm install 装配正式版。
|
|
5
|
-
* 切换后需重启 DSH 生效(运行中的代码仍是旧形态)。
|
|
6
|
-
* 与 compat.ts 存在模块环(devForm 用 pluginVersionOf、compat 用 readDevForm),
|
|
7
|
-
* 双方仅在函数体内交叉使用,模块求值期无依赖,ESM 安全。
|
|
8
|
-
* 作者 ddj 2026-08-24
|
|
9
|
-
*/
|
|
10
|
-
import { existsSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import { dirname, join } from 'node:path'
|
|
12
|
-
import { homedir } from 'node:os'
|
|
1
|
+
/** 当前运行 profile 的开发形态切换;失败恢复 manifest、lockfile 与原安装入口。 */
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from 'node:path'
|
|
13
4
|
import { PLUGIN_NAME, pluginVersionOf } from './compat.js'
|
|
5
|
+
import { dshHome } from './paths.js'
|
|
6
|
+
import { childEnv } from './childEnv.js'
|
|
14
7
|
import type { DevFormInfo } from './shared/compat.js'
|
|
15
8
|
import type { Ctx } from './store.js'
|
|
16
9
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
/** 官方 dsh profile-boot 提供的当前运行事实;不从 inventory 猜测。 */
|
|
11
|
+
interface ProfileContext {
|
|
12
|
+
dir: string
|
|
13
|
+
packageManager?: { command: string; args?: string[]; env?: NodeJS.ProcessEnv }
|
|
14
|
+
}
|
|
15
|
+
type SwitchResult = { ok: boolean; error?: string; restart: boolean }
|
|
16
|
+
interface ProfileBackup {
|
|
17
|
+
dir: string
|
|
18
|
+
link: string
|
|
19
|
+
savedLink: string
|
|
20
|
+
hadLink: boolean
|
|
21
|
+
files: { path: string; text: Buffer | undefined }[]
|
|
22
|
+
}
|
|
23
|
+
const switching = new Set<string>()
|
|
24
|
+
|
|
25
|
+
// --region 运行上下文
|
|
26
|
+
/**
|
|
27
|
+
* 获取官方 profileContext 服务;服务存在但无有效目录时拒绝猜测。
|
|
28
|
+
* @private
|
|
29
|
+
* @author ddj 2026年09月28号
|
|
30
|
+
* @param ctx 可选运行上下文
|
|
31
|
+
* @returns 官方服务对象或缺失
|
|
32
|
+
* @throws 服务对象目录无效
|
|
33
|
+
*/
|
|
34
|
+
function profileOf(ctx?: Ctx): ProfileContext | undefined {
|
|
35
|
+
const profile = ctx?.get?.('profileContext')
|
|
36
|
+
if (profile === undefined || profile === null) return undefined
|
|
37
|
+
if (typeof profile.dir !== 'string' || !isAbsolute(profile.dir)) throw new Error('当前 profileContext.dir 无效')
|
|
38
|
+
return profile as ProfileContext
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 检查 manifest 是否明确依赖本插件;坏 manifest 不作候选。
|
|
43
|
+
* @private
|
|
44
|
+
* @author ddj 2026年09月28号
|
|
45
|
+
* @param dir profile 目录
|
|
46
|
+
* @returns 是否包含插件依赖
|
|
47
|
+
*/
|
|
48
|
+
function ownsPlugin(dir: string): boolean {
|
|
20
49
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')) as { dependencies?: Record<string, unknown> } | undefined
|
|
26
|
-
if (pkg?.dependencies?.[PLUGIN_NAME]) return dirname(pkgFile)
|
|
27
|
-
} catch {
|
|
28
|
-
/* 坏 manifest 跳过该 profile */
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
} catch {
|
|
32
|
-
/* profiles 目录不存在时放弃 */
|
|
33
|
-
}
|
|
34
|
-
return undefined
|
|
50
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
51
|
+
return typeof pkg?.dependencies?.[PLUGIN_NAME] === 'string'
|
|
52
|
+
} catch { return false }
|
|
35
53
|
}
|
|
36
54
|
|
|
37
55
|
/**
|
|
38
|
-
*
|
|
39
|
-
* @
|
|
40
|
-
* @
|
|
56
|
+
* 优先使用真实运行 profile;旧宿主仅允许唯一候选回退,多候选拒绝。
|
|
57
|
+
* @public
|
|
58
|
+
* @author ddj 2026年09月28号
|
|
59
|
+
* @param ctx DSH host 上下文(可选,旧宿主兼容)
|
|
60
|
+
* @returns 当前 profile 目录;无有效或唯一候选时 undefined
|
|
41
61
|
*/
|
|
42
|
-
export function
|
|
43
|
-
const profileDir = findProfileDir()
|
|
44
|
-
if (!profileDir) return { enabled: false }
|
|
62
|
+
export function findProfileDir(ctx?: Ctx): string | undefined {
|
|
45
63
|
try {
|
|
46
|
-
const
|
|
47
|
-
|
|
64
|
+
const profile = profileOf(ctx)
|
|
65
|
+
if (profile) return ownsPlugin(profile.dir) ? profile.dir : undefined
|
|
66
|
+
const homePath = ctx?.get?.('dshHomePath')
|
|
67
|
+
const root = join(typeof homePath === 'function' ? homePath() : dshHome(), 'profiles')
|
|
68
|
+
const candidates = readdirSync(root).map((name) => join(root, name)).filter(ownsPlugin)
|
|
69
|
+
return candidates.length === 1 ? candidates[0] : undefined
|
|
70
|
+
} catch { return undefined }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 读取选中 profile 的 link 依赖状态。
|
|
75
|
+
* @private
|
|
76
|
+
* @author ddj 2026年09月28号
|
|
77
|
+
* @param dir 已选定目录
|
|
78
|
+
* @returns 开发形态;无法读取时未开启
|
|
79
|
+
*/
|
|
80
|
+
function readProfileForm(dir: string): DevFormInfo {
|
|
81
|
+
try {
|
|
82
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
48
83
|
const spec = pkg?.dependencies?.[PLUGIN_NAME]
|
|
49
|
-
if (typeof spec === 'string' && spec.startsWith('link:')) {
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
} catch {
|
|
53
|
-
/* manifest 不可读视为非开发形态 */
|
|
54
|
-
}
|
|
84
|
+
if (typeof spec === 'string' && spec.startsWith('link:')) return { enabled: true, path: spec.slice(5) }
|
|
85
|
+
} catch { /* 只读探测失败 */ }
|
|
55
86
|
return { enabled: false }
|
|
56
87
|
}
|
|
57
88
|
|
|
58
89
|
/**
|
|
59
|
-
*
|
|
60
|
-
* @
|
|
90
|
+
* 读取当前运行 profile 的开发形态。
|
|
91
|
+
* @public
|
|
92
|
+
* @author ddj 2026年09月28号
|
|
93
|
+
* @param ctx DSH host 上下文
|
|
94
|
+
* @returns 开发形态状态
|
|
95
|
+
*/
|
|
96
|
+
export function readDevForm(ctx?: Ctx): DevFormInfo {
|
|
97
|
+
const dir = findProfileDir(ctx)
|
|
98
|
+
return dir ? readProfileForm(dir) : { enabled: false }
|
|
99
|
+
}
|
|
100
|
+
// --endregion
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 规划切换后的 manifest,保留其它字段。
|
|
104
|
+
* @public
|
|
105
|
+
* @author ddj 2026年09月28号
|
|
61
106
|
* @param text 原 manifest 文本
|
|
62
|
-
* @param enabled
|
|
63
|
-
* @param path
|
|
64
|
-
* @param version
|
|
65
|
-
* @returns 新 manifest
|
|
107
|
+
* @param enabled true 为开发链接
|
|
108
|
+
* @param path 开发目录
|
|
109
|
+
* @param version 正式版本号
|
|
110
|
+
* @returns 新 manifest 文本
|
|
111
|
+
* @throws JSON 或 dependencies 无效
|
|
66
112
|
*/
|
|
67
113
|
export function planManifest(text: string, enabled: boolean, path: string, version: string): string {
|
|
68
114
|
const data = JSON.parse(text.replace(/^\uFEFF/, '')) as { dependencies?: Record<string, unknown> }
|
|
69
|
-
if (!data.dependencies || typeof data.dependencies !== 'object') {
|
|
115
|
+
if (!data.dependencies || typeof data.dependencies !== 'object' || Array.isArray(data.dependencies)) {
|
|
70
116
|
throw new Error('profile package.json 缺少 dependencies 字段')
|
|
71
117
|
}
|
|
72
118
|
data.dependencies[PLUGIN_NAME] = enabled ? 'link:' + path.replace(/\\/g, '/') : '^' + version
|
|
73
119
|
return JSON.stringify(data, null, 2) + '\n'
|
|
74
120
|
}
|
|
75
121
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
122
|
+
// --region 切换事务
|
|
123
|
+
/**
|
|
124
|
+
* 检查开发目录属于本插件且不位于会被替换的 node_modules 内。
|
|
125
|
+
* @private
|
|
126
|
+
* @author ddj 2026年09月28号
|
|
127
|
+
* @param dir profile 目录
|
|
128
|
+
* @param target 用户目标路径
|
|
129
|
+
* @returns 真实绝对目录
|
|
130
|
+
* @throws 目录、包身份或位置不安全
|
|
131
|
+
*/
|
|
132
|
+
function checkTarget(dir: string, target: string): string {
|
|
133
|
+
if (!target || !isAbsolute(target)) throw new Error('开启开发形态需要提供工作区绝对路径')
|
|
134
|
+
const full = realpathSync(target)
|
|
135
|
+
if (!statSync(full).isDirectory()) throw new Error('工作区路径不是目录:' + target)
|
|
136
|
+
const pkg = JSON.parse(readFileSync(join(full, 'package.json'), 'utf8').replace(/^\uFEFF/, ''))
|
|
137
|
+
if (pkg?.name !== PLUGIN_NAME) throw new Error('工作区 package.json.name 必须是 ' + PLUGIN_NAME)
|
|
138
|
+
const modules = join(dir, 'node_modules')
|
|
139
|
+
const moduleRoot = existsSync(modules) ? realpathSync(modules) : resolve(realpathSync(dir), 'node_modules')
|
|
140
|
+
const inside = relative(moduleRoot, full)
|
|
141
|
+
if (!inside || (!inside.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) && !isAbsolute(inside))) {
|
|
142
|
+
throw new Error('开发源码不能位于当前 profile 的 node_modules 内')
|
|
143
|
+
}
|
|
144
|
+
return full
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 删除安装入口;链接仅 unlink,绝不递归其源码目标。
|
|
149
|
+
* @private
|
|
150
|
+
* @author ddj 2026年09月28号
|
|
151
|
+
* @param path 安装入口或备份入口
|
|
152
|
+
* @throws 删除失败
|
|
153
|
+
*/
|
|
154
|
+
function removeEntry(path: string): void {
|
|
155
|
+
const info = lstatSync(path, { throwIfNoEntry: false })
|
|
156
|
+
if (!info) return
|
|
157
|
+
if (info.isSymbolicLink()) unlinkSync(path)
|
|
158
|
+
else rmSync(path, { recursive: true, force: true })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 在首次写入前保存 manifest/lockfile 原字节,并移动原安装入口保留链接身份。
|
|
163
|
+
* @private
|
|
164
|
+
* @author ddj 2026年09月28号
|
|
165
|
+
* @param dir profile 目录
|
|
166
|
+
* @returns 可回滚的备份
|
|
167
|
+
* @throws 备份失败;尚未修改 manifest
|
|
168
|
+
*/
|
|
169
|
+
function backupProfile(dir: string): ProfileBackup {
|
|
170
|
+
const files = ['package.json', 'pnpm-lock.yaml'].map((name) => {
|
|
171
|
+
const path = join(dir, name)
|
|
172
|
+
return { path, text: existsSync(path) ? readFileSync(path) : undefined }
|
|
173
|
+
})
|
|
174
|
+
const backupDir = mkdtempSync(join(dir, '.vscode-mode-backup-'))
|
|
175
|
+
const link = join(dir, 'node_modules', PLUGIN_NAME)
|
|
176
|
+
const savedLink = join(backupDir, 'module')
|
|
177
|
+
const hadLink = !!lstatSync(link, { throwIfNoEntry: false })
|
|
178
|
+
try {
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
if (file.text !== undefined) writeFileSync(join(backupDir, relative(dir, file.path)), file.text)
|
|
181
|
+
}
|
|
182
|
+
if (hadLink) renameSync(link, savedLink)
|
|
183
|
+
return { dir: backupDir, link, savedLink, hadLink, files }
|
|
184
|
+
} catch (error) {
|
|
185
|
+
rmSync(backupDir, { recursive: true, force: true })
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
83
188
|
}
|
|
84
189
|
|
|
85
190
|
/**
|
|
86
|
-
*
|
|
87
|
-
* @
|
|
88
|
-
* @
|
|
191
|
+
* 恢复原安装入口和配置文件;恢复失败时保留备份供恢复。
|
|
192
|
+
* @private
|
|
193
|
+
* @author ddj 2026年09月28号
|
|
194
|
+
* @param saved 原状态备份
|
|
195
|
+
* @throws 回滚失败,错误包含备份位置
|
|
196
|
+
*/
|
|
197
|
+
function restoreProfile(saved: ProfileBackup): void {
|
|
198
|
+
const failures: string[] = []
|
|
199
|
+
try {
|
|
200
|
+
removeEntry(saved.link)
|
|
201
|
+
if (saved.hadLink) renameSync(saved.savedLink, saved.link)
|
|
202
|
+
} catch (error) { failures.push(String(error)) }
|
|
203
|
+
for (const file of saved.files) {
|
|
204
|
+
try {
|
|
205
|
+
if (file.text === undefined) rmSync(file.path, { force: true })
|
|
206
|
+
else writeFileSync(file.path, file.text)
|
|
207
|
+
} catch (error) { failures.push(String(error)) }
|
|
208
|
+
}
|
|
209
|
+
if (failures.length) throw new Error('回滚失败,备份保留于 ' + saved.dir + ':' + failures.join('; '))
|
|
210
|
+
rmSync(saved.dir, { recursive: true, force: true })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 执行已预检的切换;安装失败时恢复原入口及 manifest/lockfile。
|
|
215
|
+
* @private
|
|
216
|
+
* @author ddj 2026年09月28号
|
|
217
|
+
* @param ctx host 上下文
|
|
218
|
+
* @param dir 已确定的 profile
|
|
89
219
|
* @param enabled 目标形态
|
|
90
|
-
* @param
|
|
91
|
-
* @returns
|
|
220
|
+
* @param target 已预检目标
|
|
221
|
+
* @returns 事务结果;成功需重启
|
|
92
222
|
*/
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
223
|
+
async function switchProfile(ctx: Ctx, dir: string, enabled: boolean, target: string): Promise<SwitchResult> {
|
|
224
|
+
const version = pluginVersionOf()
|
|
225
|
+
if (!version) throw new Error('读取插件版本失败,无法写入版本依赖')
|
|
226
|
+
const next = planManifest(readFileSync(join(dir, 'package.json'), 'utf8'), enabled, target, version)
|
|
227
|
+
const saved = backupProfile(dir)
|
|
228
|
+
try {
|
|
229
|
+
writeFileSync(join(dir, 'package.json'), next, 'utf8')
|
|
230
|
+
mkdirSync(join(dir, 'node_modules'), { recursive: true })
|
|
231
|
+
if (enabled) symlinkSync(target, saved.link, 'junction')
|
|
232
|
+
await runPnpmInstall(ctx, dir)
|
|
233
|
+
} catch (error) {
|
|
234
|
+
restoreProfile(saved)
|
|
235
|
+
throw error
|
|
101
236
|
}
|
|
102
237
|
try {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!version) return { ok: false, error: '读取插件版本失败,无法写入版本依赖', restart: false }
|
|
106
|
-
const next = planManifest(readFileSync(pkgFile, 'utf8').replace(/^\uFEFF/, ''), enabled, target, version)
|
|
107
|
-
writeFileSync(pkgFile, next, 'utf8')
|
|
108
|
-
applyJunction(profileDir, enabled, target)
|
|
109
|
-
const installError = await runPnpmInstall(ctx, profileDir)
|
|
110
|
-
return { ok: !installError, error: installError, restart: true }
|
|
238
|
+
removeEntry(saved.savedLink)
|
|
239
|
+
rmSync(saved.dir, { recursive: true, force: true })
|
|
111
240
|
} catch (error) {
|
|
112
|
-
return { ok:
|
|
241
|
+
return { ok: true, error: '切换成功,旧安装备份清理失败:' + saved.dir + ':' + String(error), restart: true }
|
|
113
242
|
}
|
|
243
|
+
return { ok: true, restart: true }
|
|
114
244
|
}
|
|
115
245
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
246
|
+
/**
|
|
247
|
+
* 预检运行 profile、目标与安装服务,再串行执行可回滚切换。
|
|
248
|
+
* @public
|
|
249
|
+
* @author ddj 2026年09月28号
|
|
250
|
+
* @param ctx DSH host 上下文
|
|
251
|
+
* @param enabled 目标形态
|
|
252
|
+
* @param path 开发工作区绝对路径
|
|
253
|
+
* @returns 结果与是否需重启;失败不提交切换
|
|
254
|
+
*/
|
|
255
|
+
export async function setDevForm(ctx: Ctx, enabled: boolean, path?: string): Promise<SwitchResult> {
|
|
256
|
+
const dir = findProfileDir(ctx)
|
|
257
|
+
if (!dir) return { ok: false, error: '无法唯一定位当前 profile;请检查 profileContext 或 DSH_HOME/profiles', restart: false }
|
|
258
|
+
const key = process.platform === 'win32' ? resolve(dir).toLowerCase() : resolve(dir)
|
|
259
|
+
if (switching.has(key)) return { ok: false, error: '该 profile 正在切换开发形态', restart: false }
|
|
260
|
+
switching.add(key)
|
|
261
|
+
try {
|
|
262
|
+
const current = readProfileForm(dir)
|
|
263
|
+
const raw = path ?? current.path ?? ''
|
|
264
|
+
const target = enabled ? checkTarget(dir, raw) : ''
|
|
265
|
+
if (enabled === current.enabled && (!enabled || resolve(dir, current.path ?? '') === target)) return { ok: true, restart: false }
|
|
266
|
+
if (typeof ctx?.get?.('subprocess')?.spawn !== 'function') throw new Error('缺少 subprocess 服务,未修改 profile')
|
|
267
|
+
return await switchProfile(ctx, dir, enabled, target)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
return { ok: false, error: String(error), restart: false }
|
|
270
|
+
} finally { switching.delete(key) }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 使用官方 profileContext.packageManager 运行 Desktop 内置 pnpm;旧宿主使用 PATH。
|
|
275
|
+
* @private
|
|
276
|
+
* @author ddj 2026年09月28号
|
|
277
|
+
* @param ctx host 上下文
|
|
278
|
+
* @param profileDir profile 工作目录
|
|
279
|
+
* @throws 启动失败或非零退出;只需退出码,stdio 使用 inherit
|
|
280
|
+
*/
|
|
281
|
+
async function runPnpmInstall(ctx: Ctx, profileDir: string): Promise<void> {
|
|
282
|
+
const runner = profileOf(ctx)?.packageManager
|
|
283
|
+
const handle = ctx.get('subprocess').spawn({
|
|
284
|
+
argv: [runner?.command ?? 'pnpm', ...runner?.args ?? [], 'install'],
|
|
122
285
|
cwd: profileDir,
|
|
123
|
-
|
|
286
|
+
env: childEnv(runner?.env),
|
|
287
|
+
stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
|
|
124
288
|
graceMs: 120000,
|
|
125
289
|
})
|
|
126
290
|
const outcome = await handle.done
|
|
127
|
-
|
|
128
|
-
if (code !== 0) {
|
|
129
|
-
const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
|
|
130
|
-
return 'pnpm install 失败(exit ' + code + '):' + stderr.slice(0, 500)
|
|
131
|
-
}
|
|
132
|
-
return undefined
|
|
291
|
+
if (outcome?.exitCode !== 0) throw new Error('pnpm install 失败(exit ' + String(outcome?.exitCode) + ')')
|
|
133
292
|
}
|
|
293
|
+
// --endregion
|
package/src/externalHandoff.ts
CHANGED
|
@@ -1,70 +1,111 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-vscode-mode host — 深链移交(避免重复打开 DSH 页面)。
|
|
3
|
-
* launcher 投递待打开请求(edrv.external.handoff)→ 已打开页面 3s 轮询领取(edrv.external.pending)
|
|
4
|
-
* 并就地执行打开规则;15s 内无页面轮询过 = 无活跃页面 → launcher 回退打开新页。
|
|
5
|
-
* token 防多实例串扰;TTL 兜底防陈旧悬挂。模块级状态(host 单进程单例)。
|
|
6
|
-
* 作者 ddj 2026-09-08
|
|
7
|
-
*/
|
|
8
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { OPEN_TTL_MS } from './shared/externalOpen.js'
|
|
9
3
|
|
|
10
|
-
|
|
11
|
-
export interface ExternalOpenRequest {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
4
|
+
export interface ExternalOpenRequest { paths: string[]; line?: number; column?: number }
|
|
5
|
+
export interface OpenClaim extends ExternalOpenRequest { token: string; leaseId: string }
|
|
6
|
+
export interface OpenReceipt { token: string; clientId: string; leaseId: string; success: boolean; error?: string }
|
|
7
|
+
interface Pending extends ExternalOpenRequest {
|
|
8
|
+
token: string; at: number; clientId?: string; leaseId?: string; leaseAt?: number
|
|
9
|
+
success?: boolean; error?: string
|
|
15
10
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const PENDING_TTL_MS = 60_000
|
|
19
|
-
/** 活跃页面判定窗口:窗口内有过 pending 轮询 = 有页面在。 */
|
|
20
|
-
const PRESENCE_WINDOW_MS = 15_000
|
|
21
|
-
|
|
22
|
-
let pending: (ExternalOpenRequest & { token: string; at: number }) | null = null
|
|
11
|
+
const pending = new Map<string, Pending>()
|
|
12
|
+
const LEASE_MS = 30_000
|
|
23
13
|
let lastPollAt = 0
|
|
24
14
|
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
* @
|
|
28
|
-
* @param
|
|
29
|
-
* @
|
|
15
|
+
/** @public @author ddj 2026年09月28号
|
|
16
|
+
* Queue a bounded request without replacing earlier opens.
|
|
17
|
+
* @param req Paths and navigation coordinates.
|
|
18
|
+
* @param token Optional inbox request ID.
|
|
19
|
+
* @param createdAt Original producer timestamp; queue ingestion never extends it.
|
|
20
|
+
* @returns Presence and stable request token.
|
|
30
21
|
*/
|
|
31
|
-
export function handoffOpen(req: ExternalOpenRequest): { clients: number; token: string } {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
export function handoffOpen(req: ExternalOpenRequest, token: string = randomUUID(), createdAt = Date.now()): { clients: number; token: string } {
|
|
23
|
+
prunePending()
|
|
24
|
+
const clients = Date.now() - lastPollAt <= 15_000 ? 1 : 0
|
|
25
|
+
if (!Number.isSafeInteger(createdAt) || createdAt + OPEN_TTL_MS <= Date.now()) return { token, clients }
|
|
26
|
+
if (!pending.has(token)) {
|
|
27
|
+
if (pending.size >= 128) throw new Error('外部打开队列已满')
|
|
28
|
+
pending.set(token, { ...req, paths: [...req.paths], token, at: createdAt })
|
|
29
|
+
}
|
|
30
|
+
return { token, clients }
|
|
35
31
|
}
|
|
36
32
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
* @
|
|
40
|
-
* @returns
|
|
33
|
+
/** @public @author ddj 2026年09月28号
|
|
34
|
+
* Claim one request; repeated heartbeats renew this client's active lease.
|
|
35
|
+
* @param args Authenticated page identity.
|
|
36
|
+
* @returns A leased request or null.
|
|
41
37
|
*/
|
|
42
|
-
export function pollPending():
|
|
38
|
+
export function pollPending(args: { clientId: string; busy?: boolean }): OpenClaim | null {
|
|
43
39
|
lastPollAt = Date.now()
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
40
|
+
prunePending()
|
|
41
|
+
if (!args?.clientId || args.clientId.length > 128) return null
|
|
42
|
+
const active = [...pending.values()].find((row) => row.clientId === args.clientId && row.success === undefined)
|
|
43
|
+
if (active && Date.now() - (active.leaseAt ?? 0) < LEASE_MS) {
|
|
44
|
+
active.leaseAt = Date.now()
|
|
47
45
|
return null
|
|
48
46
|
}
|
|
49
|
-
|
|
50
|
-
pending
|
|
51
|
-
|
|
47
|
+
if (args.busy) return null
|
|
48
|
+
for (const row of pending.values()) {
|
|
49
|
+
if (row.success !== undefined || (row.leaseAt && Date.now() - row.leaseAt < LEASE_MS)) continue
|
|
50
|
+
row.clientId = args.clientId
|
|
51
|
+
row.leaseId = randomUUID()
|
|
52
|
+
row.leaseAt = Date.now()
|
|
53
|
+
return { paths: row.paths, line: row.line, column: row.column, token: row.token, leaseId: row.leaseId }
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
* @
|
|
57
|
-
* @
|
|
58
|
-
* @param take 未领取时是否取走清除
|
|
59
|
-
* @returns delivered=true = 已有页面领取(或已被更新的请求取代)
|
|
58
|
+
/** @public @author ddj 2026年09月28号
|
|
59
|
+
* Accept only the current lease's actual editor completion.
|
|
60
|
+
* @param receipt Claimed request identity and result.
|
|
61
|
+
* @returns Whether the receipt was accepted.
|
|
60
62
|
*/
|
|
61
|
-
export function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
export function ackPending(receipt: OpenReceipt): boolean {
|
|
64
|
+
prunePending()
|
|
65
|
+
const row = pending.get(receipt.token)
|
|
66
|
+
if (!row || Date.now() >= row.at + OPEN_TTL_MS || row.clientId !== receipt.clientId || row.leaseId !== receipt.leaseId) return false
|
|
67
|
+
if (!row.leaseAt || Date.now() - row.leaseAt >= LEASE_MS) return false
|
|
68
|
+
if (row.success !== undefined) return row.success === receipt.success
|
|
69
|
+
row.success = receipt.success === true
|
|
70
|
+
row.error = receipt.error?.slice(0, 1000)
|
|
71
|
+
return true
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
74
|
+
/** @public @author ddj 2026年09月28号
|
|
75
|
+
* Inspect completion; absence and mere delivery never imply success.
|
|
76
|
+
* @param token Request identity.
|
|
77
|
+
* @param take Legacy cancellation flag, only cancels unclaimed requests.
|
|
78
|
+
* @returns Terminal completion state.
|
|
79
|
+
*/
|
|
80
|
+
export function pendingState(token: string, take = false): { delivered: boolean; completed: boolean; error?: string } {
|
|
81
|
+
prunePending()
|
|
82
|
+
const row = pending.get(token)
|
|
83
|
+
if (take && row && !row.leaseId) cancelPending(token, '打开已取消')
|
|
84
|
+
return { delivered: row?.success === true, completed: row?.success !== undefined, error: row?.error }
|
|
70
85
|
}
|
|
86
|
+
|
|
87
|
+
/** @public @author ddj 2026年09月28号
|
|
88
|
+
* Cancel queued or claimed work while retaining its identity against replay.
|
|
89
|
+
* @param token Original request ID.
|
|
90
|
+
* @param error Terminal diagnostic.
|
|
91
|
+
*/
|
|
92
|
+
export function cancelPending(token: string, error = '打开超时,请重试'): void {
|
|
93
|
+
const row = pending.get(token)
|
|
94
|
+
if (!row) return
|
|
95
|
+
if (row.success === undefined) { row.success = false; row.error = error }
|
|
96
|
+
row.clientId = undefined
|
|
97
|
+
row.leaseId = undefined
|
|
98
|
+
row.leaseAt = undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @private @author ddj 2026年09月28号 Expire at the original deadline and retain bounded replay tombstones. */
|
|
102
|
+
function prunePending(): void {
|
|
103
|
+
const now = Date.now()
|
|
104
|
+
for (const [key, row] of pending) {
|
|
105
|
+
if (now >= row.at + OPEN_TTL_MS && row.success === undefined) cancelPending(key)
|
|
106
|
+
if (now >= row.at + OPEN_TTL_MS * 2) pending.delete(key)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @public @author ddj 2026年09月28号 Release profile-scoped state during host disposal. */
|
|
111
|
+
export function clearPending(): void { pending.clear(); lastPollAt = 0 }
|
package/src/fileOpenSettings.ts
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* 全程 try/catch,不产生未捕获 rejection。
|
|
9
9
|
* 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号 / 2026年09月22号 / 2026年09月23号
|
|
10
10
|
*/
|
|
11
|
-
import {
|
|
12
|
-
import { pathToFileURL } from 'node:url'
|
|
11
|
+
import { loadHostModule as hostImport } from './hostImport.js'
|
|
13
12
|
import type { Ctx } from './store.js'
|
|
14
13
|
import { KEYBINDING_DEFAULTS } from './shared/keybindings.js'
|
|
15
14
|
import { EDITOR_LIMIT_DEFAULT } from './shared/editorLimit.js'
|
|
@@ -56,28 +55,6 @@ export type SettingsDepsLoader = () => Promise<SettingsDeps | null>
|
|
|
56
55
|
|
|
57
56
|
let depsPromise: Promise<SettingsDeps | null> | undefined
|
|
58
57
|
|
|
59
|
-
/**
|
|
60
|
-
* 宿主锚点动态导入:先经 process.argv[1](DSH 启动脚本所在树)解析并加载目标包,
|
|
61
|
-
* 避开本插件自身 node_modules 的 dev 依赖副本(dev-link 下 import() 相对插件位置
|
|
62
|
-
* 解析,会命中 rc 线旧包 dsh-settings@0.1.0-rc.8);argv[1] 缺失或解析失败回退
|
|
63
|
-
* 普通 specifier import。
|
|
64
|
-
* @author ddj 2026年09月15号
|
|
65
|
-
* @param specifier 包名
|
|
66
|
-
* @returns 加载的模块命名空间
|
|
67
|
-
*/
|
|
68
|
-
async function hostImport(specifier: string): Promise<unknown> {
|
|
69
|
-
const entry = process.argv[1]
|
|
70
|
-
if (entry) {
|
|
71
|
-
try {
|
|
72
|
-
const resolved = createRequire(entry).resolve(specifier)
|
|
73
|
-
return await import(pathToFileURL(resolved).href)
|
|
74
|
-
} catch {
|
|
75
|
-
/* 锚点不可用:回退普通 import */
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return import(specifier)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
58
|
/**
|
|
82
59
|
* schema 库候选名(新名在前)。
|
|
83
60
|
* DSH 官方自 0.1.5 起把 vendored 包改名为 @deepseek-ai/schemastery 并全树改用新名,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Host-anchored optional package imports. @author ddj 2026年09月28号 */
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve against the running DSH entry before the plugin's development dependencies.
|
|
7
|
+
* Import failures after successful resolution are preserved, not hidden by an older copy.
|
|
8
|
+
* @author ddj 2026年09月28号
|
|
9
|
+
* @param specifier Host package name.
|
|
10
|
+
* @returns The evaluated host module namespace.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadHostModule(specifier: string): Promise<unknown> {
|
|
13
|
+
const anchor = process.argv[1]
|
|
14
|
+
let resolved: string | undefined
|
|
15
|
+
if (anchor) {
|
|
16
|
+
try { resolved = createRequire(anchor).resolve(specifier) } catch { /* Source-only tests may have no Host tree. */ }
|
|
17
|
+
}
|
|
18
|
+
return import(resolved ? pathToFileURL(resolved).href : specifier)
|
|
19
|
+
}
|