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.
@@ -23,6 +23,16 @@ let pending = null
23
23
  let registered = false
24
24
  let disposer = null
25
25
 
26
+ /**
27
+ * 片段 provider 注销器的全局挂点名。
28
+ *
29
+ * 为什么挂 window:`window.monaco` 由 loader 注入后**跨插件重载存活**,而模块级
30
+ * registered/disposer 会随 bundle 重新求值复位 —— 只判 registered 会在每次重载后
31
+ * 重复注册补全 provider(同一编辑器出现重复候补)。故注销器落 window 供跨代认领与清理。
32
+ * @author ddj 2026年09月18号
33
+ */
34
+ const SNIPPET_GLOBAL = '__edrvSnippetsDisposer__'
35
+
26
36
  /** 当前会话 id(补全按会话工作区叠加项目片段;EditorView 装配时注入)。 */
27
37
  let sessionId = null
28
38
 
@@ -102,6 +112,12 @@ export function entriesForLanguage(entries, languageId) {
102
112
  */
103
113
  export function registerSnippetProvider(monaco) {
104
114
  if (registered || !monaco?.languages?.registerCompletionItemProvider) return
115
+ // 跨重载守卫:上一代 bundle 注册的 provider 仍在存活 window.monaco 上(注销器已落 window)
116
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
117
+ if (host && host[SNIPPET_GLOBAL]) {
118
+ registered = true
119
+ return
120
+ }
105
121
  const snippetKind = monaco.languages.CompletionItemKind?.Snippet
106
122
  const asSnippet = monaco.languages.CompletionItemInsertTextRule?.InsertAsSnippet
107
123
  // 缺少片段枚举(精简版 Monaco)时不注册:否则候选项会退化为纯文本插入,误导用户
@@ -135,6 +151,8 @@ export function registerSnippetProvider(monaco) {
135
151
  }
136
152
  },
137
153
  })
154
+ // 注销器落 window:跨重载认领(见 SNIPPET_GLOBAL 说明)
155
+ if (host) host[SNIPPET_GLOBAL] = disposer
138
156
  }
139
157
 
140
158
  /**
@@ -147,13 +165,17 @@ export function setupSnippets(monaco) {
147
165
  }
148
166
 
149
167
  /**
150
- * 卸载:注销 provider(插件热重载/卸载时调用)。
151
- * @author ddj 2026年09月10号
168
+ * 卸载:注销 provider 并复位状态(插件热重载/卸载时调用)。
169
+ * 兼容上一代 bundle 遗留的 window 注销器(模块级 disposer 为空时仍能清干净)。
170
+ * @author ddj 2026年09月10号 / 2026年09月18号
152
171
  */
