dsh-vscode-mode 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -45,12 +45,13 @@
45
45
  "@vscode/ripgrep": "^1.18.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
49
- "@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.2.0-0",
50
- "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.1 <0.2.0-0",
51
- "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.1 <0.2.0-0",
52
- "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
53
- "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0",
48
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
49
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
50
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
51
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
52
+ "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
53
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0",
54
+ "@deepseek-ai/schemastery": ">=3.18.0 <4",
54
55
  "cordis": ">=4.0.0-rc <5",
55
56
  "react": "^18.2.0",
56
57
  "react-dom": "^18.2.0",
@@ -38,6 +38,17 @@ let inFlight = null
38
38
  let debounceTimer = null
39
39
  const cache = new Map()
40
40
 
41
+ /**
42
+ * 补全 provider 注销器的全局挂点名。
43
+ *
44
+ * 为什么挂 window 而不是只靠模块级 registered:DSH 0.1.6-alpha.2 起支持插件运行时
45
+ * 卸载/重载,而 `window.monaco` 由 loader 注入后**跨重载存活**,模块级状态却会随
46
+ * bundle 重新求值而复位 —— 只判 registered 会在每次重载后再注册一次 provider,
47
+ * 导致同一编辑器出现重复补全。故注销器落 window,重载时命中即跳过注册。
48
+ * @author ddj 2026年09月18号
49
+ */
50
+ const AI_INLINE_GLOBAL = '__edrvAiInlineDisposer__'
51
+
41
52
  /** 上次开关状态(configUpdate 后经事件刷新,关闭时立即静默)。 */
42
53
  let enabled = false
43
54
 
