dsh-vscode-mode 0.22.0 → 0.23.1

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,269 @@
1
+ /**
2
+ * dsh-vscode-mode host — 快捷菜单配置的读写(~/.dsh/dsh-vscode-mode/data/quick-menu.json)。
3
+ *
4
+ * 为什么单独成模块:菜单项是用户数据而不是界面镜像——不能落在 cache/(会被 sweep、
5
+ * 且与 uiState 共享 512KB 预算),也不适合塞进 Config schema(嵌套 JSON 不参与校验)。
6
+ * 写入约定:revision 乐观并发 + 同文件串行队列 + tmp 原子替换 + 单代 .bak 备份,
7
+ * 解析失败时把原文件另存 .bad-<ts>.json 再回落默认文档,绝不让坏文件阻塞功能。
8
+ * 作者 ddj 2026年10月30号
9
+ */
10
+ import { copyFile, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
11
+ import { dirname } from 'node:path'
12
+ import {
13
+ defaultDoc,
14
+ normalizeDoc,
15
+ parseDoc,
16
+ serializeDoc,
17
+ QUICK_MENU_BYTES_CAP,
18
+ QUICK_MENU_MAX_ITEMS,
19
+ } from './shared/quickMenu.js'
20
+ import type { QuickMenuDoc } from './shared/quickMenu.js'
21
+ import { quickMenuFile } from './paths.js'
22
+
23
+ /** 一次性读取结果。 */
24
+ export interface QuickMenuState {
25
+ /** 合法文档(读到坏文件时为默认文档)。 */
26
+ doc: QuickMenuDoc
27
+ /** 读取告警(坏文件被备份并重置时给出)。 */
28
+ warning?: string
29
+ /** 文件最后修改时间毫秒(缺失为 0)。 */
30
+ mtime: number
31
+ }
32
+
33
+ /** 写入结果(冲突时把磁盘上的最新文档带回,供客户端刷新)。 */
34
+ export type QuickMenuSaveResult =
35
+ | { ok: true; doc: QuickMenuDoc }
36
+ | { ok: false; error: string; doc?: QuickMenuDoc }
37
+
38
+ /** mtime 缓存(文件未变则复用解析结果,避免每次打开菜单都读盘)。 */
39
+ const stateCache = new Map<string, QuickMenuState>()
40
+
41
+ /** 同文件写队列尾(串行化并发写入;队列清空即移除条目,与 uiState 同套路)。 */
42
+ const writeChains = new Map<string, Promise<unknown>>()
43
+
44
+ /**
45
+ * 把写任务挂到该文件的队尾串行执行(前序失败不影响后续)。
46
+ * @author ddj 2026年10月30号
47
+ * @param file 目标文件绝对路径
48
+ * @param run 实际写操作
49
+ * @returns 本次写操作结果
50
+ */
51
+ function chainWrite<T>(file: string, run: () => Promise<T>): Promise<T> {
52
+ const prev = writeChains.get(file) ?? Promise.resolve()
53
+ const next = prev.then(run, run)
54
+ const tail = next.then(() => undefined, () => undefined)
55
+ writeChains.set(file, tail)
56
+ void tail.then(() => {
57
+ if (writeChains.get(file) === tail) writeChains.delete(file)
58
+ })
59
+ return next
60
+ }
61
+
62
+ /**
63
+ * 读取文件修改时间(缺失/不可读返回 0)。
64
+ * @author ddj 2026年10月30号
65
+ * @param file 文件路径
66
+ * @returns mtime 毫秒
67
+ */
68
+ async function mtimeOf(file: string): Promise<number> {
69
+ try {
70
+ return (await stat(file)).mtimeMs
71
+ } catch {
72
+ return 0
73
+ }
74
+ }
75
+
76
+ /**
77
+ * 原子写文本(先写 .tmp 再改名,缺目录自动建;失败时尽量清掉临时文件)。
78
+ * @author ddj 2026年10月30号
79
+ * @param file 目标文件
80
+ * @param text 文本
81
+ */
82
+ async function writeAtomic(file: string, text: string): Promise<void> {
83
+ await mkdir(dirname(file), { recursive: true })
84
+ const temporary = file + '.tmp'
85
+ await writeFile(temporary, text, 'utf8')
86
+ await rename(temporary, file)
87
+ }
88
+
89
+ /**
90
+ * 把坏文件另存为 .bad-<ts>.json(尽力而为,失败不影响主流程)。
91
+ * @author ddj 2026年10月30号
92
+ * @param file 源文件
93
+ * @returns 备份路径(失败为 null)
94
+ */
95
+ async function backupCorrupt(file: string): Promise<string | null> {
96
+ const target = file + '.bad-' + Date.now() + '.json'
97
+ try {
98
+ await copyFile(file, target)
99
+ return target
100
+ } catch {
101
+ return null
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 把上一次内容写成单代备份(quick-menu.bak.json)。
107
+ * @author ddj 2026年10月30号
108
+ * @param file 源文件
109
+ */
110
+ async function backupPrevious(file: string): Promise<void> {
111
+ try {
112
+ await copyFile(file, file.replace(/\.json$/, '') + '.bak.json')
113
+ } catch {
114
+ // 首次保存没有旧文件,忽略
115
+ }
116
+ }
117
+
118
+ /**
119
+ * 读盘并归一(绕过缓存;文件缺失按默认文档处理)。
120
+ * @author ddj 2026年10月30号
121
+ * @param file 文件路径
122
+ * @returns 文档 + 告警 + mtime
123
+ */
124
+ async function loadFile(file: string): Promise<QuickMenuState> {
125
+ const mtime = await mtimeOf(file)
126
+ if (mtime === 0) return { doc: defaultDoc(), mtime: 0 }
127
+ let text = ''
128
+ try {
129
+ text = await readFile(file, 'utf8')
130
+ } catch (error) {
131
+ const reason = error instanceof Error ? error.message : String(error)
132
+ return { doc: defaultDoc(), warning: '快捷菜单配置读取失败(' + reason + '),已回落默认配置', mtime }
133
+ }
134
+ const parsed = parseDoc(text)
135
+ if (!parsed.warning) return { doc: parsed.doc, mtime }
136
+ // 坏文件:先备份原文件,再写回默认文档,避免每次打开都重复告警
137
+ const backup = await backupCorrupt(file)
138
+ const doc = parsed.doc
139
+ try {
140
+ await writeAtomic(file, serializeDoc(doc))
141
+ } catch {
142
+ // 只读环境/权限不足时保留告警即可,功能继续用内存默认文档
143
+ }
144
+ const hint = backup ? '已备份为 ' + backup : '备份失败'
145
+ return { doc, warning: parsed.warning + '(' + hint + ')', mtime }
146
+ }
147
+
148
+ /**
149
+ * 读取快捷菜单文档(mtime 未变时复用缓存)。
150
+ * @author ddj 2026年10月30号
151
+ * @param home DSH home(测试可注入)
152
+ * @returns 文档 + 告警
153
+ */
154
+ export async function readQuickMenu(home?: string): Promise<QuickMenuState> {
155
+ const file = quickMenuFile(home)
156
+ const mtime = await mtimeOf(file)
157
+ const cached = stateCache.get(file)
158
+ if (cached && cached.mtime === mtime) return cached
159
+ const state = await loadFile(file)
160
+ stateCache.set(file, state)
161
+ return state
162
+ }
163
+
164
+ /**
165
+ * 清空读取缓存(测试或外部改动后强制重读)。
166
+ * @author ddj 2026年10月30号
167
+ * @param home DSH home(缺省清全部)
168
+ */
169
+ export function resetMenuCache(home?: string): void {
170
+ if (home === undefined) stateCache.clear()
171
+ else stateCache.delete(quickMenuFile(home))
172
+ }
173
+
174
+ /**
175
+ * 校验待保存文档的硬性上限(超出即拒绝,避免静默截断用户数据)。
176
+ * @author ddj 2026年10月30号
177
+ * @param doc 待保存文档
178
+ * @returns 错误文案(合法为 null)
179
+ */
180
+ function checkLimits(doc: QuickMenuDoc): string | null {
181
+ if (doc.items.length > QUICK_MENU_MAX_ITEMS) return '菜单项超过上限(' + QUICK_MENU_MAX_ITEMS + ' 项)'
182
+ const bytes = Buffer.byteLength(serializeDoc(doc), 'utf8')
183
+ if (bytes > QUICK_MENU_BYTES_CAP) return '配置超出容量上限(' + QUICK_MENU_BYTES_CAP + ' 字节)'
184
+ return null
185
+ }
186
+
187
+ /**
188
+ * 保存文档(revision 不匹配即判定为并发冲突,返回磁盘最新文档)。
189
+ * @author ddj 2026年10月30号
190
+ * @param doc 客户端提交的完整文档
191
+ * @param baseRevision 客户端读到的 revision
192
+ * @param home DSH home(测试可注入)
193
+ * @returns 保存结果
194
+ */
195
+ export async function saveQuickMenu(
196
+ doc: QuickMenuDoc,
197
+ baseRevision: number,
198
+ home?: string,
199
+ ): Promise<QuickMenuSaveResult> {
200
+ const file = quickMenuFile(home)
201
+ return chainWrite(file, async () => {
202
+ const limit = checkLimits(doc)
203
+ if (limit) return { ok: false as const, error: limit }
204
+ const current = await loadFile(file)
205
+ if (current.doc.revision !== baseRevision) {
206
+ return { ok: false as const, error: '配置已被其他窗口修改,请重新加载后再保存', doc: current.doc }
207
+ }
208
+ const next = normalizeDoc(doc)
209
+ // 悬浮按钮坐标只由 patchLauncher 维护:保存表单时沿用磁盘现值,
210
+ // 否则「拖动/重置位置」与「保存设置」并发时会互相覆盖(曾实测重置后被旧坐标顶回)。
211
+ next.launcher = current.doc.launcher
212
+ next.revision = current.doc.revision + 1
213
+ next.updatedAt = new Date().toISOString()
214
+ try {
215
+ await backupPrevious(file)
216
+ await writeAtomic(file, serializeDoc(next))
217
+ } catch (error) {
218
+ const reason = error instanceof Error ? error.message : String(error)
219
+ return { ok: false as const, error: '快捷菜单配置写入失败:' + reason }
220
+ }
221
+ const state: QuickMenuState = { doc: next, mtime: await mtimeOf(file) }
222
+ stateCache.set(file, state)
223
+ return { ok: true as const, doc: next }
224
+ })
225
+ }
226
+
227
+ /**
228
+ * 只更新悬浮按钮坐标(不推进 revision,避免拖拽把正在编辑的表单顶掉)。
229
+ * @author ddj 2026年10月30号
230
+ * @param launcher 坐标(null 表示回到输入框锚点)
231
+ * @param home DSH home(测试可注入)
232
+ * @returns 保存结果
233
+ */
234
+ export async function patchLauncher(
235
+ launcher: { x: number | null; y: number | null },
236
+ home?: string,
237
+ ): Promise<QuickMenuSaveResult> {
238
+ const file = quickMenuFile(home)
239
+ return chainWrite(file, async () => {
240
+ const current = await loadFile(file)
241
+ const next: QuickMenuDoc = {
242
+ ...current.doc,
243
+ launcher: { x: launcher.x, y: launcher.y },
244
+ updatedAt: new Date().toISOString(),
245
+ }
246
+ try {
247
+ await writeAtomic(file, serializeDoc(next))
248
+ } catch (error) {
249
+ const reason = error instanceof Error ? error.message : String(error)
250
+ return { ok: false as const, error: '保存悬浮按钮位置失败:' + reason }
251
+ }
252
+ const state: QuickMenuState = { doc: next, mtime: await mtimeOf(file) }
253
+ stateCache.set(file, state)
254
+ return { ok: true as const, doc: next }
255
+ })
256
+ }
257
+
258
+ /** 导出/导入时的建议文件名(浏览器侧下载用,host 不参与)。 */
259
+ export const quickMenuBaseName = 'quick-menu.json'
260
+
261
+ /**
262
+ * 当前 home 下的配置路径(诊断日志用)。
263
+ * @author ddj 2026年10月30号
264
+ * @param home DSH home
265
+ * @returns 绝对路径
266
+ */
267
+ export function quickMenuPath(home?: string): string {
268
+ return quickMenuFile(home)
269
+ }
package/src/reviewRpc.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Session-scoped official review RPC. @author ddj 2026年10月26号 */
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
+ import type { ReviewItem, ReviewPage } from './shared/review.js'
3
4
  import type { PendingPage } from './shared/pendingReview.js'
4
5
  import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
5
6
  import { undoReview } from './reviewUndo.js'
@@ -9,6 +10,41 @@ import { bucketOf, resolveScope, sessionOf } from './registry.js'
9
10
  import type { Registry } from './registry.js'
10
11
  import type { Ctx } from './store.js'
11
12
 
13
+ /** 批量写入快照的硬上限:待审积压超过它说明状态异常,宁可拒绝也不做无界写循环。 */
14
+ const BATCH_LIMIT = 2000
15
+
16
+ /**
17
+ * @private
18
+ * @author ddj 2026年10月31号
19
+ * @description: 批量写入用的一次性完整快照。合并口径与列表一致(待审投影优先、同路径官方旧行让位),
20
+ * 但一次取全,且 items 与 pending 同源,客户端才能把它当成写前不变式。
21
+ * @param cwd 工作区
22
+ * @param pending 该会话可见的待审页
23
+ * @returns 完整快照;待审积压超过上限或分页期间数量漂移时返回错误
24
+ */
25
+ function batchSnapshot(cwd: string, pending: PendingPage): { ok: true; page: ReviewPage } | { ok: false; error: string } {
26
+ const first = reviewPage(cwd)
27
+ if (first.pending > BATCH_LIMIT) return { ok: false, error: `待审记录过多(${first.pending} 条,上限 ${BATCH_LIMIT}),请先分批处理后重试` }
28
+
29
+ const official: ReviewItem[] = []
30
+ let page: ReviewPage | undefined = first
31
+ while (page) {
32
+ for (const item of page.items) if (item.current && item.decision === 'pending') official.push(item)
33
+ page = page.nextCursor === null ? undefined : reviewPage(cwd, page.nextCursor)
34
+ }
35
+ // 分页期间有记录被判定掉:数量对不上就不给客户端一份半截快照
36
+ if (official.length !== first.pending) return { ok: false, error: '待审记录数量不完整,未执行批量操作' }
37
+
38
+ // 待审投影已接管的路径,官方旧行必须让位,否则同一文件会被批量写两次
39
+ const live = pending.items.filter((item) => !item.settled)
40
+ const kept = official.filter((row) => !live.some((entry) => pendingSamePath(entry.path, row.currentPath) || pendingSamePath(entry.path, row.path)))
41
+ const items = [...live.map((entry) => pendingItemOf(entry, cwd)), ...kept]
42
+ return {
43
+ ok: true,
44
+ page: { items, pending: items.length, nextCursor: null, gaps: first.gaps, ...(first.overview ? { overview: first.overview } : {}) },
45
+ }
46
+ }
47
+
12
48
  /**
13
49
  * @public
14
50
  * @author ddj 2026年10月26号 / 2026年10月28号
@@ -52,6 +88,10 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
52
88
  return {
53
89
  'edrv.review.list': async (args) => {
54
90
  const cwd = await workspace(args.sessionId)
91
+ // 批量通道要的是一次性完整快照:分页口径的 pending 只是页内展示计数,不能当完整性不变式
92
+ if (args.all && !args.history && args.path === undefined) {
93
+ return batchSnapshot(cwd, pendingPageOf(cwd, args.sessionId))
94
+ }
55
95
  const official = reviewPage(cwd, args.cursor, args.history, args.path)
56
96
  const pending = pendingPageOf(cwd, args.sessionId)
57
97
  // 历史快照只属于官方通道;待审条目没有轮次历史,混进去会造出假的「轮次 0」。
package/src/rpc.ts CHANGED
@@ -34,7 +34,9 @@ import type { ContentSearcher } from './search/content.js'
34
34
  import { newContentSearcher } from './search/content.js'
35
35
  import { restoreFile, revertCall, revertHunk, revertHunks, type Result } from './revert.js'
36
36
  import { rulesList, rulesRead, rulesRemove, rulesSave, rulesToggle } from './rules.js'
37
+ import { listSkillCatalog } from './skillCatalog.js'
37
38
  import { isSnippetFilePath, snippetsEntries, snippetsList, snippetsRead, snippetsRemove, snippetsSave } from './snippets.js'
39
+ import { patchLauncher, readQuickMenu, saveQuickMenu } from './quickMenu.js'
38
40
  import { runtimeOf } from './mcpRuntime.js'
39
41
  import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
40
42
  import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle, mcpSnapshot } from './mcpProject.js'
@@ -1565,6 +1567,14 @@ export function buildHandlers(
1565
1567
  return { ok: false, error: '切换规则失败:' + String(error) }
1566
1568
  }
1567
1569
  },
1570
+ 'skills.list': async (args) => {
1571
+ try {
1572
+ // 只读枚举:不做 workspace 注册校验(读盘无副作用),cwd 由客户端按会话作用域传入
1573
+ return { ok: true, ...(await listSkillCatalog(args?.cwd)) }
1574
+ } catch (error) {
1575
+ return { ok: false, error: '读取技能候选失败:' + String(error) }
1576
+ }
1577
+ },
1568
1578
  'snippets.list': async () => {
1569
1579
  try {
1570
1580
  return { ok: true, ...(await snippetsList(ctx)) }
@@ -1604,6 +1614,33 @@ export function buildHandlers(
1604
1614
  return { ok: false, error: '读取代码片段条目失败:' + String(error) }
1605
1615
  }
1606
1616
  },
1617
+ 'quickMenu.get': async () => {
1618
+ try {
1619
+ const state = await readQuickMenu()
1620
+ return { ok: true, doc: state.doc, warning: state.warning }
1621
+ } catch (error) {
1622
+ return { ok: false, error: '读取快捷菜单失败:' + String(error) }
1623
+ }
1624
+ },
1625
+ 'quickMenu.save': async (args) => {
1626
+ try {
1627
+ const result = await saveQuickMenu(args.doc, args.baseRevision)
1628
+ // 冲突时只回错误文案:客户端收到后重拉最新文档,避免用旧版本覆盖别人的写入
1629
+ if (!result.ok) return { ok: false, error: result.error }
1630
+ return { ok: true, doc: result.doc }
1631
+ } catch (error) {
1632
+ return { ok: false, error: '保存快捷菜单失败:' + String(error) }
1633
+ }
1634
+ },
1635
+ 'quickMenu.launcher': async (args) => {
1636
+ try {
1637
+ const result = await patchLauncher({ x: args.x, y: args.y })
1638
+ if (!result.ok) return { ok: false, error: result.error }
1639
+ return { ok: true, doc: result.doc }
1640
+ } catch (error) {
1641
+ return { ok: false, error: '保存悬浮按钮位置失败:' + String(error) }
1642
+ }
1643
+ },
1607
1644
  }
1608
1645
  }
1609
1646
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 路径比较口径:Host 与客户端共用同一套「远端 / 盘符 / 相对路径」判定,只在平台折叠上分工。
3
+ * @author ddj 2026年10月30号
4
+ */
5
+
6
+ /** 远端仓库身份:`scheme://...` 区分大小写,绝不能折叠。 */
7
+ const REMOTE = /^[a-z]+:\/\//i
8
+ /** 盘符绝对路径:大小写不敏感与宿主平台无关,两边都必须折叠。 */
9
+ const DRIVE = /^[a-z]:\//i
10
+
11
+ /**
12
+ * @author ddj 2026年10月30号
13
+ * @description: 统一分隔符为 `/`,其余字符原样保留。
14
+ * @param path 原始路径
15
+ * @returns 反斜杠已归一化的路径
16
+ */
17
+ export function unifiedPath(path: string): string {
18
+ return path.replace(/\\/g, '/')
19
+ }
20
+
21
+ /**
22
+ * @author ddj 2026年10月30号
23
+ * @description: 是否为远端仓库身份。
24
+ * @param path 原始路径
25
+ * @returns 远端身份返回 true
26
+ */
27
+ export function isRemotePath(path: string): boolean {
28
+ return REMOTE.test(unifiedPath(path))
29
+ }
30
+
31
+ /**
32
+ * @author ddj 2026年10月30号
33
+ * @description: 是否为盘符绝对路径。
34
+ * @param path 原始路径
35
+ * @returns Windows 盘符路径返回 true
36
+ */
37
+ export function isDrivePath(path: string): boolean {
38
+ return DRIVE.test(unifiedPath(path))
39
+ }
40
+
41
+ /**
42
+ * @author ddj 2026年10月30号
43
+ * @description: 生成路径比较键。盘符路径恒折叠大小写;其余路径是否折叠由调用方按运行平台决定
44
+ * (Host 在 Windows 上折叠,客户端不在宿主进程里,取 false)。
45
+ * @param path 原始路径
46
+ * @param foldCase 非盘符路径是否折叠大小写
47
+ * @returns 比较键
48
+ */
49
+ export function pathKeyFor(path: string, foldCase: boolean): string {
50
+ const unified = unifiedPath(path)
51
+ if (REMOTE.test(unified)) return unified
52
+ if (DRIVE.test(unified)) return unified.toLowerCase()
53
+ return foldCase ? unified.toLowerCase() : unified
54
+ }