153
172
  export function disposeSnippets() {
154
- if (typeof disposer === 'function') {
155
- try { disposer.dispose?.() } catch { /* 已注销 */ }
173
+ const host = /* @__PURE__ */ (typeof window === 'undefined' ? undefined : window)
174
+ const target = (typeof disposer === 'function' ? disposer : null) ?? (host ? host[SNIPPET_GLOBAL] : null)
175
+ if (target && typeof target.dispose === 'function') {
176
+ try { target.dispose() } catch { /* 已注销 */ }
156
177
  }
178
+ if (host) delete host[SNIPPET_GLOBAL]
157
179
  disposer = null
158
180
  registered = false
159
181
  invalidateSnippets()
@@ -184,23 +184,48 @@ function isPinned(tab: TabLike): boolean {
184
184
  /**
185
185
  * 归一化持久化的页签数据:兼容旧版 `string[]`、去重、固定分区。
186
186
  * 损坏项(非字符串/非对象/无 path)直接丢弃。
187
- * @author ddj 2026年09月11号
187
+ *
188
+ * G9:传入 cwd 时把每个路径收敛为**页签规范形态**(工作区相对路径),
189
+ * 以迁移历史持久化数据里残留的绝对路径(见 {@link tabPathOf})。
190
+ * @author ddj 2026年09月11号 / 2026年09月18号
188
191
  * @param raw localStorage 解析结果(任意形状)
192
+ * @param cwd 会话工作区目录(可选;给出时同步归一化路径形态)
189
193
  * @returns 归一化后的页签数组
190
194
  */
191
- export function normalizeTabs(raw: unknown): TabLike[] {
195
+ export function normalizeTabs(raw: unknown, cwd?: string | null): TabLike[] {
192
196
  if (!Array.isArray(raw)) return []
193
197
  const seen = new Set<string>()
194
198
  const out: TabLike[] = []
195
199
  for (const item of raw) {
196
200
  const tab = tabOf(item)
197
- if (!tab || seen.has(tab.path)) continue
198
- seen.add(tab.path)
199
- out.push(tab)
201
+ if (!tab) continue
202
+ const path = tabPathOf(tab.path, cwd)
203
+ if (!path || seen.has(path)) continue
204
+ seen.add(path)
205
+ out.push(tab.pinned === true ? { path, pinned: true } : { path })
200
206
  }
201
207
  return pinnedFirst(out)
202
208
  }
203
209
 
210
+ /**
211
+ * 页签规范形态 = 工作区相对路径(G9)。
212
+ *
213
+ * 为什么需要:差异记录路径取自工具结果 `target.displayPath`(官方恒为绝对拼写),
214
+ * 而资源管理器树给出的是工作区相对路径。两者进页签后,地址栏形态不一致,且
215
+ * `insertTab` 按原串去重 → 同一文件可能出两个页签;绝对路径还会被
216
+ * `isTreeRevealable` 判为不可定位,「在资源管理器视图中显示」对差异入口失效。
217
+ * 统一经 {@link relativeOf} 收敛后,页签/地址栏/去重/持久化/`sameFile` 口径一致。
218
+ *
219
+ * 工作区外文件(全局片段/规则)由 relativeOf 自然回退为原绝对路径,语义不变。
220
+ * @author ddj 2026年09月18号
221
+ * @param path 原始路径(绝对或相对,两种分隔符均可)
222
+ * @param cwd 会话工作区目录(可空;无 cwd 时仅做分隔符归一)
223
+ * @returns 页签规范路径
224
+ */
225
+ export function tabPathOf(path: string, cwd?: string | null): string {
226
+ return relativeOf(path, cwd)
227
+ }
228
+
204
229
  /**
205
230
  * 解释单条持久化页签:字符串 = 路径(旧版),对象取 path/pinned。
206
231
  * @author ddj 2026年09月11号
@@ -9,6 +9,7 @@
9
9
  import React from 'react'
10
10
  import { rpc } from '../rpc.js'
11
11
  import { setAiInlineEnabled } from '../ai/inlineProvider.js'
12
+ import { isDelistedModel } from '../../shared/ai.js'
12
13
 
13
14
  /**
14
15
  * AI 补全设置卡片(设置页「AI 补全」Tab 主体)。
@@ -79,9 +80,16 @@ export function AiSettings() {
79
80
  if (!cfg) return React.createElement('div', { className: 'vsm-mcp-empty' }, error || '正在读取 AI 补全配置…')
80
81
 
81
82
  const modelValue = cfg.provider && cfg.model ? cfg.provider + '/' + cfg.model : ''
83
+ // G7:配置指向已从官方默认列表下架的模型(DSH 0.1.6-alpha.2 移除 V4 Flash 系列)。
84
+ // 仅提示,不擅自改写用户已保存的配置(避免覆盖其意图);清空即回到自动路由。
85
+ const delisted = isDelistedModel(cfg.model)
82
86
  return React.createElement('section', { className: 'vsm-lsp-card' },
83
87
  React.createElement('h3', null, 'AI 自动补全(实验)'),
84
88
  React.createElement('p', { className: 'vsm-lsp-note' }, '编辑停顿后由模型生成内联建议(ghost text),Tab 接受,Alt+\\ 手动触发。每次补全是一次模型调用;建议选择非推理模型,且思考强度选「跟随默认」或最低档以获得更快响应。'),
89
+ (delisted
90
+ ? React.createElement('div', { className: 'vsm-mcp-error vsm-mcp-banner' },
91
+ '当前模型「' + cfg.model + '」已从 DSH 默认模型列表移除(0.1.6-alpha.2 起)。该模型可能不再可用;将「模型」改回「自动」即可使用第一个可用模型(现有配置不会被自动改动)。')
92
+ : null),
85
93
  React.createElement('div', { className: 'vsm-lsp-row' },
86
94
  React.createElement('button', { className: cfg.enabled ? 'vsm-primary' : '', disabled: busy, onClick: toggle },
87
95
  cfg.enabled ? '已开启(点击关闭)' : '已关闭(点击开启)'),
@@ -56,7 +56,7 @@ import { SnippetsPicker } from './SnippetsPicker.js'
56
56
  import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
57
57
  import {
58
58
  absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
59
- insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, togglePin,
59
+ insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
60
60
  } from '../tabActions.js'
61
61
  import { buildTabMenu } from '../tabMenu.js'
62
62
  import { ensureSvnChanges, ensureSvnStatus, getSvnChanges, getSvnStatus, refreshSvnChanges, svnAdd, svnChangeMapOf, svnDiffBase, svnEditorActions, svnRevert, svnTortoise, svnUpdate } from '../svnStatus.js'
@@ -307,6 +307,33 @@ export function EditorView(props) {
307
307
  if (select) setActive(path)
308
308
  }
309
309
 
310
+ /**
311
+ * 页签规范路径(G9):统一收敛为工作区相对路径后再进页签。
312
+ *
313
+ * 差异记录路径取自工具结果 `target.displayPath`(官方恒绝对),而资源管理器树给相对路径;
314
+ * 不归一会导致地址栏形态不一致、同文件出现两个页签(insertTab 按原串去重)、
315
+ * 且绝对路径被 isTreeRevealable 判为不可定位(「在资源管理器视图中显示」失效)。
316
+ * 工作区外文件由 relativeOf 回退原绝对路径,语义不变。
317
+ * @author ddj 2026年09月18号
318
+ * @param path 原始路径(绝对或相对)
319
+ * @returns 页签规范路径
320
+ */
321
+ const tabPath = (path) => tabPathOf(path, cwd)
322
+ /**
323
+ * 打开文件入口的统一收敛(G9):所有「进页签」路径都经此归一,避免逐点打补丁。
324
+ * @author ddj 2026年09月18号
325
+ * @param path 原始路径
326
+ * @param select 是否设为活动页签
327
+ * @returns 归一后的路径(空值返回 null)
328
+ */
329
+ const addTabNorm = (path, select) => {
330
+ if (!path) return null
331
+ const normalized = tabPath(path)
332
+ if (!normalized) return null
333
+ addTab(normalized, select)
334
+ return normalized
335
+ }
336
+
310
337
  /**
311
338
  * 保存当前活动文件的视图状态(光标/滚动/折叠)到工作区作用域缓存。
312
339
  * @author ddj 2026年08月28号
@@ -388,7 +415,7 @@ export function EditorView(props) {
388
415
  flushSave()
389
416
  saveViewState(active)
390
417
  navPendingRef.current = entry
391
- addTab(entry.path, true)
418
+ addTabNorm(entry.path, true)
392
419
  setFocusRequest((value) => value + 1) // 目标已是活动文件时也触发恢复
393
420
  }
394
421
 
@@ -832,20 +859,23 @@ export function EditorView(props) {
832
859
  const onOpen = (e) => {
833
860
  const p = e?.detail?.path
834
861
  if (!p) return
862
+ // G9:入口统一归一为工作区相对路径(差异栏/对话链接/LSP 跳转等都经此事件)
863
+ const normalized = tabPath(p)
864
+ if (!normalized) return
835
865
  if (e?.detail?.focusDiff === true) {
836
866
  recordNav()
837
- pendingFocusRef.current = { path: p, region: null }
867
+ pendingFocusRef.current = { path: normalized, region: null }
838
868
  setFocusRequest((value) => value + 1)
839
- addTab(p, true)
869
+ addTab(normalized, true)
840
870
  return
841
871
  }
842
872
  if (e?.detail?.line != null) {
843
873
  // LSP/搜索跳转:打开并定位到行列(endLine/endColumn 为目标区间,供落地高亮)
844
- openFileAt(p, e?.detail?.line, e?.detail?.column, e?.detail?.endLine, e?.detail?.endColumn)
874
+ openFileAt(normalized, e?.detail?.line, e?.detail?.column, e?.detail?.endLine, e?.detail?.endColumn)
845
875
  return
846
876
  }
847
877
  recordNav()
848
- addTab(p, true)
878
+ addTab(normalized, true)
849
879
  }
850
880
  const onShowLauncher = (event) => {
851
881
  const tab = event?.detail?.tab
@@ -939,10 +969,13 @@ export function EditorView(props) {
939
969
  ?? localStorage.getItem(CACHE_KEY.editorLegacy + String(scope))
940
970
  if (raw) {
941
971
  const saved = JSON.parse(raw)
942
- const restored = normalizeTabs(saved?.tabs)
972
+ // G9:迁移历史持久化里的绝对路径页签(旧版差异入口写入),并顺带按新形态去重
973
+ const restored = normalizeTabs(saved?.tabs, cwd)
943
974
  if (restored.length) {
944
975
  setTabs(restored)
945
- setActive(pickActive(restored, saved?.active))
976
+ // 活动路径同形态归一,否则恢复后匹配不到任何页签(pickActive 回退首个)
977
+ const wanted = typeof saved?.active === 'string' ? tabPathOf(saved.active, cwd) : saved?.active
978
+ setActive(pickActive(restored, wanted))
946
979
  }
947
980
  }
948
981
  } catch (e) { /* 损坏忽略 */ }
@@ -2495,8 +2528,9 @@ export function EditorView(props) {
2495
2528
  // 打开文件即离开基线差异审阅态(差异视图是「当前文件」的临时视图)
2496
2529
  setSvnDiff(null)
2497
2530
  recordNav()
2498
- addTab(path, true)
2499
- if (focusDiff) pendingFocusRef.current = { path, region: null }
2531
+ // G9:统一归一为页签规范形态(差异栏/启动器/树/命令栏等入口一致)
2532
+ const normalized = addTabNorm(path, true)
2533
+ if (focusDiff && normalized) pendingFocusRef.current = { path: normalized, region: null }
2500
2534
  }
2501
2535
 
2502
2536
  /**
@@ -2512,8 +2546,10 @@ export function EditorView(props) {
2512
2546
  const openFileAt = (path, line, column, endLine, endColumn) => {
2513
2547
  if (!path) return
2514
2548
  recordNav()
2515
- addTab(path, true)
2516
- pendingFocusRef.current = { path, region: null, line: line ?? null, column: column ?? 1, endLine: endLine ?? null, endColumn: endColumn ?? null }
2549
+ // G9:同 openFile,先归一再进页签,保证待跳转路径与 active 同形态
2550
+ const normalized = addTabNorm(path, true)
2551
+ if (!normalized) return
2552
+ pendingFocusRef.current = { path: normalized, region: null, line: line ?? null, column: column ?? 1, endLine: endLine ?? null, endColumn: endColumn ?? null }
2517
2553
  setFocusRequest((value) => value + 1)
2518
2554
  }
2519
2555
 
@@ -2683,7 +2719,9 @@ export function EditorView(props) {
2683
2719
  }, '保留本地')))
2684
2720
  }
2685
2721
 
2686
- const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
2722
+ // G9:记录路径为绝对、active 为页签规范形态(相对),必须经 sameFile 归一比较,
2723
+ // 否则当前活动文件会被误列入「其他差异文件」。
2724
+ const otherFiles = sum.pendingFiles.filter((f) => !sameFile(f.path, active))
2687
2725
 
2688
2726
  /**
2689
2727
  * 渲染编辑器/文件加载进度面板。
package/src/compat.ts CHANGED
@@ -10,7 +10,7 @@ import { readFileSync } from 'node:fs'
10
10
  import { dirname, join } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { entriesOf } from './mcp.js'
13
- import { loadSettingsDeps, settingsInstallNote, settingsInstallStrategy } from './fileOpenSettings.js'
13
+ import { loadSettingsDeps, schemaLibName, settingsInstallNote, settingsInstallStrategy } from './fileOpenSettings.js'
14
14
  import { readDevForm } from './devForm.js'
15
15
  import { compareDshVersions, detectDshVersion, familyLabel, parseDshVersion } from './dshVersion.js'
16
16
  import { SKILL_PREFIXES, skillGroupState } from './skills.js'
@@ -59,7 +59,7 @@ export function detectExternal(ctx: Ctx, depsAvailable: boolean): CompatAdapter[
59
59
  const skills = skillGroupState()
60
60
  return [
61
61
  { name: MCP_PACKAGE, active: mcpCount > 0, note: mcpCount > 0 ? mcpCount + ' 个 MCP 服务条目' : '未检测到 MCP 条目(MCP 管理页显示为空)' },
62
- { name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装' : '未安装:fileOpenTool 持久化降级为配置值' },
62
+ { name: '设置持久化(@deepseek-ai/dsh-settings)', active: depsAvailable, note: depsAvailable ? '设置 section 已安装(schema: ' + (schemaLibName() || '未知') + ')' : '未安装:fileOpenTool 持久化降级为配置值' },
63
63
  { name: 'settings 服务', active: hasSettings, note: hasSettings ? '可读写设置' : '不可用(设置读写走配置回退)' },
64
64
  { name: '文件浏览器打开(subprocess 服务)', active: typeof sub?.spawn === 'function', note: typeof sub?.spawn === 'function' ? '可定位/打开 OS 文件浏览器' : '不可用(右键「在文件浏览器中打开」将提示失败)' },
65
65
  // 新增项一律追加在末尾:既有下标被 tests/compat.test.ts 断言,不得前插。
@@ -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.6-alpha.1'
136
+ const TESTED_DSH_MAX = '0.1.6-alpha.2'
137
137
 
138
138
  /** 版本适配机制状态行:DSH 版本探测 + 设置 section 安装策略。 */
139
139
  export function versionAdapters(dshVersion: string): CompatAdapter[] {
package/src/dshVersion.ts CHANGED
@@ -82,10 +82,17 @@ export function inDshRange(version: DshVersion | null | undefined, range: DshRan
82
82
  return true
83
83
  }
84
84
 
85
- /** 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。 */
85
+ /**
86
+ * 版本线标签:报告与文档展示版本归属;不可解析返回 '未知'。
87
+ * 逐线自新到旧匹配,先命中先返回(区间无上界,故顺序即优先级)。
88
+ * @author ddj 2026年09月02号 / 2026年09月18号
89
+ * @param input 版本串(如 '0.1.6-alpha.2')
90
+ * @returns 版本线标签
91
+ */
86
92
  export function familyLabel(input: string): string {
87
93
  const version = parseDshVersion(input)
88
94
  if (!version) return '未知'
95
+ if (inDshRange(version, { from: '0.1.6-alpha.2' })) return '0.1.6-alpha.2 及更新(会话多实例共存 + 回合改动卡片 + Office 侧栏预览 + 侧栏浏览器)'
89
96
  if (inDshRange(version, { from: '0.1.6-alpha.1' })) return '0.1.6-alpha 及更新(MCP SDK v2 + Web 侧边栏终端 + 文件链接默认侧栏预览)'
90
97
  if (inDshRange(version, { from: '0.1.5-alpha.1' })) return '0.1.5-alpha 及更新(官方右侧 Sidebar 编辑区 + sidebar.panellist)'
91
98
  if (inDshRange(version, { from: '0.1.3-alpha.1' })) return '0.1.3-alpha 及更新(对话文件链接=remote.session.openWorkspacePath)'
@@ -73,20 +73,96 @@ async function hostImport(specifier: string): Promise<unknown> {
73
73
  }
74
74
 
75
75
  /**
76
- * 动态加载设置依赖(模块级缓存;任一缺失/失败返回 null 而非抛错)。
77
- * installSettingsSection 仅在 rc 线 dsh-settings 中存在时提供(alpha 起移除,
78
- * 属性探测得到 undefined,不抛错)。
79
- * @author ddj 2026年08月24号 / 2026年09月15号
76
+ * schema 库候选名(新名在前)。
77
+ * DSH 官方自 0.1.5 起把 vendored 包改名为 @deepseek-ai/schemastery 并全树改用新名,
78
+ * 安装树里**没有**裸 schemastery;裸名保留给 rc 线(其 dsh-settings 仍 peers 旧名),
79
+ * 也让开发形态(插件 node_modules devDependency 副本)继续可用。
80
+ */
81
+ const SCHEMA_SPECIFIERS = ['@deepseek-ai/schemastery', 'schemastery'] as const
82
+
83
+ /** 设置持久化包名(installSettingsSection 仅 rc 线提供,alpha 线缺失属正常)。 */
84
+ const SETTINGS_SPECIFIER = '@deepseek-ai/dsh-settings'
85
+
86
+ /**
87
+ * 逐个尝试候选名,返回首个加载成功的模块及其包名(全失败返回 null,不抛错)。
88
+ * @author ddj 2026年09月18号
89
+ * @param specifiers 候选包名(按优先级)
90
+ * @param importFn 加载函数(测试注入)
91
+ * @returns 命中的模块与包名;全失败返回 null
92
+ */
93
+ async function firstImport(
94
+ specifiers: readonly string[],
95
+ importFn: (specifier: string) => Promise<unknown> = hostImport,
96
+ ): Promise<{ specifier: string; module: unknown } | null> {
97
+ for (const specifier of specifiers) {
98
+ try {
99
+ return { specifier, module: await importFn(specifier) }
100
+ } catch {
101
+ /* 该候选不可解析:尝试下一个 */
102
+ }
103
+ }
104
+ return null
105
+ }
106
+
107
+ /**
108
+ * 抹平 schema 库的 ESM/CJS 互操作形态取默认导出。
109
+ * 三种实测形态:真 ESM(`default` 即 z)、CJS 经 import()(`default` 与
110
+ * `module.exports` 同为 z)、双层包装(`default.default` 才是 z)。
111
+ * @author ddj 2026年09月18号
112
+ * @param module 加载到的模块命名空间(可空)
113
+ * @returns 具备 object/string 等构造器的 z;取不到返回 null
114
+ */
115
+ export function pickSchema(module: unknown): SettingsDeps['z'] | null {
116
+ const layers = [module, (module as { default?: unknown } | null)?.default, (module as Record<string, unknown> | null)?.['module.exports']]
117
+ for (const layer of layers) {
118
+ const z = ((layer as { default?: unknown } | null)?.default ?? layer) as SettingsDeps['z'] | undefined
119
+ if (z && typeof z.object === 'function' && typeof z.string === 'function') return z
120
+ }
121
+ return null
122
+ }
123
+
124
+ /** 实际命中的 schema 库名(供兼容性报告展示;未命中为空串)。 */
125
+ let schemaLib = ''
126
+
127
+ /**
128
+ * 读取实际命中的 schema 库名(报告文案用)。
129
+ * @author ddj 2026年09月18号
130
+ * @returns 包名;未解析到为空串
131
+ */
132
+ export function schemaLibName(): string {
133
+ return schemaLib
134
+ }
135
+
136
+ /** 复位依赖缓存与命中库名(测试隔离用)。 */
137
+ export function resetSettingsDeps(): void {
138
+ depsPromise = undefined
139
+ schemaLib = ''
140
+ }
141
+
142
+ /**
143
+ * 动态加载设置依赖(模块级缓存;schema 库缺失返回 null 而非抛错)。
144
+ * ⚠️ 两个依赖**独立解析**:@deepseek-ai/dsh-settings 仅 rc 线跑 legacy 策略时需要,
145
+ * alpha 线走 settings 服务 installSection 用不到它;原先 Promise.all 让该包缺失
146
+ * 拖垮整体 → 用户端(npm 安装)settings section 永不装配。
147
+ * 同理,schema 库按候选链解析(安装树只有新名 @deepseek-ai/schemastery)。
148
+ * @author ddj 2026年08月24号 / 2026年09月15号 / 2026年09月18号
149
+ * @param importFn 加载函数(测试注入;缺省宿主锚点动态导入)
80
150
  * @returns 设置依赖或 null
81
151
  */
82
- export function loadSettingsDeps(): Promise<SettingsDeps | null> {
152
+ export function loadSettingsDeps(importFn: (specifier: string) => Promise<unknown> = hostImport): Promise<SettingsDeps | null> {
83
153
  if (!depsPromise) {
84
- depsPromise = Promise.all([hostImport('@deepseek-ai/dsh-settings'), hostImport('schemastery')])
85
- .then(([dshSettings, schemastery]) => ({
154
+ depsPromise = Promise.all([firstImport([SETTINGS_SPECIFIER], importFn), firstImport(SCHEMA_SPECIFIERS, importFn)])
155
+ .then(([settingsHit, schemaHit]) => {
156
+ const z = pickSchema(schemaHit?.module)
157
+ if (!z) {
158
+ schemaLib = ''
159
+ return null
160
+ }
161
+ schemaLib = schemaHit?.specifier ?? ''
86
162
  // dsh-settings 类型声明随版本变化(rc.8 有 d.ts、alpha 已移除导出),统一经 unknown 松绑
87
- installSettingsSection: (dshSettings as unknown as { installSettingsSection?: SettingsDeps['installSettingsSection'] }).installSettingsSection,
88
- z: (schemastery as unknown as { default: SettingsDeps['z'] }).default,
89
- }))
163
+ const installSettingsSection = (settingsHit?.module as unknown as { installSettingsSection?: SettingsDeps['installSettingsSection'] } | undefined)?.installSettingsSection
164
+ return { installSettingsSection, z }
165
+ })
90
166
  .catch(() => null)
91
167
  }
92
168
  return depsPromise
package/src/lsp/extmgr.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  * 已装扩展作为 LSP provider 的"扩展源"(kind=extension),见 providers.ts。
6
6
  * 作者 ddj 2026-08-27
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
9
9
  import { basename, dirname, join } from 'node:path'
10
- import { unzip } from './zip.js'
10
+ import { unzip, type ZipEntry } from './zip.js'
11
11
  import { dshHome, PLUGIN_ID } from '../paths.js'
12
12
 
13
13
  /** 单个已装扩展信息(edrv.lsp.ext.list 载荷元素)。 */
@@ -57,9 +57,61 @@ export function vsixManifest(vsix: Buffer): Record<string, unknown> {
57
57
  throw new Error('VSIX 缺少 extension/package.json')
58
58
  }
59
59
 
60
+ /**
61
+ * 判定解包路径是否为可执行入口(无 mode 时的启发式兜底)。
62
+ * 只认确定语义:`bin/` 段下的文件、语言服务器/运行时常见可执行名、`.sh` 脚本。
63
+ * @author ddj 2026年09月18号
64
+ * @param rel 相对路径(/ 分隔)
65
+ * @returns 是否应补执行位
66
+ */
67
+ export function looksExec(rel: string): boolean {
68
+ const path = String(rel ?? '').replace(/\\/g, '/')
69
+ if (!path) return false
70
+ if (path.split('/').includes('bin')) return true
71
+ if (/\.(sh|bash|zsh)$/i.test(path)) return true
72
+ const name = path.split('/').pop() ?? ''
73
+ const base = name.replace(/\.exe$/i, '')
74
+ return ['lua-language-server', 'emmylua_ls', 'OmniSharp', 'dotnet', 'node'].includes(base)
75
+ }
76
+
77
+ /**
78
+ * 计算解包后要施加的权限位(纯函数,可单测)。
79
+ * 优先信任归档自带的 Unix mode 中的执行位(VS Code 自身解 VSIX 即此口径);
80
+ * 归档无 mode(Windows 打包的 VSIX)时按路径启发式补 0o755;
81
+ * 其余保持 undefined(交给 umask 默认,不越权改动)。
82
+ * @author ddj 2026年09月18号
83
+ * @param entry 解压条目
84
+ * @returns 目标权限位;无需处理返回 undefined
85
+ */
86
+ export function execModeOf(entry: ZipEntry): number | undefined {
87
+ if (entry.isDirectory) return undefined
88
+ const mode = entry.mode
89
+ if (mode !== undefined && (mode & 0o111) !== 0) return mode
90
+ return looksExec(entry.path) ? 0o755 : undefined
91
+ }
92
+
93
+ /**
94
+ * 落盘后补可执行位(POSIX 专属;Windows 无此概念,直接跳过)。
95
+ * 必须 best-effort:只读文件系统/无权限时不能拖垮整个扩展安装。
96
+ * @author ddj 2026年09月18号
97
+ * @param target 落盘绝对路径
98
+ * @param entry 解压条目
99
+ * @param platform 目标平台(测试注入)
100
+ */
101
+ export function applyExecBit(target: string, entry: ZipEntry, platform: NodeJS.Platform = process.platform): void {
102
+ if (platform === 'win32') return
103
+ const mode = execModeOf(entry)
104
+ if (mode === undefined) return
105
+ try {
106
+ chmodSync(target, mode)
107
+ } catch (error) {
108
+ /* 权限补不上不阻塞安装(后续 spawn 会给出真实错误) */
109
+ }
110
+ }
111
+
60
112
  /**
61
113
  * 解包 VSIX 到目录(仅取 extension/ 子树,剥前缀;返回清单)。
62
- * @author ddj 2026年08月27号
114
+ * @author ddj 2026年08月27号 / 2026年09月18号
63
115
  * @param vsix vsix 字节
64
116
  * @param dest 目标目录(已存在则先清空)
65
117
  * @returns 清单对象
@@ -80,6 +132,7 @@ export function unpackVsix(vsix: Buffer, dest: string): Record<string, unknown>
80
132
  }
81
133
  mkdirSync(dirname(target), { recursive: true })
82
134
  writeFileSync(target, entry.data)
135
+ applyExecBit(target, entry)
83
136
  if (rel === 'package.json') manifest = JSON.parse(entry.data.toString('utf8')) as Record<string, unknown>
84
137
  }
85
138
  if (!manifest) throw new Error('VSIX 解包后缺少 package.json')
package/src/lsp/zip.ts CHANGED
@@ -6,11 +6,13 @@
6
6
  */
7
7
  import { inflateRawSync } from 'node:zlib'
8
8
 
9
- /** 单条解压结果:相对路径 + 内容(目录条目 path 以 / 结尾)。 */
9
+ /** 单条解压结果:相对路径 + 内容(目录条目 path 以 / 结尾)+ Unix 权限位(可缺失)。 */
10
10
  export interface ZipEntry {
11
11
  path: string
12
12
  data: Buffer
13
13
  isDirectory: boolean
14
+ /** Unix 权限位(中央目录 external attributes 高 16 位;0 或非 Unix 归档时缺失)。 */
15
+ mode?: number
14
16
  }
15
17
 
16
18
  const EOCD_SIG = 0x06054b50
@@ -19,16 +21,16 @@ const LHDR_SIG = 0x04034b50
19
21
 
20
22
  /**
21
23
  * 解析 ZIP 缓冲区 → 条目列表(不解压内容,仅元数据)。
22
- * @author ddj 2026年08月27号
24
+ * @author ddj 2026年08月27号 / 2026年09月18号
23
25
  * @param buf zip 字节
24
- * @returns 条目元数据
26
+ * @returns 条目元数据(含 Unix mode,见 modeOf)
25
27
  */
26
- export function zipEntries(buf: Buffer): { name: string; method: number; compressed: number; uncompressed: number; localOffset: number }[] {
28
+ export function zipEntries(buf: Buffer): { name: string; method: number; compressed: number; uncompressed: number; localOffset: number; mode?: number }[] {
27
29
  const eocd = findEocd(buf)
28
30
  if (eocd < 0) throw new Error('不是有效的 ZIP 文件(找不到 EOCD)')
29
31
  const total = buf.readUInt16LE(eocd + 10)
30
32
  let offset = buf.readUInt32LE(eocd + 16)
31
- const out: { name: string; method: number; compressed: number; uncompressed: number; localOffset: number }[] = []
33
+ const out: { name: string; method: number; compressed: number; uncompressed: number; localOffset: number; mode?: number }[] = []
32
34
  for (let i = 0; i < total; i++) {
33
35
  if (buf.readUInt32LE(offset) !== CDIR_SIG) throw new Error('ZIP 中央目录损坏')
34
36
  const method = buf.readUInt16LE(offset + 10)
@@ -39,17 +41,31 @@ export function zipEntries(buf: Buffer): { name: string; method: number; compres
39
41
  const commentLen = buf.readUInt16LE(offset + 32)
40
42
  const localOffset = buf.readUInt32LE(offset + 42)
41
43
  const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString('utf8')
42
- out.push({ name, method, compressed, uncompressed, localOffset })
44
+ const mode = modeOf(buf.readUInt32LE(offset + 38))
45
+ out.push({ name, method, compressed, uncompressed, localOffset, ...(mode === undefined ? {} : { mode }) })
43
46
  offset += 46 + nameLen + extraLen + commentLen
44
47
  }
45
48
  return out
46
49
  }
47
50
 
51
+ /**
52
+ * 中央目录 external attributes → Unix 权限位。
53
+ * ZIP 把 Unix mode 放在 `st_mode << 16`(含 S_IFREG 等类型位),低 16 位仅 DOS 属性;
54
+ * 故必须右移 16 再取低 9 位权限。非 Unix 归档(Windows 打包)该字段为 0 或纯 DOS 位。
55
+ * @author ddj 2026年09月18号
56
+ * @param attributes 中央目录 offset+38 的 4 字节
57
+ * @returns 0o000~0o777 权限位;无有效 mode 返回 undefined
58
+ */
59
+ export function modeOf(attributes: number): number | undefined {
60
+ const mode = (attributes >>> 16) & 0o777
61
+ return mode === 0 ? undefined : mode
62
+ }
63
+
48
64
  /**
49
65
  * 解压 ZIP → 条目列表(目录条目含尾部 /)。
50
- * @author ddj 2026年08月27号
66
+ * @author ddj 2026年08月27号 / 2026年09月18号
51
67
  * @param buf zip 字节
52
- * @returns 条目(含内容)
68
+ * @returns 条目(含内容与可选 mode)
53
69
  */
54
70
  export function unzip(buf: Buffer): ZipEntry[] {
55
71
  const metas = zipEntries(buf)
@@ -66,7 +82,7 @@ export function unzip(buf: Buffer): ZipEntry[] {
66
82
  const raw = buf.subarray(dataStart, dataStart + meta.compressed)
67
83
  data = meta.method === 8 ? Buffer.from(inflateRawSync(raw)) : meta.method === 0 ? Buffer.from(raw) : Buffer.alloc(0)
68
84
  }
69
- out.push({ path: meta.name, data, isDirectory: isDir })
85
+ out.push({ path: meta.name, data, isDirectory: isDir, ...(meta.mode === undefined ? {} : { mode: meta.mode }) })
70
86
  }
71
87
  return out
72
88
  }
package/src/revert.ts CHANGED
@@ -11,9 +11,26 @@ import { applyLocations, locateHunks, preciseHunk } from './shared/diff.js'
11
11
  /** 回滚结果;stale=true 表示该 hunk 的新文本已不在文件中(无可回滚内容,不算失败)。 */
12
12
  export type Result = { ok: true } | { ok: false; error: string; stale?: boolean }
13
13
 
14
+ /**
15
+ * 删除单文件的 argv(纯函数,platform 可注入便于单测)。
16
+ * Windows 走 PowerShell Remove-Item;其余平台走 /bin/rm。
17
+ * 原先无条件先发 powershell:macOS/Linux 上必然 spawn 失败后才回落,
18
+ * 每删一个文件多一次无谓 spawn 与失败噪声。
19
+ * @author ddj 2026年09月18号
20
+ * @param absPath 绝对路径
21
+ * @param platform 目标平台(缺省当前进程平台)
22
+ * @returns 删除命令 argv
23
+ */
24
+ export function removeFileArgv(absPath: string, platform: NodeJS.Platform = process.platform): string[] {
25
+ if (platform === 'win32') {
26
+ return ['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Remove-Item -LiteralPath "' + absPath + '" -Force']
27
+ }
28
+ return ['/bin/rm', '-f', '--', absPath]
29
+ }
30
+
14
31
  /**
15
32
  * 删除新建文件(拒绝创建时):subprocess 删除,路径先经 fs.contains 校验工作区边界。
16
- * @author ddj 2026年08月20号
33
+ * @author ddj 2026年08月20号 / 2026年09月18号
17
34
  * @returns 成功或失败原因
18
35
  */
19
36
  export async function deleteCreated(ctx: Ctx, session: Session, record: DiffRecord): Promise<Result> {
@@ -40,15 +57,10 @@ export async function deleteCreated(ctx: Ctx, session: Session, record: DiffReco
40
57
  }
41
58
  }
42
59
  try {
43
- await attempt(['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Remove-Item -LiteralPath "' + p + '" -Force'])
60
+ await attempt(removeFileArgv(p))
44
61
  return { ok: true }
45
62
  } catch (error) {
46
- try {
47
- await attempt(['/bin/rm', '-f', '--', p])
48
- return { ok: true }
49
- } catch (error2) {
50
- return { ok: false, error: '删除失败(文件仍存在):' + String(error) + ' / ' + String(error2) }
51
- }
63
+ return { ok: false, error: '删除失败(文件仍存在):' + String(error) }
52
64
  }
53
65
  }
54
66