dsh-vscode-mode 0.4.2 → 0.4.4

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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * dsh-vscode-mode client — 已打开文件的外部改动轮询。
3
+ *
4
+ * 背景:RPC 只有客户端拉取通道(无服务端推送),编辑区因此无法感知外部写盘。
5
+ * 本 hook 以轻量接口补上感知:每 POLL_MS 一次把「全部已打开页签路径」批量交给
6
+ * host edrv.versions(单请求多条 stat),逐条与本地基线比对后按 watchDecision 的
7
+ * 判定表把动作回调给 EditorView 执行(IO 与 UI 都在那里)。
8
+ *
9
+ * 为何「版本变了还要读内容」:host 版本令牌含 ctime,同字节重写(格式化工具/同步盘/
10
+ * 编辑器保存策略)也会让它变化,只看版本会把无实质变化的文件反复重载(打断光标与
11
+ * 滚动)。故版本变化时读一次磁盘内容与缓冲比对,相同则只推进基线;读取结果按版本
12
+ * 记入 readVersions,同一版本只读一次(陈旧缓冲长期不处理也不会反复拉大文件)。
13
+ *
14
+ * 取舍:不使用 fs.watch(句柄/网络盘/长路径兼容性差),纯 stat+按需读轮询跨平台稳定
15
+ * 且可单测;1.5s 周期对「外部改文件 → 编辑区可见」足够及时,标签页隐藏时整轮跳过。
16
+ *
17
+ * 上报规则(避免每轮都弹提示):
18
+ * - 首次观测某路径(无标记):报 conflict/deleted,并落标记;
19
+ * - 已有标记且仍异常:不再重复回调(提示已在屏上),版本恢复一致时自动清标记;
20
+ * - 站点不可达(老 host 无此方法/离线):整轮静默放弃,不清标记、不放大重试。
21
+ *
22
+ * 作者 ddj 2026-09-15
23
+ */
24
+ import * as React from 'react'
25
+ import { rpc } from '../rpc.js'
26
+ import type { FileVersionItem } from '../../shared/rpc.js'
27
+ import {
28
+ baselineOf,
29
+ clearReadVersion,
30
+ clearSync,
31
+ markReadVersion,
32
+ markSync,
33
+ readSync,
34
+ readVersionOf,
35
+ recordBaseline,
36
+ syncDecision,
37
+ versionOf,
38
+ } from '../watchDecision.js'
39
+
40
+ /** 轮询周期:外部改动可见延迟上限(标签页隐藏时跳过整轮)。 */
41
+ export const POLL_MS = 1500
42
+
43
+ /** 同步判定结果(hook 只说「发生了什么」,怎么处理由调用方决定)。 */
44
+ export interface DiskChange {
45
+ path: string
46
+ kind: 'modified' | 'conflict' | 'deleted'
47
+ }
48
+
49
+ /** 读盘结果(供内容比对)。 */
50
+ export interface DiskContent {
51
+ /** 磁盘内容是否与调用方缓冲内容相同。 */
52
+ equal: boolean
53
+ }
54
+
55
+ /** 一轮判定的上下文(ref 与回调打包,避免 evaluate 参数过长;导出供测试构造)。 */
56
+ export interface PollContext {
57
+ sessionId: string
58
+ scope: string
59
+ dirtyRef: React.MutableRefObject<Record<string, boolean>>
60
+ onReadDisk?: (path: string) => Promise<DiskContent | null>
61
+ onChange: (change: DiskChange) => void
62
+ }
63
+
64
+ /** hook 选项(ref 传入最新值,避免把轮询循环写进 React 依赖数组)。 */
65
+ export interface FileWatchOptions {
66
+ /** 会话 id(缺失时不轮询)。 */
67
+ sessionId?: string | null
68
+ /** 工作区作用域键(基线/台账按它隔离)。 */
69
+ scope: string
70
+ /** 当前全部页签路径(读 ref 取最新值)。 */
71
+ tabsRef: React.MutableRefObject<string[]>
72
+ /** 脏标记表(path → 是否待保存)。 */
73
+ dirtyRef: React.MutableRefObject<Record<string, boolean>>
74
+ /**
75
+ * 版本变化时读磁盘内容(与调用方缓冲内容比对用);
76
+ * 未实现/失败返回 null → 干净缓冲按「需要重载」处理,脏缓冲按冲突处理。
77
+ */
78
+ onReadDisk?: (path: string) => Promise<DiskContent | null>
79
+ /** 同步动作回调(EditorView 负责重载、提示、刷新差异标记)。 */
80
+ onDiskChange: (change: DiskChange) => void
81
+ }
82
+
83
+ /**
84
+ * 装配外部改动轮询(挂载即开始,卸载/换会话即停)。
85
+ * @author ddj 2026年09月15号
86
+ * @param options 会话/作用域/ref 与回调
87
+ */
88
+ export function useFileWatch(options: FileWatchOptions): void {
89
+ const { sessionId, scope, tabsRef, dirtyRef, onReadDisk, onDiskChange } = options
90
+ const cbRef = React.useRef(onDiskChange)
91
+ cbRef.current = onDiskChange
92
+ const readRef = React.useRef(onReadDisk)
93
+ readRef.current = onReadDisk
94
+ /** 在途守卫:上一轮未返回(host 卡住)时跳过本轮,避免请求堆积。 */
95
+ const busyRef = React.useRef(false)
96
+
97
+ React.useEffect(() => {
98
+ if (!sessionId) return
99
+ const context: PollContext = {
100
+ sessionId,
101
+ scope,
102
+ dirtyRef,
103
+ onReadDisk: (path) => (readRef.current ? readRef.current(path) : Promise.resolve(null)),
104
+ onChange: (change) => cbRef.current(change),
105
+ }
106
+ /**
107
+ * 一轮观测:批量取版本 → 逐条判定 → 回调。
108
+ * @author ddj 2026年09月15号
109
+ */
110
+ const poll = async (): Promise<void> => {
111
+ if (busyRef.current) return
112
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
113
+ const paths = (tabsRef.current ?? []).filter((p) => typeof p === 'string' && p)
114
+ if (!paths.length) return
115
+ busyRef.current = true
116
+ try {
117
+ const res = await rpc('edrv.versions', { sessionId, paths })
118
+ if (!res || !res.ok || !Array.isArray(res.items)) return
119
+ for (const item of res.items) {
120
+ if (!item || !item.path) continue
121
+ await evaluate(item, context)
122
+ }
123
+ } catch (error) {
124
+ // 老 host 无此方法 / 离线:静默停摆这一轮,不清任何标记
125
+ } finally {
126
+ busyRef.current = false
127
+ }
128
+ }
129
+ const timer = window.setInterval(() => { void poll() }, POLL_MS)
130
+ void poll()
131
+ return () => window.clearInterval(timer)
132
+ }, [sessionId, scope])
133
+ }
134
+
135
+ /**
136
+ * 单条判定:按 watchDecision 的判定表决定动作,必要时清/落台账标记。
137
+ * 版本变化时先读磁盘内容(同版本只读一次)再定分支:内容相同只推进基线,
138
+ * 内容不同且缓冲脏才升级为冲突提示。
139
+ *
140
+ * 导出原因:这是「观测 → 动作派发」的关键落点(三条分支各自的副作用不轻),
141
+ * 用假 fetch + 假 context 可直接驱动它做端到端断言,无需渲染 React 树。
142
+ * 仅供测试与同模块内 poll 调用,不属于对外 API。
143
+ * @author ddj 2026年09月15号
144
+ * @param item host 版本条目
145
+ * @param context 轮询上下文(作用域/脏标记/读盘/回调)
146
+ */
147
+ export async function evaluate(item: FileVersionItem, context: PollContext): Promise<void> {
148
+ const path = item.path
149
+ const scope = context.scope
150
+ const baseline = baselineOf(scope, path)
151
+ const version = versionOf(item)
152
+ const missing = item.type === 'missing'
153
+ const dirty = context.dirtyRef.current?.[path] === true
154
+ // 无基线但拿到版本:补记基线(首次观测不报变化,避免开页即弹提示)
155
+ if (!baseline) {
156
+ recordBaseline(scope, path, version)
157
+ return
158
+ }
159
+ const versionChanged = version !== baseline
160
+ if (!versionChanged && !missing) {
161
+ clearSync(scope, path) // 版本恢复一致:清掉历史标记(含误报自愈)
162
+ return
163
+ }
164
+ // 版本变了但内容一致(同字节重写:格式化工具/同步盘/编辑器保存策略):
165
+ // 只推进基线,绝不通知调用方重载——那会白刷一次并打断光标与滚动。
166
+ const mode = await inspect(path, version, context)
167
+ if (mode.known && mode.equal) {
168
+ recordBaseline(scope, path, version)
169
+ markReadVersion(scope, path, version, true)
170
+ clearSync(scope, path)
171
+ return
172
+ }
173
+ const action = syncDecision({ hasBaseline: true, versionChanged, missing, clean: !dirty, contentEqual: mode.equal })
174
+ if (action === 'sync-silent') {
175
+ clearSync(scope, path)
176
+ if (mode.known) {
177
+ recordBaseline(scope, path, version) // 内容不同:先推进基线,随后由调用方重载
178
+ markReadVersion(scope, path, version, false)
179
+ } else {
180
+ clearReadVersion(scope, path) // 读失败:交重载重新读盘(并重试比对)
181
+ }
182
+ context.onChange({ path, kind: 'modified' })
183
+ return
184
+ }
185
+ if (action === 'none') return
186
+ const kind = action === 'deleted' ? 'deleted' : 'conflict'
187
+ const flagged = readSync(scope, path)
188
+ if (flagged && flagged.kind === kind) return
189
+ markSync(scope, path, kind)
190
+ context.onChange({ path, kind })
191
+ }
192
+
193
+ /**
194
+ * 读盘并与调用方缓冲比对(脏缓冲也需要:内容相同就不该报冲突)。
195
+ * 同版本只读一次:已读过该版本的路径直接回放当时的比对结果(不重复拉取大文件)。
196
+ * @author ddj 2026年09月15号
197
+ * @param path 文件路径
198
+ * @param version 当前磁盘版本(可为 null)
199
+ * @param context 轮询上下文
200
+ * @returns { known: 是否拿到磁盘内容, equal: 内容是否与缓冲一致 }
201
+ */
202
+ async function inspect(
203
+ path: string,
204
+ version: string | null,
205
+ context: PollContext,
206
+ ): Promise<{ known: boolean; equal: boolean }> {
207
+ if (!version) return { known: false, equal: false }
208
+ const cached = readVersionOf(context.scope, path, version)
209
+ if (cached) return { known: true, equal: cached.equal }
210
+ const disk = await context.onReadDisk?.(path).catch(() => null)
211
+ if (!disk) return { known: false, equal: false }
212
+ markReadVersion(context.scope, path, version, disk.equal)
213
+ return { known: true, equal: disk.equal }
214
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * dsh-vscode-mode client — 外部磁盘改动的同步决策(纯逻辑,可单测)。
3
+ *
4
+ * 存在意义:编辑区内容只在打开/手动刷新时从磁盘读取,外部(其他编辑器、Unity、
5
+ * 脚本、agent 工具)改了文件后缓冲与文件树长期陈旧,且陈旧缓冲的防抖自动保存会
6
+ * 直接覆盖外部改动。本模块只承载判定与基线台账,IO 与 UI 由 useFileWatch /
7
+ * EditorView 承担:
8
+ * - 基线(baseline):每个已打开路径记录「最后一次从磁盘读到的版本令牌」,
9
+ * 版本令牌来自 host fs.stat(不透明串),客户端只做相等比较。
10
+ * - 台账(syncState):判定为需要用户介入(冲突/消失)的路径,供状态栏提示
11
+ * 与「抑制自动保存」读取;冲突被处理或自愈后清除。
12
+ *
13
+ * 判定表(syncDecision):
14
+ * 无基线 → none(首次打开或未带版本,由调用方补记基线)
15
+ * 文件缺失 + 缓冲干净 → deleted
16
+ * 文件缺失 + 缓冲脏 → none(保留未保存编辑,交由保存失败路径提示)
17
+ * 版本未变 → none
18
+ * 版本变 + 缓冲干净 → sync-silent(先比内容:相同只补基线,不同才重载)
19
+ * 版本变 + 缓冲脏 + 内容相同 → sync-silent(仅补记基线,不打断编辑)
20
+ * 版本变 + 缓冲脏 + 内容不同 → conflict(绝不覆盖)
21
+ *
22
+ * 重要事实(实测,勿改回「只看版本」):host 版本令牌 = host fs 的 stat 身份串,
23
+ * 本地后端实现为 `dev:ino:size:mtimeNs:ctimeNs`,其中 ctime 在「同字节重写」时
24
+ * 也会变(格式化工具/同步盘/编辑器保存策略常见)。因此版本变化只代表「动过」,
25
+ * 是否真的需要重载必须由磁盘内容与缓冲内容的比对决定;读取结果按版本号缓存
26
+ * (readVersion),同一版本只读一次,避免陈旧缓冲长期不处理时反复拉取大文件。
27
+ *
28
+ * 作者 ddj 2026-09-15
29
+ */
30
+ import type { FileVersionItem } from '../shared/rpc.js'
31
+
32
+ // --region 类型
33
+
34
+ /** 一次同步判定的输入(全部为已观测事实,不含 IO)。 */
35
+ export interface SyncInput {
36
+ /** 是否已有磁盘版本基线。 */
37
+ hasBaseline: boolean
38
+ /** 磁盘版本令牌是否与基线不同(无基线时为 false)。 */
39
+ versionChanged: boolean
40
+ /** 磁盘上是否已不存在该路径。 */
41
+ missing: boolean
42
+ /** 缓冲是否已置脏(有待保存的编辑)。 */
43
+ clean: boolean
44
+ /** 缓冲内容是否与磁盘内容相同(仅冲突分支需要,未取到磁盘内容时传 false)。 */
45
+ contentEqual: boolean
46
+ }
47
+
48
+ /** 同步判定结果。 */
49
+ export type SyncAction = 'sync-silent' | 'conflict' | 'deleted' | 'none'
50
+
51
+ /** 需要用户介入的台账项(状态栏提示与自动保存抑制共用)。 */
52
+ export interface SyncFlag {
53
+ path: string
54
+ kind: 'conflict' | 'deleted'
55
+ at: number
56
+ }
57
+
58
+ // --endregion
59
+
60
+ // --region 常量与状态
61
+
62
+ /** 台账上限:超出按写入序逐出最旧(防长时间运行无界增长)。 */
63
+ export const SYNC_STATE_CAP = 64
64
+ /** 单作用域基线上限:超出按写入序逐出最旧(已关闭页签的基线可丢)。 */
65
+ export const BASELINE_CAP = 128
66
+
67
+ /** 作用域键 → 路径 → 磁盘版本令牌。 */
68
+ const baselines = new Map<string, Map<string, string>>()
69
+ /** 作用域键 → 路径 → 已读取过内容的磁盘版本(同版本不重复读盘;含当时的比对结果)。 */
70
+ const readVersions = new Map<string, Map<string, { version: string; equal: boolean }>>()
71
+ /** 作用域键 → 路径 → 待处理同步标记。 */
72
+ const syncState = new Map<string, Map<string, SyncFlag>>()
73
+
74
+ // --endregion
75
+
76
+ // --region 纯函数
77
+
78
+ /**
79
+ * 台账键:作用域 + 归一化路径(`/` 分隔,大小写敏感语义与 host 一致)。
80
+ * @author ddj 2026年09月15号
81
+ * @param scope 工作区作用域键
82
+ * @param path 文件路径(工作区相对或绝对)
83
+ * @returns 台账键
84
+ */
85
+ export function syncKey(scope: string, path: string): string {
86
+ return String(scope ?? '') + '\u0000' + String(path ?? '').replace(/\\/g, '/')
87
+ }
88
+
89
+ /**
90
+ * 判定一次观测的同步动作(纯函数,判定表见模块头注释)。
91
+ * @author ddj 2026年09月15号
92
+ * @param input 观测输入(基线有无/版本是否变化/是否缺失/缓冲是否脏/内容是否相同)
93
+ * @returns 同步动作
94
+ */
95
+ export function syncDecision(input: SyncInput): SyncAction {
96
+ if (!input.hasBaseline) return 'none'
97
+ if (input.missing) return input.clean ? 'deleted' : 'none'
98
+ if (!input.versionChanged) return 'none'
99
+ if (input.clean) return 'sync-silent'
100
+ return input.contentEqual ? 'sync-silent' : 'conflict'
101
+ }
102
+
103
+ /**
104
+ * 从 host 版本条目取可比较的版本令牌(缺失/空串 → null,表示该后端不提供版本)。
105
+ * @author ddj 2026年09月15号
106
+ * @param item 版本条目(可为 undefined)
107
+ * @returns 版本令牌或 null
108
+ */
109
+ export function versionOf(item: FileVersionItem | undefined | null): string | null {
110
+ const version = item?.version
111
+ return typeof version === 'string' && version ? version : null
112
+ }
113
+
114
+ // --endregion
115
+
116
+ // --region 基线台账
117
+
118
+ /**
119
+ * 记入(或更新)某路径的磁盘版本基线;版本为空串时不记(后端不支持版本)。
120
+ * @author ddj 2026年09月15号
121
+ * @param scope 工作区作用域键
122
+ * @param path 文件路径
123
+ * @param version 磁盘版本令牌
124
+ */
125
+ export function recordBaseline(scope: string, path: string, version: string | undefined | null): void {
126
+ if (!path || typeof version !== 'string' || !version) return
127
+ let map = baselines.get(scope)
128
+ if (!map) {
129
+ map = new Map()
130
+ baselines.set(scope, map)
131
+ }
132
+ const key = String(path).replace(/\\/g, '/')
133
+ map.delete(key)
134
+ map.set(key, version)
135
+ while (map.size > BASELINE_CAP) {
136
+ const oldest = map.keys().next().value
137
+ if (oldest === undefined) break
138
+ map.delete(oldest)
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 读取某路径的磁盘版本基线。
144
+ * @author ddj 2026年09月15号
145
+ * @param scope 工作区作用域键
146
+ * @param path 文件路径
147
+ * @returns 版本令牌;无基线 → null
148
+ */
149
+ export function baselineOf(scope: string, path: string): string | null {
150
+ return baselines.get(scope)?.get(String(path).replace(/\\/g, '/')) ?? null
151
+ }
152
+
153
+ /**
154
+ * 清除某路径的基线(页签关闭、会话销毁时调用)。
155
+ * @author ddj 2026年09月15号
156
+ * @param scope 工作区作用域键
157
+ * @param path 文件路径
158
+ */
159
+ export function clearBaseline(scope: string, path: string): void {
160
+ baselines.get(scope)?.delete(String(path).replace(/\\/g, '/'))
161
+ }
162
+
163
+ /**
164
+ * 记入「已读取过内容的磁盘版本」与比结果(同版本不再重复读盘)。
165
+ * 读盘成功、保存成功、覆盖成功、保留本地推进基线时都要记,避免下一轮白读一次。
166
+ * @author ddj 2026年09月15号
167
+ * @param scope 工作区作用域键
168
+ * @param path 文件路径
169
+ * @param version 版本令牌(空值忽略)
170
+ * @param equal 读到的磁盘内容是否与当时的缓冲内容相同
171
+ */
172
+ export function markReadVersion(scope: string, path: string, version: string | undefined | null, equal = false): void {
173
+ if (!path || typeof version !== 'string' || !version) return
174
+ let map = readVersions.get(scope)
175
+ if (!map) {
176
+ map = new Map()
177
+ readVersions.set(scope, map)
178
+ }
179
+ const key = String(path).replace(/\\/g, '/')
180
+ map.delete(key)
181
+ map.set(key, { version, equal: equal === true })
182
+ while (map.size > BASELINE_CAP) {
183
+ const oldest = map.keys().next().value
184
+ if (oldest === undefined) break
185
+ map.delete(oldest)
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 该磁盘版本是否已读过内容;是则同时给出当时的比对结果(轮询跳过读盘)。
191
+ * 必须按版本号严格匹配:缓存里可能是更早版本的结果,版本不同一律视为未读。
192
+ * @author ddj 2026年09月15号
193
+ * @param scope 工作区作用域键
194
+ * @param path 文件路径
195
+ * @param version 当前磁盘版本令牌
196
+ * @returns 未读过该版本 → null;读过 → { equal: 内容是否与缓冲一致 }
197
+ */
198
+ export function readVersionOf(scope: string, path: string, version: string | null): { equal: boolean } | null {
199
+ if (!version) return null
200
+ const memo = readVersions.get(scope)?.get(String(path).replace(/\\/g, '/'))
201
+ if (!memo || memo.version !== version) return null
202
+ return { equal: memo.equal }
203
+ }
204
+
205
+ /**
206
+ * 清除某路径的已读版本(重载后必须清:下一轮需按新版本重新读盘比对)。
207
+ * @author ddj 2026年09月15号
208
+ * @param scope 工作区作用域键
209
+ * @param path 文件路径
210
+ */
211
+ export function clearReadVersion(scope: string, path: string): void {
212
+ readVersions.get(scope)?.delete(String(path).replace(/\\/g, '/'))
213
+ }
214
+
215
+ /**
216
+ * 清空某作用域的全部基线(作用域切换时调用,防旧工作区基线串到新工作区)。
217
+ * @author ddj 2026年09月15号
218
+ * @param scope 工作区作用域键
219
+ */
220
+ export function clearScope(scope: string): void {
221
+ baselines.delete(scope)
222
+ readVersions.delete(scope)
223
+ syncState.delete(scope)
224
+ }
225
+
226
+ // --endregion
227
+
228
+ // --region 同步标记
229
+
230
+ /**
231
+ * 标记某路径需要用户介入(冲突/文件消失)。
232
+ * @author ddj 2026年09月15号
233
+ * @param scope 工作区作用域键
234
+ * @param path 文件路径
235
+ * @param kind 标记类型
236
+ */
237
+ export function markSync(scope: string, path: string, kind: SyncFlag['kind']): void {
238
+ let map = syncState.get(scope)
239
+ if (!map) {
240
+ map = new Map()
241
+ syncState.set(scope, map)
242
+ }
243
+ const key = String(path).replace(/\\/g, '/')
244
+ map.delete(key)
245
+ map.set(key, { path, kind, at: Date.now() })
246
+ while (map.size > SYNC_STATE_CAP) {
247
+ const oldest = map.keys().next().value
248
+ if (oldest === undefined) break
249
+ map.delete(oldest)
250
+ }
251
+ }
252
+
253
+ /**
254
+ * 读取某路径的待处理同步标记。
255
+ * @author ddj 2026年09月15号
256
+ * @param scope 工作区作用域键
257
+ * @param path 文件路径
258
+ * @returns 标记;无 → null
259
+ */
260
+ export function readSync(scope: string, path: string): SyncFlag | null {
261
+ return syncState.get(scope)?.get(String(path).replace(/\\/g, '/')) ?? null
262
+ }
263
+
264
+ /**
265
+ * 清除某路径的同步标记(重新加载/保留本地/保存成功后调用)。
266
+ * @author ddj 2026年09月15号
267
+ * @param scope 工作区作用域键
268
+ * @param path 文件路径
269
+ */
270
+ export function clearSync(scope: string, path: string): void {
271
+ syncState.get(scope)?.delete(String(path).replace(/\\/g, '/'))
272
+ }
273
+
274
+ // --endregion
package/src/compat.ts CHANGED
@@ -133,7 +133,7 @@ export function detectGuards(ctx: Ctx): CompatAdapter[] {
133
133
  }
134
134
 
135
135
  /** 已实测覆盖的最高 DSH 版本(适配矩阵上界,超过则提示,见 buildReport)。 */
136
- const TESTED_DSH_MAX = '0.1.3-alpha.2'
136
+ const TESTED_DSH_MAX = '0.1.6-alpha.1'
137
137
 
138
138
  /** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略。 */
139
139
  export function versionAdapters(dshVersion: string): CompatAdapter[] {
package/src/dshVersion.ts CHANGED
@@ -86,6 +86,8 @@ export function inDshRange(version: DshVersion | null | undefined, range: DshRan
86
86
  export function familyLabel(input: string): string {
87
87
  const version = parseDshVersion(input)
88
88
  if (!version) return '未知'
89
+ if (inDshRange(version, { from: '0.1.6-alpha.1' })) return '0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)'
90
+ if (inDshRange(version, { from: '0.1.5-alpha.1' })) return '0.1.5-alpha 及更新(官方右侧 Sidebar 编辑区 + sidebar.panellist)'
89
91
  if (inDshRange(version, { from: '0.1.3-alpha.1' })) return '0.1.3-alpha 及更新(对话文件链接=remote.session.openWorkspacePath)'
90
92
  if (inDshRange(version, { from: '0.1.2-alpha.1' })) return '0.1.2-alpha 及更新(设置 API=settings.installSection)'
91
93
  return '0.1.0/0.1.1 rc 线(设置 API=installSettingsSection)'
@@ -101,21 +103,51 @@ const VERSION_CANDIDATES = [
101
103
 
102
104
  let detectedVersion: string | undefined
103
105
 
106
+ /**
107
+ * 宿主入口 require 锚点:process.argv[1] 指向运行中 DSH 的启动脚本
108
+ * (安装树 node_modules/@deepseek-ai/dsh/... 或 profile 树),从其所在目录
109
+ * 向上解析能命中宿主的 @deepseek-ai 包——避开本插件自身 node_modules 里的
110
+ * dev 依赖副本(dev-link 安装下 import.meta.url 锚点会先命中 rc 线副本,
111
+ * 曾致版本探测与 deps 加载错位到 0.1.0-rc.8,见 fileOpenSettings 的 hostImport)。
112
+ * @author ddj 2026年09月15号
113
+ * @returns 宿主锚点 require;argv[1] 缺失或不可用时 undefined
114
+ */
115
+ function hostRequire(): NodeRequire | undefined {
116
+ const entry = process.argv[1]
117
+ if (!entry) return undefined
118
+ try {
119
+ return createRequire(entry)
120
+ } catch {
121
+ return undefined
122
+ }
123
+ }
124
+
125
+ /** 用指定 resolver 解析一个候选包版本(解析/读取失败返回 null)。 */
126
+ function resolveVersionOf(specifier: string, resolver: NodeRequire): string | null {
127
+ try {
128
+ const file = resolver.resolve(specifier)
129
+ const pkg = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
130
+ if (typeof pkg.version === 'string' && parseDshVersion(pkg.version) !== null) return pkg.version
131
+ } catch {
132
+ /* exports 未放行或包缺失:尝试下一个候选 */
133
+ }
134
+ return null
135
+ }
136
+
104
137
  /** 探测运行中 DSH 核心版本(模块级缓存;失败空串,不抛错)。 */
105
138
  export function detectDshVersion(): string {
106
139
  if (detectedVersion !== undefined) return detectedVersion
107
140
  detectedVersion = ''
108
- const require = createRequire(import.meta.url)
109
- for (const specifier of VERSION_CANDIDATES) {
110
- try {
111
- const file = require.resolve(specifier)
112
- const pkg = JSON.parse(readFileSync(file, 'utf8')) as { version?: unknown }
113
- if (typeof pkg.version === 'string' && parseDshVersion(pkg.version) !== null) {
114
- detectedVersion = pkg.version
115
- break
141
+ // 宿主锚点优先(运行中 DSH 的 @deepseek-ai 树),失败回退本插件位置(测试/独立运行)。
142
+ const resolvers: Array<NodeRequire | undefined> = [hostRequire(), createRequire(import.meta.url)]
143
+ for (const resolver of resolvers) {
144
+ if (!resolver) continue
145
+ for (const specifier of VERSION_CANDIDATES) {
146
+ const version = resolveVersionOf(specifier, resolver)
147
+ if (version !== null) {
148
+ detectedVersion = version
149
+ return detectedVersion
116
150
  }
117
- } catch {
118
- /* exports 未放行或包缺失:尝试下一个候选 */
119
151
  }
120
152
  }
121
153
  return detectedVersion
@@ -7,6 +7,8 @@
7
7
  * 全程 try/catch,不产生未捕获 rejection。
8
8
  * 作者 ddj 2026年08月24号 / 2026年08月26号 / 2026年09月02号
9
9
  */
10
+ import { createRequire } from 'node:module'
11
+ import { pathToFileURL } from 'node:url'
10
12
  import type { Ctx } from './store.js'
11
13
  import { KEYBINDING_DEFAULTS } from './shared/keybindings.js'
12
14
  import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
@@ -47,16 +49,38 @@ export type SettingsDepsLoader = () => Promise<SettingsDeps | null>
47
49
 
48
50
  let depsPromise: Promise<SettingsDeps | null> | undefined
49
51
 
52
+ /**
53
+ * 宿主锚点动态导入:先经 process.argv[1](DSH 启动脚本所在树)解析并加载目标包,
54
+ * 避开本插件自身 node_modules 的 dev 依赖副本(dev-link 下 import() 相对插件位置
55
+ * 解析,会命中 rc 线旧包 dsh-settings@0.1.0-rc.8);argv[1] 缺失或解析失败回退
56
+ * 普通 specifier import。
57
+ * @author ddj 2026年09月15号
58
+ * @param specifier 包名
59
+ * @returns 加载的模块命名空间
60
+ */
61
+ async function hostImport(specifier: string): Promise<unknown> {
62
+ const entry = process.argv[1]
63
+ if (entry) {
64
+ try {
65
+ const resolved = createRequire(entry).resolve(specifier)
66
+ return await import(pathToFileURL(resolved).href)
67
+ } catch {
68
+ /* 锚点不可用:回退普通 import */
69
+ }
70
+ }
71
+ return import(specifier)
72
+ }
73
+
50
74
  /**
51
75
  * 动态加载设置依赖(模块级缓存;任一缺失/失败返回 null 而非抛错)。
52
76
  * installSettingsSection 仅在 rc 线 dsh-settings 中存在时提供(alpha 起移除,
53
77
  * 属性探测得到 undefined,不抛错)。
54
- * @author ddj 2026年08月24号
78
+ * @author ddj 2026年08月24号 / 2026年09月15号
55
79
  * @returns 设置依赖或 null
56
80
  */
57
81
  export function loadSettingsDeps(): Promise<SettingsDeps | null> {
58
82
  if (!depsPromise) {
59
- depsPromise = Promise.all([import('@deepseek-ai/dsh-settings'), import('schemastery')])
83
+ depsPromise = Promise.all([hostImport('@deepseek-ai/dsh-settings'), hostImport('schemastery')])
60
84
  .then(([dshSettings, schemastery]) => ({
61
85
  // dsh-settings 类型声明随版本变化(rc.8 有 d.ts、alpha 已移除导出),统一经 unknown 松绑
62
86
  installSettingsSection: (dshSettings as unknown as { installSettingsSection?: SettingsDeps['installSettingsSection'] }).installSettingsSection,