@@ -96,8 +107,15 @@ function diffPending(editor) {
96
107
  */
97
108
  export function registerAiInline(monaco) {
98
109
  if (registered || !monaco?.languages?.registerInlineCompletionsProvider) return
110
+ // 跨重载守卫:上一代 bundle 注册的 provider 仍挂在存活的 window.monaco 上,
111
+ // 此时直接认领为「已注册」,避免重复注册(注销器在首次注册时已落 window)。
112
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
113
+ if (host && host[AI_INLINE_GLOBAL]) {
114
+ registered = true
115
+ return
116
+ }
99
117
  registered = true
100
- monaco.languages.registerInlineCompletionsProvider('*', {
118
+ const inlineDisposer = monaco.languages.registerInlineCompletionsProvider('*', {
101
119
  async provideInlineCompletions(model, position, context, token) {
102
120
  const t0 = Date.now()
103
121
  if (!enabled || token?.isCancellationRequested) return { items: [] }
@@ -166,6 +184,29 @@ export function registerAiInline(monaco) {
166
184
  },
167
185
  freeInlineCompletions() { /* 无资源需释放 */ },
168
186
  })
187
+ if (host) host[AI_INLINE_GLOBAL] = inlineDisposer
188
+ }
189
+
190
+ /**
191
+ * 卸载 AI 内联补全:注销 provider 并复位模块状态(插件重载/卸载时调用)。
192
+ *
193
+ * 复位 registered 让重装后能重新注册;清 window 挂点避免新实例被旧注销器误判为「已注册」。
194
+ * 同时作废在飞请求与去抖定时器,防止卸载后仍有回调写状态。
195
+ * @author ddj 2026年09月18号
196
+ */
197
+ export function disposeAiInline() {
198
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
199
+ const inlineDisposer = host ? host[AI_INLINE_GLOBAL] : undefined
200
+ if (inlineDisposer && typeof inlineDisposer.dispose === 'function') {
201
+ try { inlineDisposer.dispose() } catch { /* 已注销 */ }
202
+ }
203
+ if (host) delete host[AI_INLINE_GLOBAL]
204
+ registered = false
205
+ seq += 1 // 作废在飞响应
206
+ if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null }
207
+ if (inFlight) { try { inFlight.abort() } catch { /* 已结束 */ } inFlight = null }
208
+ cache.clear()
209
+ monacoRef = null
169
210
  }
170
211
 
171
212
  /** 缓存写入(超限整体清空,LRU 简化)。 */
@@ -51,7 +51,10 @@ import { createCommandBridge } from './commandBridge.js'
51
51
  import { REGISTRY_GLOBAL } from './commandGlobals.js'
52
52
  import { sidebarMinApply } from './sidebarMin.js'
53
53
  import { log } from './log.js'
54
- import { setupLsp, setSession } from './monaco/lsp/index.js'
54
+ import { setupLsp, setSession, disposeLsp } from './monaco/lsp/index.js'
55
+ import { disposeSnippets } from './snippets/provider.js'
56
+ import { disposeAiInline } from './ai/inlineProvider.js'
57
+ import { readSessionScope, subscribeScope } from './sessionScope.js'
55
58
  import type { CompatAdapter } from '../shared/compat.js'
56
59
 
57
60
  // ⚠️ inject 只列必需服务:webUiSettings 是 @linxin666/dsh-client-ui-web-ui-settings 提供的
@@ -105,52 +108,48 @@ export function apply(ctx: any): void {
105
108
  // 外部深链落地:Windows 右键菜单 / Unity 外部编辑器 → 浏览器 URL 参数 → 打开规则路由
106
109
  setupExtOpen(ctx)
107
110
 
108
- // Monaco 加载时机:不再 DSH 启动即预热,改为进入会话界面(sessions.list.current 出现)后再后台加载,
111
+ // Monaco 加载时机:不再 DSH 启动即预热,改为进入会话界面(当前会话出现)后再后台加载,
109
112
  // 用户点开「文件编辑」页签即用;空闲时仍由 EditorView 挂载兜底加载。
110
113
  // 模块级 promise 去重,重复触发只首次真正加载;requestIdleCallback 让出会话首屏带宽,缺省回退延时调度;
111
114
  // 预热失败静默吞掉(loader 失败会重置 promise,页签打开时仍走原有加载/重试路径)。
112
- const monacoList = sessions?.list
113
- if (typeof window !== 'undefined' && monacoList && typeof monacoList.subscribe === 'function') {
114
- const list = monacoList as { getSnapshot: () => { current?: unknown }; subscribe: (listener: () => void) => () => void }
115
+ //
116
+ // 会话取值经 readSessionScope 收敛(跨版本三级链,见 sessionScope.ts):
117
+ // DSH 0.1.6-alpha.2 随多实例共存移除了 sessions.list.current,旧版则没有 uiSession;
118
+ // 原先直读 list.getSnapshot().current 在新版恒为 undefined,会让预热/LSP 同步/编辑 Tab 恢复静默失效。
119
+ // 会话切换 → LSP 文档归属更新 + 状态刷新(原先为两条订阅,取值源一致,此处合流为一条)。
120
+ if (typeof window !== 'undefined') {
115
121
  const schedulePreload = () => {
116
122
  const preload = () => loadMonaco(() => {}).then((m: unknown) => setupLsp(m)).catch(() => {})
117
123
  if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(preload, { timeout: 2000 })
118
124
  else schedule(preload, 300)
119
125
  }
120
- const onSessionEnter = () => {
121
- const current = list.getSnapshot()?.current
122
- if (!current) return
123
- setSession(current)
126
+ let preloadedFor: string | undefined
127
+ const onScopeChange = () => {
128
+ const scope = readSessionScope(ctx)
129
+ // 传 undefined = 清除绑定(保留原 LSP 同步语义:无当前会话时不残留旧归属)
130
+ setSession(scope.sessionId)
131
+ if (!scope.sessionId || scope.sessionId === preloadedFor) return
132
+ preloadedFor = scope.sessionId
124
133
  schedulePreload()
125
134
  }
126
- ctx.effect(() => list.subscribe(onSessionEnter), 'vscode-mode: monaco session trigger')
127
- onSessionEnter()
128
- }
129
- // 会话切换 → LSP 文档归属更新 + 状态刷新
130
- if (monacoList && typeof monacoList.subscribe === 'function') {
131
- const list = monacoList as { getSnapshot: () => { current?: string }; subscribe: (listener: () => void) => () => void }
132
- ctx.effect(() => list.subscribe(() => {
133
- setSession(list.getSnapshot()?.current)
134
- }), 'vscode-mode: lsp session sync')
135
+ ctx.effect(() => subscribeScope(ctx, onScopeChange), 'vscode-mode: monaco session trigger')
136
+ onScopeChange()
135
137
  }
136
138
  // 会话切换 → 编辑 Tab 跨会话自动恢复:上一会话编辑区激活时,新会话自动展开并激活
137
139
  //(官方停靠面按会话隔离,新会话默认收起;先试 openTabIn 直写新会话面,未 adopt 走有界重试)。
138
- // 注意:首次打开某会话时通知早于 current 字段落定,故通知只调度延迟任务,执行时再读最新快照
139
- if (monacoList && typeof monacoList.subscribe === 'function') {
140
- const list = monacoList as { getSnapshot: () => { current?: string }; subscribe: (listener: () => void) => () => void }
141
- ctx.effect(() => {
142
- let lastCurrent = list.getSnapshot?.()?.current
143
- const check = () => {
144
- const current = list.getSnapshot?.()?.current
145
- if (!current || current === lastCurrent) return
146
- lastCurrent = current
147
- if (!isEditorTabActive() || officialService === undefined) return
148
- try { officialService.service.openTabIn?.(current, OFFICIAL_TAB_KIND, {}) } catch { /* 未 adopt 等 seat 就绪走重试 */ }
149
- restoreEditorTab(officialService.service, schedule)
150
- }
151
- return list.subscribe(() => schedule(check, 0))
152
- }, 'vscode-mode: editor tab restore')
153
- }
140
+ // 注意:首次打开某会话时通知早于会话字段落定,故通知只调度延迟任务,执行时再读最新作用域
141
+ ctx.effect(() => {
142
+ let lastCurrent = readSessionScope(ctx).sessionId
143
+ const check = () => {
144
+ const current = readSessionScope(ctx).sessionId
145
+ if (!current || current === lastCurrent) return
146
+ lastCurrent = current
147
+ if (!isEditorTabActive() || officialService === undefined) return
148
+ try { officialService.service.openTabIn?.(current, OFFICIAL_TAB_KIND, {}) } catch { /* 未 adopt 等 seat 就绪走重试 */ }
149
+ restoreEditorTab(officialService.service, schedule)
150
+ }
151
+ return subscribeScope(ctx, () => schedule(check, 0))
152
+ }, 'vscode-mode: editor tab restore')
154
153
  const originalOpenPath = workspaces?.openPath
155
154
  const binder = pickSettingsBinder(ctx)
156
155
  const settings = binder.scope
@@ -159,13 +158,8 @@ export function apply(ctx: any): void {
159
158
  let remoteOpenInstalled = false
160
159
  /** 两条文件链接路由共用的路由日志(openPathRouter / remoteOpenRouter;统一走插件日志器)。 */
161
160
  const routeLogger = (message: string): void => log.warn(message)
162
- /** 两条路由共用的 FileOpenContext:当前会话 id 与工作区 cwd。 */
163
- const openContext = (): FileOpenContext => {
164
- const current = sessions?.list?.getSnapshot?.()
165
- const sessionId = current?.current
166
- const summary = sessionId ? current?.byId?.[sessionId] : undefined
167
- return { sessionId, cwd: summary?.cwd } as FileOpenContext
168
- }
161
+ /** 两条路由共用的 FileOpenContext:当前会话 id 与工作区 cwd(跨版本取值链)。 */
162
+ const openContext = (): FileOpenContext => readSessionScope(ctx) as FileOpenContext
169
163
  /** 可选探测 betterSidebar 服务(不进 inject:缺失会让插件停靠等待,杀死回退路径)。 */
170
164
  let sideService = detectSidebarService(ctx)
171
165
  /** 可选探测官方右侧 Sidebar(DSH 0.1.5-alpha.1+;探测命中 ≡ 版本判定,不进 inject 同上)。 */
@@ -381,10 +375,9 @@ export function apply(ctx: any): void {
381
375
  service: sideService as NonNullable<typeof sideService>,
382
376
  renderTab: (props: Record<string, unknown>) => React.createElement(SideEditorTab, Object.assign({}, props, { schedule, addToConversation, sidebarPanels, outlineSources, fileMenuItems, sessions })),
383
377
  activeSession: () => {
384
- const snapshot = sessions?.list?.getSnapshot?.() as { current?: string; byId?: Record<string, { cwd?: string }> } | undefined
385
- const sessionId = snapshot?.current
386
- if (!sessionId) return undefined
387
- return { sessionId, cwd: snapshot?.byId?.[sessionId]?.cwd }
378
+ const scope = readSessionScope(ctx)
379
+ if (!scope.sessionId) return undefined
380
+ return { sessionId: scope.sessionId, cwd: scope.cwd }
388
381
  },
389
382
  registerLegacyFallback: registerLegacyTab,
390
383
  }), 'vscode-mode: sidebar editor tab')
@@ -445,4 +438,13 @@ export function apply(ctx: any): void {
445
438
  order: 30,
446
439
  label: 'VSCodeMode',
447
440
  }, () => React.createElement(SettingsContext.Provider, { value: settings }, React.createElement(McpSettings, { openerRegistry: registry, compatSummary })))
441
+
442
+ // 卸载收尾(G4,DSH 0.1.6-alpha.2 起支持运行时卸载/重载):
443
+ // 注销挂在存活的 window.monaco 上的全部 Monaco provider 并复位模块状态。
444
+ // 不做会导致重载后重复注册(补全/跳转/hover 各翻倍)与陈旧开关残留。
445
+ ctx.effect(() => () => {
446
+ try { disposeSnippets() } catch { /* 卸载异常不得阻断其余清理 */ }
447
+ try { disposeAiInline() } catch { /* 同上 */ }
448
+ try { disposeLsp() } catch { /* 同上 */ }
449
+ }, 'vscode-mode: monaco providers teardown')
448
450
  }
@@ -5,20 +5,33 @@
5
5
  * setSession(id):会话切换时同步(provider 与文档同步共用)。
6
6
  * 作者 ddj 2026-08-27
7
7
  */
8
- import { registerLspProviders, hideReferencesOverlay } from './providers.js'
8
+ import { registerLspProviders, disposeLspProviders, hideReferencesOverlay } from './providers.js'
9
9
  import { setLspSession, refreshStatus, lspStatusFor, onLspProgress, bindLspSession } from './lspClient.js'
10
10
 
11
11
  let monacoRef = null
12
12
  let sessionBound = false
13
+ /** 会话广播取消订阅函数(卸载时调用;落 window 以便跨重载清理上一代监听)。 */
14
+ let sessionUnbind = null
15
+
16
+ /** 会话广播订阅标记的全局挂点名(跨插件重载认领;见 disposeLsp)。 */
17
+ const LSP_SESSION_GLOBAL = '__edrvLspSessionUnbind__'
13
18
 
14
19
  /** Monaco 加载后装配(幂等;重复调用仅刷新会话)。 */
15
20
  export function setupLsp(monaco) {
16
21
  monacoRef = monaco
17
22
  registerLspProviders(monaco)
18
- // 会话广播订阅只装一次(与 Monaco 是否已加载无关)
23
+ // 会话广播订阅只装一次:跨重载时上一代已订阅(取消函数落 window,模块级状态会复位),
24
+ // 只判 sessionBound 会重复订阅 → 同一 LSP 会话事件被处理多次。
25
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
26
+ if (host && typeof host[LSP_SESSION_GLOBAL] === 'function') {
27
+ sessionBound = true
28
+ sessionUnbind = host[LSP_SESSION_GLOBAL]
29
+ return
30
+ }
19
31
  if (!sessionBound) {
20
32
  sessionBound = true
21
- bindLspSession()
33
+ sessionUnbind = bindLspSession()
34
+ if (host) host[LSP_SESSION_GLOBAL] = sessionUnbind
22
35
  }
23
36
  }
24
37
 
@@ -33,4 +46,21 @@ export function disposeLspOverlay() {
33
46
  hideReferencesOverlay()
34
47
  }
35
48
 
49
+ /**
50
+ * 卸载 LSP 装配(插件重载/卸载时调用):注销全部 provider、解绑会话广播并复位标记。
51
+ * @author ddj 2026年09月18号
52
+ */
53
+ export function disposeLsp() {
54
+ disposeLspProviders()
55
+ const unbind = sessionUnbind
56
+ if (typeof unbind === 'function') {
57
+ try { unbind() } catch { /* 已解绑 */ }
58
+ }
59
+ sessionUnbind = null
60
+ sessionBound = false
61
+ monacoRef = null
62
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
63
+ if (host) delete host[LSP_SESSION_GLOBAL]
64
+ }
65
+
36
66
  export { lspStatusFor, refreshStatus, onLspProgress }
@@ -54,7 +54,9 @@ export function setLspSession(id) {
54
54
  /**
55
55
  * 订阅窗口会话广播(幂等):EditorView 是 sessionId 的权威来源,挂载与会话切换时广播。
56
56
  * 独立于服务订阅,避免服务形状变化时 LSP 整体失联。
57
- * @author ddj 2026年09月11号
57
+ *
58
+ * 返回值是可重入的取消订阅函数;调用方应保存并在卸载时调用(否则重载后会累积监听)。
59
+ * @author ddj 2026年09月11号 / 2026年09月18号
58
60
  * @returns 取消订阅函数
59
61
  */
60
62
  export function bindLspSession() {
@@ -23,6 +23,9 @@ const SEMANTIC_LEGEND = {
23
23
  let registered = false
24
24
  const disposables = []
25
25
 
26
+ /** LSP provider 注册标记的全局挂点名(跨插件重载认领;见 disposeLspProviders)。 */
27
+ const LSP_PROVIDERS_GLOBAL = '__edrvLspProvidersRegistered__'
28
+
26
29
  /** 目标文件打开并定位(复用现有 openFileAt 的 edrv:open-editor 事件通道)。 */
27
30
  function openAt(path, target) {
28
31
  window.dispatchEvent(new CustomEvent('edrv:open-editor', { detail: Object.assign({ path }, target) }))
@@ -56,6 +59,12 @@ export function navTargetOf(selectionOrPosition) {
56
59
  /** 注册全部 Monaco LSP provider 与文档跟踪(幂等)。 */
57
60
  export function registerLspProviders(monaco) {
58
61
  if (registered) return
62
+ // 跨重载守卫:window.monaco 存活的上一代已注册过整套 provider(标记落 window)
63
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
64
+ if (host && host[LSP_PROVIDERS_GLOBAL]) {
65
+ registered = true
66
+ return
67
+ }
59
68
  registered = true
60
69
 
61
70
  // —— 文档跟踪:model 生命周期 → host 文档同步(含未保存编辑)——
@@ -152,6 +161,29 @@ export function registerLspProviders(monaco) {
152
161
  releaseDocumentSemanticTokens: () => {},
153
162
  }),
154
163
  )
164
+ // 注销器落 window:window.monaco 跨插件重载存活,模块级 registered 会复位,
165
+ // 只判 registered 会在重载后重复注册全部 LSP provider(见 disposeLspProviders)。
166
+ if (host) host[LSP_PROVIDERS_GLOBAL] = true
167
+ }
168
+
169
+ /**
170
+ * 卸载 LSP provider 集合:注销全部注册并复位状态(插件重载/卸载时调用)。
171
+ *
172
+ * 必须存在的原因:DSH 0.1.6-alpha.2 起支持插件运行时卸载/重载,而 `window.monaco`
173
+ * 由 loader 注入后**跨重载存活**;模块级 `registered`/`disposables` 却随 bundle 重新
174
+ * 求值清空 —— 不注销则每次重载都重复叠加一整套 provider(跳转/hover/语义高亮翻倍)。
175
+ * @author ddj 2026年09月18号
176
+ */
177
+ export function disposeLspProviders() {
178
+ for (const dispose of disposables.splice(0)) {
179
+ try {
180
+ if (typeof dispose === 'function') dispose()
181
+ else if (dispose && typeof dispose.dispose === 'function') dispose.dispose()
182
+ } catch { /* 单个注销异常不影响其余清理 */ }
183
+ }
184
+ registered = false
185
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
186
+ if (host) delete host[LSP_PROVIDERS_GLOBAL]
155
187
  }
156
188
 
157
189
  /** LSP SymbolInfo(host 归一化后)→ Monaco DocumentSymbol。 */
@@ -33,6 +33,63 @@ export const OFFICIAL_FILE_TAB_ID = 'dsh-vscode-mode/file'
33
33
  /** file 资源类型的 kind(与页类型 edrvEditor 区分:同 kind 只允许一份 extension)。 */
34
34
  export const OFFICIAL_FILE_KIND = 'edrvEditorFile'
35
35
 
36
+ /**
37
+ * Office 文档后缀:官方 `dsh-client-ui-sidebar-documentpreview` 的 Office 渲染器
38
+ * 声明 `doc/docx/xls/xlsx/ppt/pptx`,并在 alpha.2 起提供侧栏 Office 预览。
39
+ * 本插件让位官方(不认领),否则会把这些文件路由进 Monaco 而丢掉 Office 预览。
40
+ */
41
+ export const OFFICE_EXT: readonly string[] = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'pages', 'numbers']
42
+
43
+ /**
44
+ * 官方「不可预览二进制容器」清单(`UNVIEWABLE_BINARY_EXTENSIONS`,逐项取自
45
+ * `dsh-client-ui-sidebar-documentpreview/lib/client.js` 的 `UNVIEWABLE_BINARY_EXTENSIONS`)。
46
+ * 这些后缀官方会给出「无法预览」提示;本插件让位官方,避免把二进制当文本读成乱码。
47
+ *
48
+ * ⚠️ 刻意例外:官方清单含 `avif`,但本插件 {@link IMAGE_MIME} 支持 avif 图片预览
49
+ * (浏览器原生解码),故从本表**移除** `avif` —— 保留本插件的图片预览优于官方的「不可预览」。
50
+ * @author ddj 2026年09月18号
51
+ */
52
+ export const BLIND_EXT: readonly string[] = [
53
+ // 媒体
54
+ 'mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv', 'm4v', 'mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'wma', 'opus',
55
+ // 归档
56
+ 'zip', 'gz', 'tgz', 'bz2', 'xz', 'zst', '7z', 'rar', 'tar', 'jar',
57
+ // 可执行与库
58
+ 'exe', 'dll', 'so', 'dylib', 'bin', 'o', 'class', 'pyc', 'wasm',
59
+ // 字体
60
+ 'ttf', 'otf', 'woff', 'woff2', 'eot',
61
+ // 磁盘映像
62
+ 'dmg', 'iso', 'img',
63
+ // 数据库与设计文件
64
+ 'sqlite', 'db', 'psd', 'ai', 'sketch', 'tiff', 'tif', 'heic', 'heif',
65
+ ]
66
+
67
+ /** 让位判定用后缀集合(模块级构建一次;小写、无前导点)。 */
68
+ const DEFER_EXT_SET: Set<string> = new Set([...OFFICE_EXT, ...BLIND_EXT])
69
+
70
+ /**
71
+ * 取 path 的 basename 后缀(小写、无点);无后缀/隐藏文件返回 ''。
72
+ * @author ddj 2026年09月18号
73
+ * @param path 文件路径(`/` 或 `\` 分隔均可)
74
+ * @returns 小写后缀或 ''
75
+ */
76
+ function suffixOf(path: unknown): string {
77
+ const base = String(path ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
78
+ const dot = base.lastIndexOf('.')
79
+ return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
80
+ }
81
+
82
+ /**
83
+ * 该路径是否应让位官方查看器(Office 文档 + 官方不可预览的二进制容器)。
84
+ * 命中即不认领 `dsh-resource://file/**`,回落官方预览/提示。
85
+ * @author ddj 2026年09月18号
86
+ * @param path 文件路径(工作区相对或绝对)
87
+ * @returns 是否让位官方
88
+ */
89
+ export function deferToOfficial(path: unknown): boolean {
90
+ return DEFER_EXT_SET.has(suffixOf(path))
91
+ }
92
+
36
93
  /** 官方 Tab 打开参数(官方运行时不校验的 JSON,正文经 navigation.params 读回)。 */
37
94
  export interface OfficialTabParams {
38
95
  openPath?: string
@@ -297,7 +354,10 @@ export function installOfficial(options: {
297
354
  * 注册官方 file 资源地址认领(extension 档接管 `dsh-resource://file/**`,
298
355
  * 聊天文件链接/文件树打开改落本插件编辑器;注销后官方 textpreview 自动恢复)。
299
356
  * 认领与否由「文件链接使用工具」设置驱动(见 fileOpeners.shouldClaimFiles)。
300
- * @author ddj 2026年09月09号
357
+ *
358
+ * 让位规则(DSH 0.1.6-alpha.2 起):Office 文档与官方「不可预览」二进制容器不认领,
359
+ * 回落官方查看器(Office 侧栏预览 / 「无法预览」提示),见 {@link deferToOfficial}。
360
+ * @author ddj 2026年09月09号 / 2026年09月18号
301
361
  * @param options 装配参数(探测命中的注册表 + slots + 正文组件)
302
362
  * @returns 卸载器(反注册类型与正文);类型注册抛错返回 null(回落官方查看器)
303
363
  */
@@ -314,8 +374,12 @@ export function registerOfficialFileClaim(options: {
314
374
  kind: OFFICIAL_FILE_KIND,
315
375
  patterns: [OFFICIAL_FILE_PATTERN],
316
376
  priority: 'extension',
317
- // 畸形/解码失败地址否决:回落官方 textpreview,不产生白屏 Tab
318
- canOpen: (address: string) => parseOfficialFileAddress(address) !== null,
377
+ // 畸形/解码失败地址否决 + Office/不可预览后缀让位官方:回落官方查看器,不产生白屏 Tab
378
+ canOpen: (address: string) => {
379
+ const parsed = parseOfficialFileAddress(address)
380
+ if (parsed === null) return false
381
+ return !deferToOfficial(parsed.path)
382
+ },
319
383
  title: (address: string) => officialFileTitle(address),
320
384
  })
321
385
  } catch (error) {
@@ -0,0 +1,183 @@
1
+ /**
2
+ * dsh-vscode-mode client — 会话作用域解析(跨 DSH 版本适配)。
3
+ *
4
+ * 背景:DSH 0.1.6-alpha.2 随「客户端 Session 多实例共存」移除了 `sessions.list`
5
+ * 快照的 `current` / `currentAddress` 字段(alpha.1 及更早仍发布 `current`)。
6
+ * 官方替代面是 `ctx.uiSession.current`(`HostObservable<StandardSourceBinding>`,
7
+ * 其 `key` 即当前会话 id;绑定行由官方 ui-session 依 retainInfo.mainView 推出)。
8
+ *
9
+ * 本模块把「当前会话」收敛为单一三级取值链,并附可订阅的变更通知:
10
+ * 1. `uiSession.current.key`(0.1.6-alpha.2+ 权威)
11
+ * 2. `sessions.list.current`(rc 线 / 0.1.6-alpha.1 及更早)
12
+ * 3. `sessions.list.byId` 中 `retainedBy.mainView > 0` 的首行(新版兜底,与官方同判据)
13
+ * 4. 槽位注入的 sessionId(最后兜底)
14
+ *
15
+ * 约束:`uiSession` 不进 `inject`(旧版 DSH 无此服务,进 inject 会让插件停等),
16
+ * 一律 `ctx.get` 可选探测;任一来源缺失/异常都降级,不抛错、不阻断其他两级。
17
+ * 取值纯逻辑(pickSessionScope 等)可在 node 环境单测。
18
+ * 作者 ddj 2026年09月18号
19
+ */
20
+
21
+ /** 解析出的会话作用域:sessionId 可能缺失;cwd 仅在 list 快照可读时给出。 */
22
+ export interface SessionScope {
23
+ sessionId?: string
24
+ cwd?: string
25
+ }
26
+
27
+ /** 取值链的候选来源(三者都可缺失,缺失即跳过该级)。 */
28
+ export interface ScopeSources {
29
+ /** `ctx.uiSession.current` 快照(0.1.6-alpha.2+ 权威来源;binding.key = sessionId)。 */
30
+ uiSession?: unknown
31
+ /** `ctx.sessions.list` 快照(旧版含 `current`;新版只有 `ids`/`byId`)。 */
32
+ list?: unknown
33
+ /** 槽位注入的 sessionId(前几级都拿不到时的最后兜底)。 */
34
+ fallbackSessionId?: unknown
35
+ }
36
+
37
+ /** 对象窄化(非对象返回 undefined,供后续安全取字段)。 */
38
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
39
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
40
+ }
41
+
42
+ /** 非空字符串取值(其他类型一律视为缺失)。 */
43
+ function strOf(value: unknown): string | undefined {
44
+ return typeof value === 'string' && value ? value : undefined
45
+ }
46
+
47
+ /**
48
+ * 从 `uiSession.current` 快照读出 sessionId。
49
+ * 兼容两种形态:绑定源本身(`{ key }`)与其包裹形态(`{ value: { key } }`)——
50
+ * 官方 `createBindingSource` 的 `getSnapshot()` 返回绑定值本身,但跨版本包裹层存在差异,
51
+ * 故两者都尝试。
52
+ * @author ddj 2026年09月18号
53
+ * @param snapshot `uiSession.current` 的 `getSnapshot()` 结果(任意形状)
54
+ * @returns sessionId,或 undefined
55
+ */
56
+ export function uiSessionIdOf(snapshot: unknown): string | undefined {
57
+ const binding = asRecord(snapshot)
58
+ if (!binding) return undefined
59
+ return strOf(binding.key) ?? strOf(asRecord(binding.value)?.key)
60
+ }
61
+
62
+ /**
63
+ * 从 `sessions.list` 快照读出 sessionId。
64
+ * 先取旧版 `current`;缺失时回退 `byId` 中 `retainedBy.mainView > 0` 的首行
65
+ * (与官方 ui-session 的 publishMain 同判据,故新版行为与官方视图一致)。
66
+ * @author ddj 2026年09月18号
67
+ * @param list `sessions.list` 的 `getSnapshot()` 结果(任意形状)
68
+ * @returns sessionId,或 undefined
69
+ */
70
+ export function listSessionId(list: unknown): string | undefined {
71
+ const snap = asRecord(list)
72
+ if (!snap) return undefined
73
+ const current = strOf(snap.current)
74
+ if (current) return current
75
+ const byId = asRecord(snap.byId)
76
+ if (!byId) return undefined
77
+ for (const [id, row] of Object.entries(byId)) {
78
+ const mainView = asRecord(asRecord(row)?.retainedBy)?.mainView
79
+ if (typeof mainView === 'number' && mainView > 0) return id
80
+ }
81
+ return undefined
82
+ }
83
+
84
+ /**
85
+ * 三级取值链(纯函数):决定当前会话 id 与其 cwd。
86
+ * 优先级 uiSession > list.current > list.byId.retainedBy > fallback。
87
+ * @author ddj 2026年09月18号
88
+ * @param sources 候选来源集合
89
+ * @returns 会话作用域(字段缺失即省略该键,不写 undefined 占位)
90
+ */
91
+ export function pickSessionScope(sources: ScopeSources): SessionScope {
92
+ const sessionId = uiSessionIdOf(sources.uiSession)
93
+ ?? listSessionId(sources.list)
94
+ ?? strOf(sources.fallbackSessionId)
95
+ if (!sessionId) return {}
96
+ const byId = asRecord(asRecord(sources.list)?.byId)
97
+ const cwd = strOf(asRecord(byId?.[sessionId])?.cwd)
98
+ return cwd ? { sessionId, cwd } : { sessionId }
99
+ }
100
+
101
+ /** 读 `ctx.get(name)`(缺失/异常返回 undefined,不抛错)。 */
102
+ function safeGet(ctx: { get: (name: string) => unknown }, name: string): unknown {
103
+ try {
104
+ return ctx.get(name)
105
+ } catch {
106
+ return undefined
107
+ }
108
+ }
109
+
110
+ /** 取 `uiSession.current` 快照(服务或订阅面缺失时返回 undefined)。 */
111
+ function uiCurrentSnapshot(ctx: { get: (name: string) => unknown }): unknown {
112
+ const uiSession = asRecord(safeGet(ctx, 'uiSession'))
113
+ const current = asRecord(uiSession?.current)
114
+ const getSnapshot = current?.getSnapshot
115
+ if (typeof getSnapshot !== 'function') return undefined
116
+ try {
117
+ return (getSnapshot as () => unknown).call(current)
118
+ } catch {
119
+ return undefined
120
+ }
121
+ }
122
+
123
+ /** 取 `sessions.list` 快照(服务缺失时返回 undefined)。 */
124
+ function listSnapshot(ctx: { get: (name: string) => unknown }): unknown {
125
+ const sessions = asRecord(safeGet(ctx, 'sessions'))
126
+ const list = asRecord(sessions?.list)
127
+ const getSnapshot = list?.getSnapshot
128
+ if (typeof getSnapshot !== 'function') return undefined
129
+ try {
130
+ return (getSnapshot as () => unknown).call(list)
131
+ } catch {
132
+ return undefined
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 读当前会话作用域(服务面取值 + 三级链)。
138
+ * @author ddj 2026年09月18号
139
+ * @param ctx 客户端根上下文(只需 `get`)
140
+ * @param fallbackSessionId 槽位注入的 sessionId(可选)
141
+ * @returns 会话作用域
142
+ */
143
+ export function readSessionScope(ctx: { get: (name: string) => unknown }, fallbackSessionId?: unknown): SessionScope {
144
+ return pickSessionScope({
145
+ uiSession: uiCurrentSnapshot(ctx),
146
+ list: listSnapshot(ctx),
147
+ fallbackSessionId,
148
+ })
149
+ }
150
+
151
+ /**
152
+ * 订阅会话切换(同时挂 `uiSession.current` 与新/旧 `sessions.list`)。
153
+ * 旧版无 `uiSession`,新版 `list` 不再变(切会话只动 uiSession),故两条都要挂;
154
+ * 回调可能因两条源先后触发而重复,调用方按上一次 sessionId 自行去重。
155
+ * @author ddj 2026年09月18号
156
+ * @param ctx 客户端根上下文(只需 `get`)
157
+ * @param listener 变更回调
158
+ * @returns 取消订阅函数(幂等)
159
+ */
160
+ export function subscribeScope(ctx: { get: (name: string) => unknown }, listener: () => void): () => void {
161
+ const disposers: Array<() => void> = []
162
+ const attach = (owner: unknown): void => {
163
+ const subscribe = asRecord(owner)?.subscribe
164
+ if (typeof subscribe !== 'function') return
165
+ try {
166
+ const disposer = (subscribe as (fn: () => void) => unknown).call(owner, listener)
167
+ if (typeof disposer === 'function') disposers.push(disposer as () => void)
168
+ } catch {
169
+ /* 订阅面异常:放弃该源,不影响其他源 */
170
+ }
171
+ }
172
+ attach(asRecord(asRecord(safeGet(ctx, 'uiSession'))?.current))
173
+ attach(asRecord(asRecord(safeGet(ctx, 'sessions'))?.list))
174
+ return () => {
175
+ for (const disposer of disposers.splice(0)) {
176
+ try {
177
+ disposer()
178
+ } catch {
179
+ /* 卸载异常不影响其余清理 */
180
+ }
181
+ }
182
+ }
183
+ }