dsh-vscode-mode 0.5.1 → 0.5.3

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.
@@ -11,6 +11,7 @@ import { createRequire } from 'node:module'
11
11
  import { pathToFileURL } from 'node:url'
12
12
  import type { Ctx } from './store.js'
13
13
  import { KEYBINDING_DEFAULTS } from './shared/keybindings.js'
14
+ import { EDITOR_LIMIT_DEFAULT } from './shared/editorLimit.js'
14
15
  import { INTEGRATION_BASE_DEFAULT } from './shared/integration.js'
15
16
  import { TORTOISE_DIR_DEFAULT } from './shared/svn.js'
16
17
  import type { AiConfigPatch, AiConfigView } from './shared/ai.js'
@@ -73,20 +74,96 @@ async function hostImport(specifier: string): Promise<unknown> {
73
74
  }
74
75
 
75
76
  /**
76
- * 动态加载设置依赖(模块级缓存;任一缺失/失败返回 null 而非抛错)。
77
- * installSettingsSection 仅在 rc 线 dsh-settings 中存在时提供(alpha 起移除,
78
- * 属性探测得到 undefined,不抛错)。
79
- * @author ddj 2026年08月24号 / 2026年09月15号
77
+ * schema 库候选名(新名在前)。
78
+ * DSH 官方自 0.1.5 起把 vendored 包改名为 @deepseek-ai/schemastery 并全树改用新名,
79
+ * 安装树里**没有**裸 schemastery;裸名保留给 rc 线(其 dsh-settings 仍 peers 旧名),
80
+ * 也让开发形态(插件 node_modules 有 devDependency 副本)继续可用。
81
+ */
82
+ const SCHEMA_SPECIFIERS = ['@deepseek-ai/schemastery', 'schemastery'] as const
83
+
84
+ /** 设置持久化包名(installSettingsSection 仅 rc 线提供,alpha 线缺失属正常)。 */
85
+ const SETTINGS_SPECIFIER = '@deepseek-ai/dsh-settings'
86
+
87
+ /**
88
+ * 逐个尝试候选名,返回首个加载成功的模块及其包名(全失败返回 null,不抛错)。
89
+ * @author ddj 2026年09月18号
90
+ * @param specifiers 候选包名(按优先级)
91
+ * @param importFn 加载函数(测试注入)
92
+ * @returns 命中的模块与包名;全失败返回 null
93
+ */
94
+ async function firstImport(
95
+ specifiers: readonly string[],
96
+ importFn: (specifier: string) => Promise<unknown> = hostImport,
97
+ ): Promise<{ specifier: string; module: unknown } | null> {
98
+ for (const specifier of specifiers) {
99
+ try {
100
+ return { specifier, module: await importFn(specifier) }
101
+ } catch {
102
+ /* 该候选不可解析:尝试下一个 */
103
+ }
104
+ }
105
+ return null
106
+ }
107
+
108
+ /**
109
+ * 抹平 schema 库的 ESM/CJS 互操作形态取默认导出。
110
+ * 三种实测形态:真 ESM(`default` 即 z)、CJS 经 import()(`default` 与
111
+ * `module.exports` 同为 z)、双层包装(`default.default` 才是 z)。
112
+ * @author ddj 2026年09月18号
113
+ * @param module 加载到的模块命名空间(可空)
114
+ * @returns 具备 object/string 等构造器的 z;取不到返回 null
115
+ */
116
+ export function pickSchema(module: unknown): SettingsDeps['z'] | null {
117
+ const layers = [module, (module as { default?: unknown } | null)?.default, (module as Record<string, unknown> | null)?.['module.exports']]
118
+ for (const layer of layers) {
119
+ const z = ((layer as { default?: unknown } | null)?.default ?? layer) as SettingsDeps['z'] | undefined
120
+ if (z && typeof z.object === 'function' && typeof z.string === 'function') return z
121
+ }
122
+ return null
123
+ }
124
+
125
+ /** 实际命中的 schema 库名(供兼容性报告展示;未命中为空串)。 */
126
+ let schemaLib = ''
127
+
128
+ /**
129
+ * 读取实际命中的 schema 库名(报告文案用)。
130
+ * @author ddj 2026年09月18号
131
+ * @returns 包名;未解析到为空串
132
+ */
133
+ export function schemaLibName(): string {
134
+ return schemaLib
135
+ }
136
+
137
+ /** 复位依赖缓存与命中库名(测试隔离用)。 */
138
+ export function resetSettingsDeps(): void {
139
+ depsPromise = undefined
140
+ schemaLib = ''
141
+ }
142
+
143
+ /**
144
+ * 动态加载设置依赖(模块级缓存;schema 库缺失返回 null 而非抛错)。
145
+ * ⚠️ 两个依赖**独立解析**:@deepseek-ai/dsh-settings 仅 rc 线跑 legacy 策略时需要,
146
+ * alpha 线走 settings 服务 installSection 用不到它;原先 Promise.all 让该包缺失
147
+ * 拖垮整体 → 用户端(npm 安装)settings section 永不装配。
148
+ * 同理,schema 库按候选链解析(安装树只有新名 @deepseek-ai/schemastery)。
149
+ * @author ddj 2026年08月24号 / 2026年09月15号 / 2026年09月18号
150
+ * @param importFn 加载函数(测试注入;缺省宿主锚点动态导入)
80
151
  * @returns 设置依赖或 null
81
152
  */
