dsh-vscode-mode 0.23.3 → 0.23.5

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.23.3",
3
+ "version": "0.23.5",
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 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -84,7 +84,7 @@
84
84
  "vitest": "^3.2.4"
85
85
  },
86
86
  "scripts": {
87
- "build": "tsdown",
87
+ "build": "tsdown && node scripts/check-client-artifacts.mjs lib",
88
88
  "build:client": "tsdown",
89
89
  "typecheck": "tsc -p tsconfig.json --noEmit",
90
90
  "test": "vitest run && node --test tests/nativeOpen.test.mjs",
@@ -4,7 +4,7 @@ import { createRequestId } from './requestId.js'
4
4
  import type { PlanDocument } from './planState.js'
5
5
 
6
6
  export interface EditorRequest { requestId: string; sessionId: string; path: string | null; line?: number; column?: number; preview?: boolean; plan?: PlanDocument }
7
- export interface OpenOptions { preview?: boolean; plan?: PlanDocument; signal?: AbortSignal }
7
+ export interface OpenOptions { preview?: boolean; plan?: PlanDocument; signal?: AbortSignal; timeoutMs?: number }
8
8
  interface Waiter { request: EditorRequest; finish: (success: boolean) => void; claimed?: boolean }
9
9
  const waiters = new Map<string, Waiter>()
10
10
 
@@ -37,7 +37,7 @@ export function finishOpen(requestId: string, success: boolean, error?: string):
37
37
  * @param line Optional target line.
38
38
  * @param column Optional target column.
39
39
  * @param sessionId Owning session, required for routing.
40
- * @param options Explicit preview, read-only plan document and optional cancellation signal.
40
+ * @param options Explicit preview, read-only plan document, cancellation signal and receipt deadline.
41
41
  * @returns Actual completion; timeout and disposal are false.
42
42
  */
43
43
  export function requestOpen(path: string | null, line?: number, column?: number, sessionId = '', options: OpenOptions = {}): Promise<boolean> {
@@ -60,7 +60,7 @@ export function requestOpen(path: string | null, line?: number, column?: number,
60
60
  function abort(): void { finish(false) }
61
61
  options.signal?.addEventListener('abort', abort, { once: true })
62
62
  waiters.set(request.requestId, { request, finish })
63
- timer = setTimeout(() => finish(false), 20_000)
63
+ timer = setTimeout(() => finish(false), options.timeoutMs ?? 20_000)
64
64
  openEditorView(null)
65
65
  window.dispatchEvent(new CustomEvent('edrv:open-request', { detail: request }))
66
66
  })
@@ -2,6 +2,7 @@
2
2
  import React from 'react'
3
3
  import { requestOpen } from './openReceipt.js'
4
4
  import { createPlanFeed, clearPlanState, isPlanAddress, planDocument, type PlanWindow, type PlanOpen } from './planState.js'
5
+ import { installPlanDirect, optionalService, type PlanOpenOptions } from './planDirect.js'
5
6
  import { detectOfficial, OFFICIAL_SLOT_NAME, type OfficialSidebar } from './officialSidebar.js'
6
7
  import { readSessionScope, subscribeScope } from './sessionScope.js'
7
8
  import { log } from './log.js'
@@ -22,16 +23,12 @@ const CLAIM_ID = 'dsh-vscode-mode/plan'
22
23
  const CLAIM_KIND = 'edrvPlan'
23
24
  const EMPTY_RESOURCE = { status: 'none' }
24
25
 
25
- /** @private @author ddj 2026年10月08号 @param ctx Optional context. @param name Service name. @returns Service or undefined without gating plugin activation. */
26
- function optional(ctx: PlanContext, name: string): unknown {
27
- try { return ctx.get(name) } catch { return undefined }
28
- }
29
26
  /** @private @author ddj 2026年10月08号 @param tab Current tab. @returns Native plan fallback options, preserving transient review parameters. */
30
27
  function nativeOptions(tab: PlanTab | undefined): { kind: string; params?: unknown } {
31
28
  return { kind: 'plan', params: tab?.navigation?.params }
32
29
  }
33
- /** @private @author ddj 2026年10月08号 @param ctx Optional context. @param official Sidebar services. @returns Transfer component with native fallback and cancellable receipts. */
34
- function planRouter(ctx: PlanContext, official: OfficialSidebar) {
30
+ /** @private @author ddj 2026年10月08号 @param ctx Optional context. @param official Sidebar services. @param native Official openResource bypass. @returns Transfer component with native fallback and cancellable receipts. */
31
+ function planRouter(ctx: PlanContext, official: OfficialSidebar, native: (address: string, options?: PlanOpenOptions) => void) {
35
32
  const transfers = new Map<string, { controller: AbortController; sessionId: string }>()
36
33
  /** @private @author ddj 2026年10月08号 @param all Plugin disposal. Cancel scope-stale transfers, not transfers merely hidden by the editor mount. */
37
34
  function cancel(all = false): void {
@@ -60,7 +57,7 @@ function planRouter(ctx: PlanContext, official: OfficialSidebar) {
60
57
  function openNative(): void {
61
58
  if (typeof official.service.openResource !== 'function') return
62
59
  try {
63
- official.service.openResource(address, nativeOptions(tab))
60
+ native(address, nativeOptions(tab))
64
61
  tab?.actions?.close?.()
65
62
  } catch (error) { log.warn('计划官方查看器回退失败:' + String(error)) }
66
63
  }
@@ -99,11 +96,12 @@ function registerPlan(ctx: PlanContext, official: OfficialSidebar): () => void {
99
96
  patterns: ['dsh-resource://plan/**', 'dsh-resource://plan-review/**'],
100
97
  canOpen: isPlanAddress, title: () => '计划预览',
101
98
  })
102
- const router = planRouter(ctx, official)
99
+ const direct = installPlanDirect(ctx, official.service)
100
+ const router = planRouter(ctx, official, direct.native)
103
101
  try {
104
102
  const offBody = ctx.slots.inject(OFFICIAL_SLOT_NAME, () => ctx.slots.register({ name: OFFICIAL_SLOT_NAME, key: CLAIM_ID }, router.body))
105
- return () => { router.dispose(); if (typeof offBody === 'function') offBody(); offType() }
106
- } catch (error) { router.dispose(); offType(); throw error }
103
+ return () => { direct.dispose(); router.dispose(); if (typeof offBody === 'function') offBody(); offType() }
104
+ } catch (error) { direct.dispose(); router.dispose(); offType(); throw error }
107
105
  }
108
106
  /** @private @author ddj 2026年10月08号 @param ctx Context. @returns Current-session feed subscription, cancelling pending opens on scope change. */
109
107
  function watchPlans(ctx: PlanContext): () => void {
@@ -116,7 +114,7 @@ function watchPlans(ctx: PlanContext): () => void {
116
114
  /** @private @author ddj 2026年10月08号 @param id Owning session. @returns Valid borrowed feed, or undefined on older/incompatible services. */
117
115
  function sourceOf(id: string): EventSource | undefined {
118
116
  try {
119
- const sessions = optional(ctx, 'sessions') as Sessions | undefined
117
+ const sessions = optionalService(ctx, 'sessions') as Sessions | undefined
120
118
  if (!id || typeof sessions?.binding !== 'function') return undefined
121
119
  const feed = sessions.binding(id)?.eventSource
122
120
  return typeof feed?.getSnapshot === 'function' && typeof feed.subscribe === 'function' ? feed : undefined
@@ -0,0 +1,266 @@
1
+ /**
2
+ * dsh-vscode-mode client — 计划地址直通。
3
+ *
4
+ * 背景:官方计划卡片「查看全文」调 `ctx.sidebarRight.openResource(planAddress)`,
5
+ * 侧栏会先创建计划页签、再由本插件认领并立刻关闭,桌面端表现为「快速打开又迅速关闭」。
6
+ * 本模块在装配时包装该服务方法:能直接拿到计划正文时改为把只读计划送进「文件编辑」,
7
+ * 不再创建侧栏页签;地址不属于在屏会话、正文暂不可得、编辑器未回执或包装未生效时
8
+ * 一律回落原方法,官方页签与原认领转交链路继续作为后备。
9
+ * 作者 ddj 2026年10月31号
10
+ */
11
+
12
+ import { log } from './log.js'
13
+ import { requestOpen } from './openReceipt.js'
14
+ import { cachedPlan, isPlanAddress, planDocument, rememberPlan, type PlanDocument } from './planState.js'
15
+ import { readSessionScope } from './sessionScope.js'
16
+
17
+ //region 对外结构面
18
+
19
+ /** 官方 openResource 的可选参数(临时回顾页签的正文在 params.planReview 里)。 */
20
+ export interface PlanOpenOptions { kind?: string; params?: unknown }
21
+
22
+ /** sidebarRight 的最小结构面:只接管 openResource 的调用。 */
23
+ export interface PlanServiceLike { openResource?: (address: string, options?: PlanOpenOptions) => void }
24
+
25
+ /** 直通装配结果:native 绕过包装回落原方法,dispose 中止在途并条件还原。 */
26
+ export interface PlanDirect {
27
+ dispose: () => void
28
+ native: (address: string, options?: PlanOpenOptions) => void
29
+ }
30
+
31
+ /** 资源流最小结构面(@deepseek-ai/dsh-client-resources 为可选依赖)。 */
32
+ interface ResourceStream { getSnapshot?: () => unknown; subscribe?: (listener: () => void) => unknown }
33
+
34
+ /** resources 服务最小结构面。 */
35
+ interface ResourcesLike { source?: (address: string) => ResourceStream | undefined }
36
+
37
+ //endregion
38
+
39
+ /** 正文解析上限:超过即回落官方页签,避免点击后长时间无反馈。 */
40
+ const RESOLVE_MS = 1_500
41
+ /** 编辑器回执上限:超过即回落官方页签。 */
42
+ const ROUTE_MS = 5_000
43
+
44
+ /** @public @author ddj 2026年10月31号
45
+ * 可选探测客户端服务,缺失或抛错时返回 undefined。
46
+ * @param ctx 客户端上下文(只需 get)。
47
+ * @param name 服务名。
48
+ * @returns 服务实例,或 undefined(不阻断装配)。
49
+ */
50
+ export function optionalService(ctx: { get: (name: string) => unknown }, name: string): unknown {
51
+ try { return ctx.get(name) } catch { return undefined }
52
+ }
53
+
54
+ /** @public @author ddj 2026年10月31号
55
+ * 包装官方 openResource,把本会话的计划地址改为直通插件编辑器。
56
+ * @param ctx 客户端上下文(可选服务一律 ctx.get 探测)。
57
+ * @param service 官方 sidebarRight 服务;仅在赋值生效时留下包装。
58
+ * @returns 供路由回退使用的原方法,以及卸载时中止在途并还原的清理函数。
59
+ */
60
+ export function installPlanDirect(ctx: { get: (name: string) => unknown }, service: PlanServiceLike): PlanDirect {
61
+ const original = service.openResource
62
+ const pending = new Map<string, () => void>()
63
+ let closed = false
64
+ let installed = false
65
+
66
+ //region 判定与参数
67
+
68
+ /** @private @author ddj 2026年10月31号
69
+ * 解码地址段(畸形编码一律视为不可接管)。
70
+ * @param address 官方资源地址。
71
+ * @returns 去掉协议前缀后的解码段。
72
+ */
73
+ function decode(address: string): string[] | undefined {
74
+ try { return address.split('/').slice(3).map(decodeURIComponent) } catch { return undefined }
75
+ }
76
+
77
+ /** @private @author ddj 2026年10月31号
78
+ * 判断该地址是否由本插件接管:只认两段式计划地址,且归属当前在屏会话。
79
+ * @param address 官方资源地址。
80
+ * @returns 可接管的会话 id,其他情况 undefined(回落原方法)。
81
+ */
82
+ function take(address: string): string | undefined {
83
+ if (closed || !isPlanAddress(address)) return undefined
84
+
85
+ const segments = decode(address)
86
+ if (segments?.length !== 2) return undefined
87
+
88
+ const current = readSessionScope(ctx).sessionId ?? ''
89
+ return current !== '' && segments[0] === current ? current : undefined
90
+ }
91
+
92
+ /** @private @author ddj 2026年10月31号
93
+ * 读官方参数里随页签携带的计划正文(临时回顾页签走这里)。
94
+ * @param address 官方资源地址。
95
+ * @param options 官方 openResource 参数。
96
+ * @returns 计划正文,或不带正文时 undefined。
97
+ */
98
+ function inline(address: string, options?: PlanOpenOptions): PlanDocument | undefined {
99
+ const params = options?.params
100
+ if (params === null || typeof params !== 'object') return undefined
101
+ return planDocument(address, (params as { planReview?: unknown }).planReview)
102
+ }
103
+
104
+ //endregion
105
+
106
+ //region 资源解析
107
+
108
+ /** @private @author ddj 2026年10月31号
109
+ * 打开计划资源流。
110
+ * @param services resources 服务。
111
+ * @param address 官方资源地址。
112
+ * @returns 资源流;服务缺失或抛错时 undefined。
113
+ */
114
+ function streamOf(services: ResourcesLike | undefined, address: string): ResourceStream | undefined {
115
+ try { return services?.source?.(address) } catch { return undefined }
116
+ }
117
+
118
+ /** @private @author ddj 2026年10月31号
119
+ * 从资源快照读已就绪的计划正文。
120
+ * @param address 官方资源地址。
121
+ * @param stream 计划资源流。
122
+ * @returns 计划正文;未就绪、失败或内容不完整时 undefined。
123
+ */
124
+ function planOf(address: string, stream: ResourceStream): PlanDocument | undefined {
125
+ let snapshot: unknown
126
+ try { snapshot = stream.getSnapshot?.() } catch { return undefined }
127
+
128
+ const state = snapshot !== null && typeof snapshot === 'object' ? snapshot as { status?: unknown; value?: unknown } : {}
129
+ return state.status === 'live' ? planDocument(address, state.value) : undefined
130
+ }
131
+
132
+ /** @private @author ddj 2026年10月31号
133
+ * 订阅资源快照变化。
134
+ * @param stream 计划资源流。
135
+ * @param listener 变更监听。
136
+ * @returns 退订函数;订阅面缺失或抛错时为 no-op。
137
+ */
138
+ function watchStream(stream: ResourceStream, listener: () => void): () => void {
139
+ try {
140
+ const off = stream.subscribe?.(listener)
141
+ return typeof off === 'function' ? off as () => void : () => undefined
142
+ } catch { return () => undefined }
143
+ }
144
+
145
+ //endregion
146
+
147
+ //region 直通与回落
148
+
149
+ /** @private @author ddj 2026年10月31号
150
+ * 回落官方方法(包装外的原入口)。
151
+ * @param address 官方资源地址。
152
+ * @param options 官方参数。
153
+ * @returns 无;失败只记日志,不影响点击链路。
154
+ */
155
+ function native(address: string, options?: PlanOpenOptions): void {
156
+ if (typeof original !== 'function') return
157
+ try { original.call(service, address, options) } catch (error) { log.warn('官方计划页签回退失败:' + String(error)) }
158
+ }
159
+
160
+ /** @private @author ddj 2026年10月31号
161
+ * 把已解析的计划送进插件编辑器;地址级在途合并,重复点击不叠开。
162
+ * @param plan 计划正文。
163
+ * @param session 归属会话。
164
+ * @param address 官方资源地址。
165
+ * @param options 官方参数(回落时原样透传)。
166
+ * @returns 无;未就绪或失败时回落官方页签,但不劫持已切换的会话。
167
+ */
168
+ function direct(plan: PlanDocument, session: string, address: string, options?: PlanOpenOptions): void {
169
+ if (closed || pending.has(address)) return
170
+
171
+ const controller = new AbortController()
172
+ pending.set(address, () => { pending.delete(address); controller.abort() })
173
+ rememberPlan(plan)
174
+
175
+ /** @private @author ddj 2026年10月31号 @param ok 编辑器是否就绪。 @returns 无;失败时回落官方页签。 */
176
+ function settle(ok: boolean): void {
177
+ pending.delete(address)
178
+ if (ok || closed || readSessionScope(ctx).sessionId !== session) return
179
+ native(address, options)
180
+ }
181
+ requestOpen(null, undefined, undefined, session, { preview: true, plan, signal: controller.signal, timeoutMs: ROUTE_MS })
182
+ .then(settle, (error: unknown) => { log.warn('计划直通失败:' + String(error)); settle(false) })
183
+ }
184
+
185
+ /** @private @author ddj 2026年10月31号
186
+ * 从计划资源异步解析正文,就绪后直通;超时或无法解析回落官方页签。
187
+ * @param address 官方资源地址。
188
+ * @param session 归属会话。
189
+ * @param options 官方参数。
190
+ * @returns 无。
191
+ */
192
+ function lookup(address: string, session: string, options?: PlanOpenOptions): void {
193
+ const stream = streamOf(optionalService(ctx, 'resources') as ResourcesLike | undefined, address)
194
+ if (stream === undefined) { native(address, options); return }
195
+
196
+ const early = planOf(address, stream)
197
+ if (early !== undefined) { direct(early, session, address, options); return }
198
+ if (closed || pending.has(address)) return
199
+
200
+ let timer: ReturnType<typeof setTimeout> | undefined
201
+ let stop: (() => void) | undefined
202
+ let done = false
203
+
204
+ /** @private @author ddj 2026年10月31号 @param ok 是否已拿到正文。 @returns 无;幂等收尾,清定时器、退订、释放地址。 */
205
+ function finish(ok: boolean): void {
206
+ if (done) return
207
+ done = true
208
+ if (timer !== undefined) clearTimeout(timer)
209
+ stop?.()
210
+ pending.delete(address)
211
+ if (!ok && !closed) native(address, options)
212
+ }
213
+ pending.set(address, () => finish(false))
214
+ timer = setTimeout(() => finish(false), RESOLVE_MS)
215
+ stop = watchStream(stream, () => {
216
+ const plan = planOf(address, stream)
217
+ if (plan === undefined) return
218
+ finish(true)
219
+ direct(plan, session, address, options)
220
+ })
221
+ }
222
+
223
+ //endregion
224
+
225
+ //region 装配
226
+
227
+ /** @private @author ddj 2026年10月31号 @param address 官方资源地址。 @param options 官方参数。 @returns 无;包装后的 openResource 入口。 */
228
+ function open(address: string, options?: PlanOpenOptions): void {
229
+ const session = take(address)
230
+ if (session === undefined) { native(address, options); return }
231
+
232
+ const known = inline(address, options) ?? cachedPlan(address)
233
+ if (known !== undefined) { direct(known, session, address, options); return }
234
+ lookup(address, session, options)
235
+ }
236
+
237
+ /** @private @author ddj 2026年10月31号 @returns 无;赋值后校验,未生效即降级为原认领转交链路。 */
238
+ function mount(): void {
239
+ if (typeof original !== 'function') return
240
+ try { service.openResource = open } catch (error) { log.warn('计划直通装配失败:' + String(error)) }
241
+ installed = service.openResource === open
242
+ if (installed) return
243
+ try { service.openResource = original } catch { /* 只读服务:保持原状 */ }
244
+ }
245
+
246
+ /** @private @author ddj 2026年10月31号 @returns 无;仅在包装仍是自己时还原(HMR 安全)。 */
247
+ function unmount(): void {
248
+ if (!installed || service.openResource !== open) return
249
+ try { service.openResource = original } catch (error) { log.warn('计划直通还原失败:' + String(error)) }
250
+ installed = false
251
+ }
252
+
253
+ /** @private @author ddj 2026年10月31号 @returns 无;卸载时先标记关闭,再中止在途并还原官方方法。 */
254
+ function dispose(): void {
255
+ if (closed) return
256
+ closed = true
257
+ for (const cancel of [...pending.values()]) { try { cancel() } catch { /* 收尾失败不影响卸载 */ } }
258
+ pending.clear()
259
+ unmount()
260
+ }
261
+
262
+ mount()
263
+ return { dispose, native }
264
+
265
+ //endregion
266
+ }
@@ -4,6 +4,8 @@ export interface PlanOpen { path?: string; plan?: PlanDocument }
4
4
  export interface PlanEntry { type?: string; event: { type: string; seq?: number; data: unknown } }
5
5
  export interface PlanWindow { revision: number; entries: readonly PlanEntry[]; change: { kind: string; entries?: readonly PlanEntry[] } }
6
6
  const associated = new Map<string, Set<string>>()
7
+ /** 地址 → 已解析计划正文(只留内存,供后续点击省掉一次资源解析)。 */
8
+ const documents = new Map<string, PlanDocument>()
7
9
 
8
10
  /** @private @author ddj 2026年10月08号 @param value JSON candidate. @returns Object or empty record. */
9
11
  function record(value: unknown): Record<string, unknown> {
@@ -59,8 +61,24 @@ function associate(sessionId: string, path: string, cwd: string): void {
59
61
  associated.set(sessionId, paths)
60
62
  if (associated.size > 32) associated.delete(associated.keys().next().value!)
61
63
  }
64
+ /** @public @author ddj 2026年10月31号
65
+ * 记住一份已解析计划,供「查看全文」时免去资源解析。
66
+ * @param plan 已解析计划。
67
+ * @returns 无;只缓存合法地址与正文,超过上限按插入序淘汰。
68
+ */
69
+ export function rememberPlan(plan: PlanDocument): void {
70
+ const valid = planDocument(plan.address, plan)
71
+ if (valid === undefined) return
72
+
73
+ documents.set(valid.address, valid)
74
+ if (documents.size > 64) documents.delete(documents.keys().next().value!)
75
+ }
76
+
77
+ /** @public @author ddj 2026年10月31号 @param address 官方资源地址。 @returns 已缓存的计划正文。 */
78
+ export function cachedPlan(address: string): PlanDocument | undefined { return documents.get(address) }
79
+
62
80
  /** @public @author ddj 2026年10月08号 Clear associations when the plugin unloads. */
63
- export function clearPlanState(): void { associated.clear() }
81
+ export function clearPlanState(): void { associated.clear(); documents.clear() }
64
82
 
65
83
  /** @public @author ddj 2026年10月08号
66
84
  * Reduce only live append deltas; hydration, pagination, reconnect and duplicate sequences never open plans.
@@ -91,7 +109,7 @@ export function createPlanFeed(sessionId: string, cwd = '') {
91
109
  if (event.type === 'tool/result') return undefined
92
110
  const address = 'dsh-resource://plan/' + encodeURIComponent(sessionId) + '/' + encodeURIComponent(id)
93
111
  const plan = planDocument(address, { markdown: call.args.plan })
94
- if (plan) { remember(id); return { plan } }
112
+ if (plan) { rememberPlan(plan); remember(id); return { plan } }
95
113
  return undefined
96
114
  }
97
115
  if (!settled || !['write', 'edit'].includes(call.name) || typeof call.args.file_path !== 'string') return undefined
@@ -44,7 +44,11 @@ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Se
44
44
  for (const item of page?.items ?? []) {
45
45
  if (!item.id.startsWith(PENDING_PREFIX)) continue
46
46
  const path = item.currentPath || item.path
47
+ // 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
48
+ // 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
49
+ // 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
47
50
  covered.add(diffPathKey(path))
51
+ covered.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
48
52
  rows.push({
49
53
  path,
50
54
  label: item.display || path,
@@ -206,8 +206,15 @@ export function capabilityOf(entry: PendingEntry, flags: PendingFlags): { keep:
206
206
  * @author ddj 2026年10月29号
207
207
  * @description: 供详情接口复用的差异构造。
208
208
  * @param entry 待审条目
209
+ * @param baselineEmpty 基线置空:文件由本次会话新建,旧基线只是会话自己的中间产物
209
210
  * @returns 官方差异
210
211
  */
211
- export function diffOf(entry: PendingEntry): OfficialDiff {
212
- return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
212
+ export function diffOf(entry: PendingEntry, baselineEmpty = false): OfficialDiff {
213
+ if (!baselineEmpty) return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
214
+ // 空基线在按行切分时会留下一个空行,差异里会多出一条「删空行」;新增文件应当只有新增行,
215
+ // 与官方整文件新增块(oldLines=0、全是 + 行)保持同一形状。
216
+ if (entry.newText === '') return textDiff(entry.path, entry.path, '', '')
217
+ const diff = textDiff(entry.path, entry.path, '', entry.newText)
218
+ if (diff.kind !== 'text') return diff
219
+ return { ...diff, hunks: diff.hunks.map((hunk) => ({ ...hunk, oldLines: 0, lines: hunk.lines.filter((line) => line !== '-') })) }
213
220
  }
@@ -25,10 +25,11 @@ import {
25
25
  * @author ddj 2026年10月29号
26
26
  * @description: 统计一次差异的增删行数;非文本差异统一记 0/0。
27
27
  * @param entry 待审条目
28
+ * @param baselineEmpty 基线置空(新建文件按全量新增计)
28
29
  * @returns 增删行数
29
30
  */
30
- function countsOf(entry: PendingEntry): { added: number; deleted: number } {
31
- const diff = diffOf(entry)
31
+ function countsOf(entry: PendingEntry, baselineEmpty = false): { added: number; deleted: number } {
32
+ const diff = diffOf(entry, baselineEmpty)
32
33
  if (diff.kind !== 'text') return { added: 0, deleted: 0 }
33
34
  let added = 0
34
35
  let deleted = 0
@@ -93,11 +94,12 @@ export function foldWrite(stored: StoredEntry | undefined, write: PendingWrite):
93
94
  * @author ddj 2026年10月29号
94
95
  * @description: 条目转列表投影,已解决条目仍保留但不可再决定。
95
96
  * @param stored 条目与标记
97
+ * @param baselineEmpty 基线置空(新建文件按全量新增计)
96
98
  * @returns 列表项
97
99
  */
98
- export function itemOf(stored: StoredEntry): PendingItem {
100
+ export function itemOf(stored: StoredEntry, baselineEmpty = false): PendingItem {
99
101
  const { entry, flags } = stored
100
- const counts = countsOf(entry)
102
+ const counts = countsOf(entry, baselineEmpty)
101
103
  const item: PendingItem = {
102
104
  id: entry.id,
103
105
  path: entry.path,
@@ -123,12 +125,13 @@ export function itemOf(stored: StoredEntry): PendingItem {
123
125
  * @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
124
126
  * @param cwd 工作区
125
127
  * @param sessionId 当前会话
128
+ * @param wholeAdd 本次会话新建的文件身份集合,命中时基线置空
126
129
  * @returns 列表
127
130
  */
128
- export function loadPage(cwd: string, sessionId?: string): PendingPage {
131
+ export function loadPage(cwd: string, sessionId?: string, wholeAdd?: ReadonlySet<string>): PendingPage {
129
132
  const all = loadEntries(cwd)
130
133
  const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all
131
- const items = visible.map(itemOf)
134
+ const items = visible.map((stored) => itemOf(stored, wholeAdd?.has(stored.entry.id) === true))
132
135
  const gaps: string[] = []
133
136
  for (const stored of visible) {
134
137
  if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`)
@@ -141,14 +144,15 @@ export function loadPage(cwd: string, sessionId?: string): PendingPage {
141
144
  * @description: 读单条详情,包含可交给现有面板渲染的差异与块决定。
142
145
  * @param cwd 工作区
143
146
  * @param id 条目身份
147
+ * @param baselineEmpty 基线置空(新建文件按全量新增渲染)
144
148
  * @returns 详情,条目不存在时 null
145
149
  */
146
- export function loadDetail(cwd: string, id: string): PendingDetail | null {
150
+ export function loadDetail(cwd: string, id: string, baselineEmpty = false): PendingDetail | null {
147
151
  const stored = loadEntries(cwd).find((item) => item.entry.id === id)
148
152
  if (!stored) return null
149
- const diff = diffOf(stored.entry)
153
+ const diff = diffOf(stored.entry, baselineEmpty)
150
154
  return {
151
- item: itemOf(stored),
155
+ item: itemOf(stored, baselineEmpty),
152
156
  diff,
153
157
  blocks: blockStatesOf(stored.entry),
154
158
  capability: capabilityOf(stored.entry, stored.flags),
@@ -171,7 +171,13 @@ export function pendingCovers(cwd: string, path: string): boolean {
171
171
  * @returns 合并后的页
172
172
  */
173
173
  export function mergedPage(official: ReviewPage, pending: PendingPage, cwd?: string): ReviewPage {
174
- const covered = new Set(pending.items.map((item) => pathKey(item.path)))
174
+ const covered = new Set<string>()
175
+ for (const item of pending.items) {
176
+ // 待审条目的 path 是写入时的原始写法(常为工作区相对路径),官方条目用绝对身份:
177
+ // 只按 path 建覆盖集时同一文件会同时留下待审行与官方行,差异栏把一份改动算成两条。
178
+ covered.add(pathKey(item.path))
179
+ covered.add(pathKey(item.id))
180
+ }
175
181
  const kept = official.items.filter((item) => !covered.has(pathKey(item.currentPath)) && !covered.has(pathKey(item.path)))
176
182
  const items = [...pending.items.map((item) => pendingItemOf(item, cwd)), ...kept]
177
183
  return {
package/src/pendingRpc.ts CHANGED
@@ -8,6 +8,7 @@ import { readText } from './pending/capture.js'
8
8
  import { pendingRawId, trackPendingCover } from './pendingCompat.js'
9
9
  import { loadEntries, saveEntry } from './pending/persist.js'
10
10
  import { applyDecide, importFromVcs, itemOf, loadDetail, loadPage, refreshed, removeEntry, undoDecide } from './pending/store.js'
11
+ import { addedFiles } from './reviewDb.js'
11
12
  import type { ShellLike } from './pending/vcs.js'
12
13
  import type { Ctx } from './store.js'
13
14
  import { policyOf } from './store.js'
@@ -82,14 +83,16 @@ function readHandlers(kit: ScopeKit): Partial<RpcHandlerMap> {
82
83
  return {
83
84
  'edrv.pending.list': async (args: PendingRequest) => {
84
85
  const cwd = await kit.read(args.sessionId)
85
- const page = loadPage(cwd, args.sessionId)
86
+ // 与官方入口同口径:本会话新建的文件基线置空,列表里的增删行数才等于整文件行数
87
+ const page = loadPage(cwd, args.sessionId, addedFiles(cwd, args.sessionId))
86
88
  // 列表即登记接管关系;旧写入口守卫之后只读内存集合,不再产生数据库读
87
89
  for (const item of page.items) trackPendingCover(cwd, item.path, true)
88
90
  return { ok: true, page }
89
91
  },
90
92
  'edrv.pending.detail': async (args: PendingRequest & { id: string }) => {
91
93
  const cwd = await kit.read(args.sessionId)
92
- const detail = loadDetail(cwd, pendingRawId(args.id))
94
+ const rawId = pendingRawId(args.id)
95
+ const detail = loadDetail(cwd, rawId, addedFiles(cwd, args.sessionId).has(rawId))
93
96
  return detail ? { ok: true, detail } : { ok: false, error: '待审条目不存在' }
94
97
  },
95
98
  }
package/src/reviewDb.ts CHANGED
@@ -205,6 +205,31 @@ export function reviewPage(cwd: string, cursor = 0, history = false, path?: stri
205
205
 
206
206
  }
207
207
 
208
+ /**
209
+ * @public
210
+ * @author ddj 2026年10月31号
211
+ * @description: 该会话里由官方记录判为「整文件新增」的文件身份集合。待审条目命中时基线要置空:
212
+ * 文件是本会话新建的,累计基线只是会话自己的中间产物,拿它做旧侧会把新增文件渲染成一堆修改块。
213
+ * @param cwd 迁移后的工作区
214
+ * @param sessionId 会话
215
+ * @returns 整文件新增的文件身份集合
216
+ */
217
+ export function addedFiles(cwd: string, sessionId: string): Set<string> {
218
+ try {
219
+ const db = dbFor(cwd)
220
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return new Set()
221
+ // 先按会话收窄再取每个文件最早的那条官方记录:首个块是整文件新增(before=false)才说明文件
222
+ // 由本次会话创建,后续修改块不能反过来把新建成改写成「有旧基线的修改」。
223
+ const rows = db.prepare(`SELECT identity FROM (SELECT identity,
224
+ json_extract(diff,'$.before') AS before, ROW_NUMBER() OVER (PARTITION BY identity ORDER BY turn, seq) AS rank
225
+ FROM review_files WHERE session_id=?) WHERE rank=1 AND before=0`).all(sessionId) as Row[]
226
+ return new Set(rows.map((row) => String(row.identity)))
227
+ } catch {
228
+ // 判定失败只退回旧的累计基线口径,不影响列表与详情可用
229
+ return new Set()
230
+ }
231
+ }
232
+
208
233
  /**
209
234
  * @public
210
235
  * @author ddj 2026年10月31号
package/src/reviewRpc.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
3
  import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
4
4
  import type { PendingPage } from './shared/pendingReview.js'
5
- import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
5
+ import { addedFiles, decideReview, reviewDetail, reviewPage } from './reviewDb.js'
6
6
  import { undoReview } from './reviewUndo.js'
7
7
  import { applyDecide, loadDetail, loadPage } from './pending/store.js'
8
8
  import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
@@ -153,7 +153,9 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
153
153
  */
154
154
  function pendingPageOf(cwd: string, sessionId: string): PendingPage {
155
155
  try {
156
- const page = loadPage(cwd, sessionId)
156
+ // 本会话新建的文件由官方首个块判为整文件新增,待审基线必须跟着置空:
157
+ // 否则同一次新建会同时存在「官方说全加 516 行」与「待审说改 8 处」两套相反口径。
158
+ const page = loadPage(cwd, sessionId, addedFiles(cwd, sessionId))
157
159
  // 已结算(老文本与新文本一致)的投影没有净变化可审:留着会顶掉同路径的官方待审记录,
158
160
  // 让差异栏选中一条 Keep 不可用的条目,表现为「按钮亮着却点不动」。
159
161
  return page.items.some((item) => item.settled === true)
@@ -192,7 +194,9 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
192
194
  const scope = await scopeOf(args.sessionId)
193
195
  const cwd = scope.cwd
194
196
  if (args.id.startsWith(PENDING_PREFIX)) {
195
- const detail = loadDetail(cwd, args.id.slice(PENDING_PREFIX.length))
197
+ const entryId = args.id.slice(PENDING_PREFIX.length)
198
+ // 详情侧与列表侧必须同一口径:整文件新增的文件用空基线渲染,差异块才是全绿一整块
199
+ const detail = loadDetail(cwd, entryId, addedFiles(cwd, args.sessionId).has(entryId))
196
200
  if (!detail) return { ok: false, error: '待审条目不存在' }
197
201
 
198
202
  const mapped = pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)