dsh-vscode-mode 0.21.4 → 0.23.0
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/README.md +52 -4
- package/lib/client.js +3442 -183
- package/lib/client.js.map +2 -2
- package/lib/index.js +3510 -222
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +52 -8
- package/src/client/styles/quickmenu.css +1049 -0
- package/src/client/ui/DiffLauncher.ts +1 -1
- package/src/client/ui/EditorView.ts +10 -2
- package/src/client/ui/OfficialDiffBar.tsx +1 -12
- package/src/client/ui/OfficialReview.ts +13 -3
- package/src/client/ui/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/client/ui/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +165 -0
- package/src/pending/decide.ts +213 -0
- package/src/pending/hunks.ts +241 -0
- package/src/pending/kernel.ts +233 -0
- package/src/pending/persist.ts +329 -0
- package/src/pending/store.ts +472 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +184 -0
- package/src/pendingRpc.ts +210 -0
- package/src/quickMenu.ts +269 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +66 -2
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +28 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -0
- package/src/store.ts +19 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** 待审投影 RPC:Keep/Revert 走累计基线,旧官方审查入口不再承接写操作。 @author ddj 2026年10月29号 */
|
|
2
|
+
import type { RpcHandlerMap } from './shared/rpc.js'
|
|
3
|
+
import type { PendingRequest } from './shared/pendingReview.js'
|
|
4
|
+
import { resolveScope, sessionOf } from './registry.js'
|
|
5
|
+
import type { Registry } from './registry.js'
|
|
6
|
+
import { bucketOf } from './registry.js'
|
|
7
|
+
import { readText } from './pending/capture.js'
|
|
8
|
+
import { pendingRawId, trackPendingCover } from './pendingCompat.js'
|
|
9
|
+
import { loadEntries, saveEntry } from './pending/persist.js'
|
|
10
|
+
import { applyDecide, importFromVcs, itemOf, loadDetail, loadPage, refreshed, removeEntry, undoDecide } from './pending/store.js'
|
|
11
|
+
import type { ShellLike } from './pending/vcs.js'
|
|
12
|
+
import type { Ctx } from './store.js'
|
|
13
|
+
import { policyOf } from './store.js'
|
|
14
|
+
|
|
15
|
+
/** 处理器共享的作用域能力:读允许跨实例冷读,写必须本端 live 会话。 */
|
|
16
|
+
interface ScopeKit {
|
|
17
|
+
ctx: Ctx
|
|
18
|
+
read(sessionId: string): Promise<string>
|
|
19
|
+
write(sessionId: string): Promise<string>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @author ddj 2026年10月30号
|
|
24
|
+
* @description: 解析会话工作区并预热记录桶;读路径也走它,避免冷读时桶未就绪。
|
|
25
|
+
* @param ctx Host 上下文
|
|
26
|
+
* @param registry 工作区记录桶缓存
|
|
27
|
+
* @param sessionId 请求作用域
|
|
28
|
+
* @returns 工作区路径
|
|
29
|
+
*/
|
|
30
|
+
async function workspaceOf(ctx: Ctx, registry: Registry, sessionId: string): Promise<string> {
|
|
31
|
+
const scope = await resolveScope(ctx, sessionId)
|
|
32
|
+
if ('err' in scope) throw new Error('会话不存在或没有工作区')
|
|
33
|
+
await bucketOf(registry, ctx, scope.cwd)
|
|
34
|
+
return scope.cwd
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @author ddj 2026年10月30号
|
|
39
|
+
* @description: 写路径专用的工作区解析:本端没有该会话就直接拒绝,不做跨实例写。
|
|
40
|
+
* @param ctx Host 上下文
|
|
41
|
+
* @param registry 工作区记录桶缓存
|
|
42
|
+
* @param sessionId 请求作用域
|
|
43
|
+
* @returns 工作区路径
|
|
44
|
+
*/
|
|
45
|
+
async function writeWorkspaceOf(ctx: Ctx, registry: Registry, sessionId: string): Promise<string> {
|
|
46
|
+
const cwd = await workspaceOf(ctx, registry, sessionId)
|
|
47
|
+
if (!sessionOf(ctx, sessionId)) throw new Error('写入要求本端活跃会话')
|
|
48
|
+
return cwd
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @author ddj 2026年10月30号
|
|
53
|
+
* @description: 把异常归一成可播报文案,避免处理器各自重复三元表达式。
|
|
54
|
+
* @param error 捕获到的异常
|
|
55
|
+
* @returns 错误文案
|
|
56
|
+
*/
|
|
57
|
+
function failureOf(error: unknown): string {
|
|
58
|
+
return error instanceof Error ? error.message : String(error)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @author ddj 2026年10月30号
|
|
63
|
+
* @description: 按会话策略包装 shell:执行器默认按 workspace-write 跑,本机无可用沙箱后端会直接拒绝。
|
|
64
|
+
* @param raw 原始 shell 能力
|
|
65
|
+
* @param policy 会话沙箱策略
|
|
66
|
+
* @returns 带策略的 shell 视图
|
|
67
|
+
*/
|
|
68
|
+
function shellOf(raw: ShellLike, policy: unknown): ShellLike {
|
|
69
|
+
return {
|
|
70
|
+
resolve: (request) => raw.resolve(policy ? { ...request, sandboxPolicy: policy } : request),
|
|
71
|
+
execute: (spec) => raw.execute(spec),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @author ddj 2026年10月30号
|
|
77
|
+
* @description: 读路径处理器:列表与详情,允许跨实例冷读。
|
|
78
|
+
* @param kit 作用域能力
|
|
79
|
+
* @returns 处理器表
|
|
80
|
+
*/
|
|
81
|
+
function readHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
82
|
+
return {
|
|
83
|
+
'edrv.pending.list': async (args: PendingRequest) => {
|
|
84
|
+
const cwd = await kit.read(args.sessionId)
|
|
85
|
+
const page = loadPage(cwd, args.sessionId)
|
|
86
|
+
// 列表即登记接管关系;旧写入口守卫之后只读内存集合,不再产生数据库读
|
|
87
|
+
for (const item of page.items) trackPendingCover(cwd, item.path, true)
|
|
88
|
+
return { ok: true, page }
|
|
89
|
+
},
|
|
90
|
+
'edrv.pending.detail': async (args: PendingRequest & { id: string }) => {
|
|
91
|
+
const cwd = await kit.read(args.sessionId)
|
|
92
|
+
const detail = loadDetail(cwd, pendingRawId(args.id))
|
|
93
|
+
return detail ? { ok: true, detail } : { ok: false, error: '待审条目不存在' }
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @author ddj 2026年10月30号
|
|
100
|
+
* @description: 判定处理器:Keep/Revert 与已解决条目移除,都会改变待审状态。
|
|
101
|
+
* @param kit 作用域能力
|
|
102
|
+
* @returns 处理器表
|
|
103
|
+
*/
|
|
104
|
+
function decideHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
105
|
+
return {
|
|
106
|
+
'edrv.pending.decide': async (args) => {
|
|
107
|
+
const cwd = await kit.write(args.sessionId)
|
|
108
|
+
const id = pendingRawId(args.id)
|
|
109
|
+
try {
|
|
110
|
+
const policy = policyOf(kit.ctx, sessionOf(kit.ctx, args.sessionId))
|
|
111
|
+
const result = await applyDecide(kit.ctx.get('fs'), cwd, { ...args, id }, undefined, policy)
|
|
112
|
+
if (!result.ok) return { ok: false, error: result.reason }
|
|
113
|
+
const detail = loadDetail(cwd, id)
|
|
114
|
+
return detail ? { ok: true, item: detail.item, wrote: result.wrote } : { ok: false, error: '待审条目不存在' }
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return { ok: false, error: failureOf(error) }
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
'edrv.pending.remove': async (args) => {
|
|
120
|
+
const cwd = await kit.write(args.sessionId)
|
|
121
|
+
const id = pendingRawId(args.id)
|
|
122
|
+
const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path
|
|
123
|
+
const removed = removeEntry(cwd, id, args.revision)
|
|
124
|
+
// 移除后该路径不再由待审投影接管,旧入口守卫随之放行
|
|
125
|
+
if (removed && path) trackPendingCover(cwd, path, false)
|
|
126
|
+
return { ok: true, removed }
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @author ddj 2026年10月30号
|
|
133
|
+
* @description: 历史处理器:撤销/重做一次决定。
|
|
134
|
+
* @param kit 作用域能力
|
|
135
|
+
* @returns 处理器表
|
|
136
|
+
*/
|
|
137
|
+
function historyHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
138
|
+
return {
|
|
139
|
+
'edrv.pending.history': async (args) => {
|
|
140
|
+
const cwd = await kit.write(args.sessionId)
|
|
141
|
+
const policy = policyOf(kit.ctx, sessionOf(kit.ctx, args.sessionId))
|
|
142
|
+
try {
|
|
143
|
+
const result = await undoDecide(kit.ctx.get('fs'), cwd, args, undefined, policy)
|
|
144
|
+
return result.ok ? { ok: true, item: result.item, wrote: result.wrote } : { ok: false, error: result.reason }
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { ok: false, error: failureOf(error) }
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @author ddj 2026年10月30号
|
|
154
|
+
* @description: 投影维护处理器:从版本库导入、按磁盘现状刷新单条。
|
|
155
|
+
* @param kit 作用域能力
|
|
156
|
+
* @returns 处理器表
|
|
157
|
+
*/
|
|
158
|
+
function refreshHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
|
|
159
|
+
return {
|
|
160
|
+
'edrv.pending.import': async (args) => {
|
|
161
|
+
const cwd = await kit.write(args.sessionId)
|
|
162
|
+
const raw = kit.ctx.get('shell') as unknown as ShellLike | undefined
|
|
163
|
+
if (!raw) return { ok: false, error: '当前环境没有 shell 能力,无法读取版本库基线' }
|
|
164
|
+
const policy = policyOf(kit.ctx, sessionOf(kit.ctx, args.sessionId))
|
|
165
|
+
try {
|
|
166
|
+
const shell = shellOf(raw, policy)
|
|
167
|
+
const result = await importFromVcs(shell, cwd, args.sessionId, { includeUntracked: args.includeUntracked, path: args.path })
|
|
168
|
+
for (const item of loadPage(cwd, args.sessionId).items) trackPendingCover(cwd, item.path, true)
|
|
169
|
+
return { ok: true, imported: result.imported, changes: result.changes }
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return { ok: false, error: failureOf(error) }
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
'edrv.pending.refresh': async (args: PendingRequest & { id: string }) => {
|
|
175
|
+
const cwd = await kit.write(args.sessionId)
|
|
176
|
+
const id = pendingRawId(args.id)
|
|
177
|
+
const stored = loadEntries(cwd).find((entry) => entry.entry.id === id)
|
|
178
|
+
if (!stored) return { ok: false, error: '待审条目不存在' }
|
|
179
|
+
const fs = kit.ctx.get('fs')
|
|
180
|
+
const session = sessionOf(kit.ctx, args.sessionId)
|
|
181
|
+
const target = await fs.resolve(id, { cwd, agent: session })
|
|
182
|
+
const content = await readText(fs, target).catch(() => undefined)
|
|
183
|
+
if (content === undefined) return { ok: false, error: '读取失败' }
|
|
184
|
+
const next = refreshed(stored, content, Date.now())
|
|
185
|
+
if (next) saveEntry(cwd, next)
|
|
186
|
+
return { ok: true, item: itemOf(next ?? stored) }
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @author ddj 2026年10月29号
|
|
193
|
+
* @description: 待审投影处理器表:按用途拆成读/判定/历史/维护四组,便于分别收敛权限。
|
|
194
|
+
* @param ctx Host 上下文
|
|
195
|
+
* @param registry 工作区记录桶缓存
|
|
196
|
+
* @returns 待审相关处理器表
|
|
197
|
+
*/
|
|
198
|
+
export function pendingHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
|
|
199
|
+
const kit: ScopeKit = {
|
|
200
|
+
ctx,
|
|
201
|
+
read: (sessionId) => workspaceOf(ctx, registry, sessionId),
|
|
202
|
+
write: (sessionId) => writeWorkspaceOf(ctx, registry, sessionId),
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
...readHandlers(kit),
|
|
206
|
+
...decideHandlers(kit),
|
|
207
|
+
...historyHandlers(kit),
|
|
208
|
+
...refreshHandlers(kit),
|
|
209
|
+
}
|
|
210
|
+
}
|
package/src/quickMenu.ts
ADDED
|
@@ -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/reviewDb.ts
CHANGED
|
@@ -207,6 +207,9 @@ export function reviewPage(cwd: string, cursor = 0, history = false, path?: stri
|
|
|
207
207
|
|
|
208
208
|
/** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @param path Legacy write target. @returns Whether any official version owns the file, including decided history. */
|
|
209
209
|
export function hasOfficial(cwd: string, path: string): boolean {
|
|
210
|
+
// 防御:解析结果若被误当成路径传进来(不透明对象),按「无官方记录」处理,
|
|
211
|
+
// 否则 SQLite 会把对象当命名参数绑定,抛出与真实原因无关的错误。
|
|
212
|
+
if (typeof path !== 'string') return false
|
|
210
213
|
const db = dbFor(cwd)
|
|
211
214
|
// A read guard must not acquire a migration write lock on legacy-only databases.
|
|
212
215
|
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false
|
package/src/reviewRpc.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/** Session-scoped official review RPC. @author ddj 2026年10月26号 */
|
|
2
2
|
import type { RpcHandlerMap } from './shared/rpc.js'
|
|
3
|
+
import type { PendingPage } from './shared/pendingReview.js'
|
|
3
4
|
import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
|
|
4
5
|
import { undoReview } from './reviewUndo.js'
|
|
6
|
+
import { applyDecide, loadDetail, loadPage } from './pending/store.js'
|
|
7
|
+
import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
|
|
5
8
|
import { bucketOf, resolveScope, sessionOf } from './registry.js'
|
|
6
9
|
import type { Registry } from './registry.js'
|
|
7
10
|
import type { Ctx } from './store.js'
|
|
@@ -31,19 +34,67 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
|
|
|
31
34
|
await bucketOf(registry, ctx, scope.cwd)
|
|
32
35
|
return scope.cwd
|
|
33
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @private
|
|
40
|
+
* @author ddj 2026年10月30号
|
|
41
|
+
* @description: 读取待审页;待审库尚未建立或迁移失败时按空页处理。
|
|
42
|
+
* 待审投影是增量能力,不能因为它让官方列表整体不可用。
|
|
43
|
+
* @param cwd 工作区
|
|
44
|
+
* @param sessionId 请求会话
|
|
45
|
+
* @returns 待审页(失败为空页)
|
|
46
|
+
*/
|
|
47
|
+
function pendingPageOf(cwd: string, sessionId: string): PendingPage {
|
|
48
|
+
try { return loadPage(cwd, sessionId) }
|
|
49
|
+
catch { return { items: [], pending: 0, nextCursor: null, gaps: [] } }
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
return {
|
|
35
53
|
'edrv.review.list': async (args) => {
|
|
36
54
|
const cwd = await workspace(args.sessionId)
|
|
37
|
-
|
|
55
|
+
const official = reviewPage(cwd, args.cursor, args.history, args.path)
|
|
56
|
+
const pending = pendingPageOf(cwd, args.sessionId)
|
|
57
|
+
// 历史快照只属于官方通道;待审条目没有轮次历史,混进去会造出假的「轮次 0」。
|
|
58
|
+
if (args.history) return { ok: true, page: official }
|
|
59
|
+
if (args.path !== undefined) {
|
|
60
|
+
// 单文件台账(差异栏「详情」展开处)必须并上该路径的待审条目,
|
|
61
|
+
// 否则待审详情(块判定 + 批注 + 让 AI 应答)在这条入口上永远点不到。
|
|
62
|
+
const key = args.path
|
|
63
|
+
const own = pending.items.filter((item) => pendingSamePath(item.path, key)).map((item) => pendingItemOf(item, cwd))
|
|
64
|
+
// 待审接管后同路径的官方轮次行只是旧口径残影:留着会让人点进只读详情,以为没有判定入口。
|
|
65
|
+
const history = own.length ? official.items.filter((item) => !pendingSamePath(item.path, key)) : official.items
|
|
66
|
+
return { ok: true, page: { ...official, items: [...own, ...history] } }
|
|
67
|
+
}
|
|
68
|
+
// 待审投影就位后由它承接被覆盖路径,官方旧行让位,同一路径只保留一个权威
|
|
69
|
+
return { ok: true, page: mergedPage(official, pending, cwd) }
|
|
38
70
|
},
|
|
39
71
|
'edrv.review.detail': async (args) => {
|
|
40
72
|
const cwd = await workspace(args.sessionId)
|
|
73
|
+
if (args.id.startsWith(PENDING_PREFIX)) {
|
|
74
|
+
const detail = loadDetail(cwd, args.id.slice(PENDING_PREFIX.length))
|
|
75
|
+
if (!detail) return { ok: false, error: '待审条目不存在' }
|
|
76
|
+
return { ok: true, detail: pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd) }
|
|
77
|
+
}
|
|
41
78
|
const detail = reviewDetail(cwd, args.id)
|
|
42
79
|
return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
|
|
43
80
|
},
|
|
44
81
|
'edrv.review.decide': async (args) => {
|
|
45
82
|
// 写类操作要求 live 会话:避免两端并发写同一工作区审查库,回滚也依赖本端会话内容
|
|
46
83
|
const cwd = await liveWorkspace(args.sessionId)
|
|
84
|
+
if (args.id.startsWith(PENDING_PREFIX)) {
|
|
85
|
+
// 待审条目走累计基线:Keep 零写盘,Revert 写回并读回校验
|
|
86
|
+
const result = await applyDecide(ctx.get('fs'), cwd, {
|
|
87
|
+
sessionId: args.sessionId,
|
|
88
|
+
id: args.id.slice(PENDING_PREFIX.length),
|
|
89
|
+
revision: args.revision,
|
|
90
|
+
operationId: args.operationId,
|
|
91
|
+
decision: args.decision,
|
|
92
|
+
scope: args.scope === 'hunk' ? 'block' : 'file',
|
|
93
|
+
...(args.blockId === undefined ? {} : { blockId: args.blockId }),
|
|
94
|
+
})
|
|
95
|
+
if (!result.ok) return { ok: false, error: result.reason }
|
|
96
|
+
return { ok: true, revision: result.flags.revision }
|
|
97
|
+
}
|
|
47
98
|
try {
|
|
48
99
|
const result = args.decision === 'rejected'
|
|
49
100
|
? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
|