82
- export function loadSettingsDeps(): Promise<SettingsDeps | null> {
153
+ export function loadSettingsDeps(importFn: (specifier: string) => Promise<unknown> = hostImport): Promise<SettingsDeps | null> {
83
154
  if (!depsPromise) {
84
- depsPromise = Promise.all([hostImport('@deepseek-ai/dsh-settings'), hostImport('schemastery')])
85
- .then(([dshSettings, schemastery]) => ({
155
+ depsPromise = Promise.all([firstImport([SETTINGS_SPECIFIER], importFn), firstImport(SCHEMA_SPECIFIERS, importFn)])
156
+ .then(([settingsHit, schemaHit]) => {
157
+ const z = pickSchema(schemaHit?.module)
158
+ if (!z) {
159
+ schemaLib = ''
160
+ return null
161
+ }
162
+ schemaLib = schemaHit?.specifier ?? ''
86
163
  // 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
- }))
164
+ const installSettingsSection = (settingsHit?.module as unknown as { installSettingsSection?: SettingsDeps['installSettingsSection'] } | undefined)?.installSettingsSection
165
+ return { installSettingsSection, z }
166
+ })
90
167
  .catch(() => null)
91
168
  }
92
169
  return depsPromise
@@ -264,6 +341,8 @@ export async function installOpenSettingsSection(
264
341
  fileOpenTool: deps.z.string().default(FILE_OPEN_DEFAULT),
265
342
  keybindings: deps.z.object(keybindingsShape(deps.z)).default({ ...KEYBINDING_DEFAULTS }),
266
343
  sidebarMinWidth: deps.z.number().default(300),
344
+ // 页签数量上限(0 = 不限制;超限时淘汰最久未使用的页签,固定页签除外)
345
+ maxOpenEditors: deps.z.number().default(EDITOR_LIMIT_DEFAULT),
267
346
  integrationBaseUrl: deps.z.string().default(INTEGRATION_BASE_DEFAULT),
268
347
  // AI 内联补全(默认关;provider/model 空 = 自动路由;effort 空 = 跟随模型默认)
269
348
  aiInline: deps.z.boolean().default(AI_CONFIG_DEFAULT.enabled),
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
 
@@ -0,0 +1,46 @@
1
+ /**
2
+ * dsh-vscode-mode shared — 编辑器页签数量上限的常量与归一化(host 与 client 双面契约)。
3
+ *
4
+ * 纯数据模块:host(settings schema 默认值)与 client(淘汰判定)共用,禁止 import
5
+ * 浏览器 API 或 Node API。
6
+ *
7
+ * ⚠️ 与 sidebarMinWidth 的关键差异:**0 是合法值**,语义为「不限制页签数量」,
8
+ * 因此归一化时不能像 normalizeSidebarMinWidth 那样把 0 当作非法输入回退默认值。
9
+ *
10
+ * 作者 ddj 2026年09月18号
11
+ */
12
+
13
+ /** 页签数量上限默认值(对齐 VS Code workbench.editor.limit 默认 10)。 */
14
+ export const EDITOR_LIMIT_DEFAULT = 10
15
+ /** 上限允许下界:0 = 不限制(关闭上限功能)。 */
16
+ export const EDITOR_LIMIT_FLOOR = 0
17
+ /** 上限允许上界(防呆:远超屏幕可容纳数量的上限无意义)。 */
18
+ export const EDITOR_LIMIT_CEIL = 50
19
+
20
+ /**
21
+ * 归一化页签数量上限。
22
+ *
23
+ * 规则:`0` 合法保留(不限制);非数字/非有限/负数 → 回退默认;其余取整后夹到
24
+ * `[EDITOR_LIMIT_FLOOR, EDITOR_LIMIT_CEIL]`。
25
+ * 数字字符串(设置文档/输入框可能给字符串)按数字解析,与 sidebarMin 口径一致。
26
+ *
27
+ * ⚠️ 只接受「真数字」与「非空数字字符串」:不得用裸 `Number(value)` 判值——
28
+ * `Number(null)`、`Number([])`、`Number(false)`、`Number('')` 全是 0,会被误判成
29
+ * 「不限制」;而缺失/损坏的设置必须回退默认(否则一个坏值就永久关掉上限)。
30
+ *
31
+ * @author ddj 2026年09月18号
32
+ * @param value 原始值(设置文档或输入框来的任意 JSON 值)
33
+ * @returns 合法上限(0 = 不限制)
34
+ */
35
+ export function normalizeMaxOpenEditors(value: unknown): number {
36
+ let n: number
37
+ if (typeof value === 'number') {
38
+ n = value
39
+ } else if (typeof value === 'string' && value.trim() !== '') {
40
+ n = Number(value)
41
+ } else {
42
+ return EDITOR_LIMIT_DEFAULT
43
+ }
44
+ if (!Number.isFinite(n) || n < 0) return EDITOR_LIMIT_DEFAULT
45
+ return Math.max(EDITOR_LIMIT_FLOOR, Math.min(EDITOR_LIMIT_CEIL, Math.round(n)))
46
+ }
@@ -14,6 +14,9 @@ export const KEYBINDING_DEFAULTS: Record<string, string> = {
14
14
  'edrv.quickOpen': 'Ctrl+P',
15
15
  'edrv.toggleSidebar': 'Ctrl+B',
16
16
  'edrv.searchInFiles': 'Ctrl+Shift+F',
17
+ // Markdown 预览切换:VS Code 同款 (Ctrl+K V 为分栏,此处取单键 Ctrl+Shift+V)。
18
+ // 仅当活动文件是 .md 时才吞键,其余情况放行给浏览器/输入框(保留「粘贴为纯文本」语义)。
19
+ 'edrv.toggleMarkdownPreview': 'Ctrl+Shift+V',
17
20
  'edrv.navigateBack': 'Alt+ArrowLeft|Ctrl+Alt+-',
18
21
  'edrv.navigateForward': 'Alt+ArrowRight|Ctrl+Shift+-',
19
22
  // 页签循环:主候选避开浏览器保留键(Ctrl+Tab / Ctrl+PgUp/PgDn 会被浏览器截获)