dsh-vscode-mode 0.11.0 → 0.13.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 +158 -111
- 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 +13362 -11806
- package/lib/client.js.map +1 -1
- package/lib/index.js +4110 -2507
- 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/activePathStore.ts +28 -0
- package/src/client/addToConversation.ts +4 -2
- package/src/client/ai/inlineProvider.ts +1 -8
- package/src/client/commandBridge.ts +14 -42
- package/src/client/externalOpen.ts +40 -19
- package/src/client/index.ts +101 -18
- package/src/client/keybindings.ts +36 -73
- 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/shortcutRecorder.ts +161 -0
- package/src/client/shortcutsOfficial.ts +460 -0
- package/src/client/sidebar/panels/RulesPanel.ts +15 -11
- package/src/client/sidebarBridge.ts +7 -0
- package/src/client/ui/DiffBadge.ts +2 -1
- package/src/client/ui/EditorView.ts +246 -137
- package/src/client/ui/McpSettings.ts +10 -8
- package/src/client/ui/OfficialSideTab.ts +5 -2
- package/src/client/ui/QuickOpen.ts +16 -16
- package/src/client/ui/ShortcutSettings.ts +274 -0
- package/src/client/ui/commandCatalog.ts +39 -43
- 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 +4 -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/rules.ts +100 -44
- 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 +256 -0
- package/src/shared/mcp.ts +4 -1
- package/src/shared/rpc.ts +7 -4
- package/src/shared/rules.ts +9 -1
- 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/KeybindingsPanel.ts +0 -190
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Correlated editor readiness for external file opens. @author ddj 2026年09月28号 */
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { finishOpen, hasOpen, peekOpen } from './openReceipt.js'
|
|
4
|
+
import type { EditorRequest } from './openReceipt.js'
|
|
5
|
+
|
|
6
|
+
export interface ReceiptHost {
|
|
7
|
+
sessionId: string
|
|
8
|
+
open(request: EditorRequest): void | Promise<void>
|
|
9
|
+
ready(request: EditorRequest): boolean
|
|
10
|
+
error(request: EditorRequest): string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** One mounted editor owns at most one claimed external request. */
|
|
14
|
+
export class OpenMonitor {
|
|
15
|
+
private pending: EditorRequest | undefined
|
|
16
|
+
private frame: number | undefined
|
|
17
|
+
private stopped = false
|
|
18
|
+
private opening = false
|
|
19
|
+
/** @private @author ddj 2026年09月28号 Claim a request arriving after mount. */
|
|
20
|
+
private listener = (): void => { this.claim() }
|
|
21
|
+
|
|
22
|
+
/** @author ddj 2026年09月28号 @param read Read current component state without a stale closure. */
|
|
23
|
+
constructor(private readonly read: () => ReceiptHost) {}
|
|
24
|
+
|
|
25
|
+
/** @public @author ddj 2026年09月28号 Attach the listener and claim a pre-mount request. */
|
|
26
|
+
start(): void {
|
|
27
|
+
window.addEventListener('edrv:open-request', this.listener)
|
|
28
|
+
this.claim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @public @author ddj 2026年09月28号 Fail claimed work and cancel the frame on unmount. */
|
|
32
|
+
stop(): void {
|
|
33
|
+
this.stopped = true
|
|
34
|
+
window.removeEventListener('edrv:open-request', this.listener)
|
|
35
|
+
if (this.frame !== undefined) cancelAnimationFrame(this.frame)
|
|
36
|
+
this.frame = undefined
|
|
37
|
+
if (this.pending) finishOpen(this.pending.requestId, false, '编辑器已关闭')
|
|
38
|
+
this.pending = undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @private @author ddj 2026年09月28号 Schedule at most one readiness/queue check. */
|
|
42
|
+
private arm(): void {
|
|
43
|
+
if (!this.stopped && this.frame === undefined) this.frame = requestAnimationFrame(this.check)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Settle only the request this instance still owns; queued work runs next frame.
|
|
48
|
+
* @private @author ddj 2026年09月28号
|
|
49
|
+
* @param request Claimed request, captured before asynchronous work.
|
|
50
|
+
* @param success Whether actual readiness was confirmed.
|
|
51
|
+
* @param error Failure description.
|
|
52
|
+
*/
|
|
53
|
+
private finish(request: EditorRequest, success: boolean, error?: string): void {
|
|
54
|
+
if (this.stopped || this.pending !== request) return
|
|
55
|
+
this.pending = undefined
|
|
56
|
+
this.opening = false
|
|
57
|
+
if (hasOpen(request.requestId)) finishOpen(request.requestId, success, error)
|
|
58
|
+
this.arm()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Observe startup without letting a late completion affect a new claim.
|
|
63
|
+
* @private @author ddj 2026年09月28号
|
|
64
|
+
* @param request Startup's request identity.
|
|
65
|
+
* @param operation Optional asynchronous editor startup.
|
|
66
|
+
*/
|
|
67
|
+
private watchOpen(request: EditorRequest, operation: void | Promise<void>): void {
|
|
68
|
+
if (!operation) return
|
|
69
|
+
this.opening = true
|
|
70
|
+
/** @author ddj 2026年09月28号 Release readiness only for this live claim. */
|
|
71
|
+
const ready = (): void => {
|
|
72
|
+
if (!this.stopped && this.pending === request) this.opening = false
|
|
73
|
+
}
|
|
74
|
+
/** @author ddj 2026年09月28号 @param error Correlated startup rejection. */
|
|
75
|
+
const fail = (error: unknown): void => this.finish(request, false, String(error))
|
|
76
|
+
void Promise.resolve(operation).then(ready, fail)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @private @author ddj 2026年09月28号 Claim this session; exceptions fail the exact request. */
|
|
80
|
+
private claim(): void {
|
|
81
|
+
if (this.stopped || (this.pending && hasOpen(this.pending.requestId))) return
|
|
82
|
+
this.pending = undefined
|
|
83
|
+
this.opening = false
|
|
84
|
+
try {
|
|
85
|
+
const host = this.read()
|
|
86
|
+
const request = peekOpen(host.sessionId)
|
|
87
|
+
if (!request) return
|
|
88
|
+
this.pending = request
|
|
89
|
+
this.watchOpen(request, host.open(request))
|
|
90
|
+
this.arm()
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (this.pending) this.finish(this.pending, false, String(error))
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @private @author ddj 2026年09月28号 Check readiness; expired claims and exceptions cannot ACK later work. */
|
|
97
|
+
private check = (): void => {
|
|
98
|
+
this.frame = undefined
|
|
99
|
+
if (this.stopped) return
|
|
100
|
+
const request = this.pending
|
|
101
|
+
if (!request || !hasOpen(request.requestId)) { this.pending = undefined; this.claim(); return }
|
|
102
|
+
try {
|
|
103
|
+
const host = this.read()
|
|
104
|
+
if (request.sessionId !== host.sessionId) { this.finish(request, false, '会话已切换'); return }
|
|
105
|
+
if (this.opening) { this.arm(); return }
|
|
106
|
+
const error = host.error(request)
|
|
107
|
+
if (error || host.ready(request)) { this.finish(request, !error, error ?? undefined); return }
|
|
108
|
+
this.arm()
|
|
109
|
+
} catch (error) { this.finish(request, false, String(error)) }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Bind a component while reading its latest model state.
|
|
115
|
+
* @author ddj 2026年09月28号
|
|
116
|
+
* @param host Mounted editor actions and readiness checks.
|
|
117
|
+
*/
|
|
118
|
+
export function useOpenReceipt(host: ReceiptHost): void {
|
|
119
|
+
const latest = React.useRef(host)
|
|
120
|
+
latest.current = host
|
|
121
|
+
/** @author ddj 2026年09月28号 @returns Listener and animation-frame cleanup. */
|
|
122
|
+
function mount(): () => void {
|
|
123
|
+
const monitor = new OpenMonitor(() => latest.current)
|
|
124
|
+
monitor.start()
|
|
125
|
+
return () => monitor.stop()
|
|
126
|
+
}
|
|
127
|
+
React.useEffect(mount, [host.sessionId])
|
|
128
|
+
}
|
package/src/compat.ts
CHANGED
|
@@ -132,8 +132,10 @@ export function detectGuards(ctx: Ctx): CompatAdapter[] {
|
|
|
132
132
|
]
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。
|
|
136
|
-
|
|
135
|
+
/** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。
|
|
136
|
+
* 0.1.7-rc.2 已在官方已签名打包 Desktop 上实机装配(认证路由、同源 RPC、项目 MCP
|
|
137
|
+
* 与私有打开队列均生效);更高版本仍按能力探测运行并提示。 */
|
|
138
|
+
const TESTED_DSH_MAX = '0.1.7-rc.2'
|
|
137
139
|
|
|
138
140
|
/** Config volatile 标记起效的版本线(此前设置走 section 安装,与 volatile 无关)。 */
|
|
139
141
|
const CONFIG_VOLATILE_MIN = '0.1.7-alpha.1'
|
|
@@ -210,7 +212,7 @@ export async function buildReport(
|
|
|
210
212
|
guards,
|
|
211
213
|
adapters,
|
|
212
214
|
warnings,
|
|
213
|
-
devForm: readDevForm(),
|
|
215
|
+
devForm: readDevForm(ctx),
|
|
214
216
|
dshVersion,
|
|
215
217
|
}
|
|
216
218
|
}
|
package/src/dap/manager.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* 作者 ddj 2026年09月29号 / 2026年09月21号
|
|
19
19
|
*/
|
|
20
20
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
21
|
+
import { childEnv } from '../childEnv.js'
|
|
22
|
+
import { stopTree } from '../processTree.js'
|
|
21
23
|
import { join } from 'node:path'
|
|
22
24
|
import { createFrameParser, encodeMessage } from '../lsp/jsonrpc.js'
|
|
23
25
|
import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath } from './sourcePath.js'
|
|
@@ -288,9 +290,18 @@ export class DapSession {
|
|
|
288
290
|
|
|
289
291
|
// ---------------------------------------------------------------- 内部实现
|
|
290
292
|
|
|
291
|
-
/**
|
|
293
|
+
/**
|
|
294
|
+
* 启动独立适配器进程组并接入协议,清理隐式宿主凭据。
|
|
295
|
+
* @private
|
|
296
|
+
* @author ddj 2026年09月28号
|
|
297
|
+
* @param spec 适配器规格
|
|
298
|
+
* @throws spawn 同步失败
|
|
299
|
+
*/
|
|
292
300
|
private spawnAdapter(spec: DapAdapterSpec): void {
|
|
293
|
-
const child = spawn(spec.command, spec.args, {
|
|
301
|
+
const child = spawn(spec.command, spec.args, {
|
|
302
|
+
stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
303
|
+
detached: process.platform !== 'win32', env: this.adapterEnv(),
|
|
304
|
+
})
|
|
294
305
|
this.child = child
|
|
295
306
|
const parser = createFrameParser()
|
|
296
307
|
child.stdout?.on('data', (chunk: Buffer) => {
|
|
@@ -300,11 +311,16 @@ export class DapSession {
|
|
|
300
311
|
log.debug('[dap-adapter] ' + chunk.toString('utf8').trim())
|
|
301
312
|
this.stderrTail = (this.stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_CAP)
|
|
302
313
|
})
|
|
303
|
-
child.on('error', (error) =>
|
|
314
|
+
child.on('error', (error) => {
|
|
315
|
+
if (this.child === child) this.fail('适配器进程错误:' + String(error))
|
|
316
|
+
})
|
|
317
|
+
child.stdin?.on('error', (error) => {
|
|
318
|
+
if (this.child === child) this.fail('适配器 stdin 错误:' + String(error))
|
|
319
|
+
})
|
|
304
320
|
child.on('exit', (code) => {
|
|
305
|
-
if (this.child
|
|
306
|
-
|
|
307
|
-
|
|
321
|
+
if (this.child !== child) return
|
|
322
|
+
if (this.phase === 'idle' || this.phase === 'terminated') this.killChild()
|
|
323
|
+
else this.fail('适配器退出(code=' + code + ')' + this.stderrSuffix())
|
|
308
324
|
})
|
|
309
325
|
}
|
|
310
326
|
|
|
@@ -529,18 +545,35 @@ export class DapSession {
|
|
|
529
545
|
this.setPhase('terminated')
|
|
530
546
|
}
|
|
531
547
|
|
|
532
|
-
/**
|
|
548
|
+
/**
|
|
549
|
+
* 清理父环境后合并 launch 配置显式 env,null 表示移除该项。
|
|
550
|
+
* @private
|
|
551
|
+
* @author ddj 2026年09月28号
|
|
552
|
+
* @returns 适配器的环境副本
|
|
553
|
+
*/
|
|
554
|
+
private adapterEnv(): NodeJS.ProcessEnv {
|
|
555
|
+
const explicit: NodeJS.ProcessEnv = {}
|
|
556
|
+
const raw = this.config?.raw?.env
|
|
557
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
558
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
559
|
+
if (typeof value === 'string' || value === null) explicit[key] = value ?? undefined
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return childEnv(explicit)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 解除会话拥有权并终止适配器进程树;不先杀根进程。
|
|
567
|
+
* @private
|
|
568
|
+
* @author ddj 2026年09月28号
|
|
569
|
+
* @description 同时清理所有待处理请求与计时器。
|
|
570
|
+
*/
|
|
533
571
|
private killChild(): void {
|
|
534
572
|
const child = this.child
|
|
535
573
|
this.child = null
|
|
536
574
|
for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.resolve(null) }
|
|
537
575
|
this.pending.clear()
|
|
538
|
-
if (
|
|
539
|
-
try { child.kill() } catch { /* 忽略 */ }
|
|
540
|
-
if (process.platform === 'win32' && child.pid) {
|
|
541
|
-
try { spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch { /* 忽略 */ }
|
|
542
|
-
}
|
|
543
|
-
setTimeout(() => { try { child.kill('SIGKILL') } catch { /* 忽略 */ } }, 1500).unref?.()
|
|
576
|
+
if (child) stopTree(child, (line) => log.warn('[dap] ' + line))
|
|
544
577
|
}
|
|
545
578
|
|
|
546
579
|
/** 相位切换(running→running 等幂等跳过)。 */
|
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
|