dsh-vscode-mode 0.23.2 → 0.23.4

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.2",
3
+ "version": "0.23.4",
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",
@@ -1,6 +1,30 @@
1
1
  import type { MissingReview } from '../shared/types.js'
2
2
  import { splitLines } from '../shared/diff.js'
3
3
 
4
+ /** 缺失文件终态:文件已在磁盘删除,且没有可展示的整文件预览。 */
5
+ export interface GoneState {
6
+ title: string
7
+ message: string
8
+ /** 该文件还有官方待审改动:Keep 可在差异栏结案,Undo 不可用。 */
9
+ official: boolean
10
+ }
11
+
12
+ /**
13
+ * @public
14
+ * @author ddj 2026年10月31号
15
+ * @description: 把 missingReview 的失败归并成「文件已删除」终态。只有确认删除的拒绝码才是终态;
16
+ * 会话缺失、权限、文件重新出现等失败返回 null,调用方继续走通用加载失败(可重试、保留跨端提示)。
17
+ * @param failure RPC 失败结果(error 必填,code 可选)
18
+ * @returns 终态信息或 null
19
+ */
20
+ export function goneStateOf(failure: { error?: unknown; code?: unknown } | null | undefined): GoneState | null {
21
+ const code = failure?.code
22
+ const official = code === 'official-covered'
23
+ if (!official && code !== 'official-decided' && code !== 'no-records' && code !== 'no-evidence' && code !== 'too-large') return null
24
+ const message = String(failure?.error ?? '文件已在磁盘删除').replace(/^Error:\s*/, '')
25
+ return { title: official ? '文件已在磁盘删除 · 有官方待审改动' : '文件已在磁盘删除', message, official }
26
+ }
27
+
4
28
  /** @public @author ddj 2026年10月28号 @param review Frozen complete snapshot. @returns One whole-file deletion, including empty files; no invented current lines. */
5
29
  export function missingRegion(review: MissingReview) {
6
30
  return {
@@ -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
@@ -62,7 +62,7 @@ import { applyOfficial, editorFont, registerThemes, themeNameOf } from '../monac
62
62
  import { createDiffRenderer } from '../monaco/diffRender.js'
63
63
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
64
64
  import { diffRegions } from '../state/regions.js'
65
- import { missingBatch, missingRegion } from '../missingReview.js'
65
+ import { goneStateOf, missingBatch, missingRegion } from '../missingReview.js'
66
66
  import { QuickOpen } from './QuickOpen.js'
67
67
  import { attachHorizontalWheel } from './horizontalWheel.js'
68
68
  import { CommandPalette } from './CommandPalette.js'
@@ -343,6 +343,11 @@ export function EditorView(props) {
343
343
  const aiStatusTimer = React.useRef(null)
344
344
  const [error, setError] = React.useState(null)
345
345
  const [loadError, setLoadError] = React.useState(null)
346
+ // 缺失文件的终态:{ path, sessionId, title, message, official }。
347
+ // 与 loadError 区分开——加载失败可以重试,磁盘删除是终态,不能一直卡在进度条 0%。
348
+ const [goneFile, setGoneFile] = React.useState(null)
349
+ const missingReadRef = React.useRef(new Map()) // path → 已重读次数(防 文件缺失↔重现 抖动死循环)
350
+ const goneNow = goneFile && goneFile.path === active && goneFile.sessionId === sessionId ? goneFile : null
346
351
  const [largeCandidate, setLargeCandidate] = React.useState(null)
347
352
  const [pagedFile, setPagedFile] = React.useState(null)
348
353
  const [pageChanged, setPageChanged] = React.useState(null)
@@ -1173,17 +1178,49 @@ export function EditorView(props) {
1173
1178
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
1174
1179
  * @param version 已知磁盘版本(缺省走 RPC 返回值)
1175
1180
  */
1176
- /** @private @author ddj 2026年10月28号 @param path Missing text file. @param sid Owning session. @param seq Loader generation. @sideEffects Loads deletion evidence without creating or saving the file. */
1181
+ /**
1182
+ * 把「文件已在磁盘删除」记成终态面板:不占进度条、不反复重试,并允许直接关闭页签。
1183
+ * 只有确认删除的原因才走终态;会话/权限类失败仍回退通用加载失败,保留跨端提示。
1184
+ * @private @author ddj 2026年10月31号
1185
+ * @param path 目标文件
1186
+ * @param sid 会话 id
1187
+ * @param seq 加载序号(过期响应丢弃)
1188
+ * @param failure 宿主拒绝原因(可带稳定 code)
1189
+ */
1190
+ function settleGone(path, sid, seq, failure) {
1191
+ if (!loadCurrent(path, seq)) return
1192
+ const error = failure && typeof failure === 'object' ? failure : { error: failure }
1193
+ const gone = goneStateOf(error)
1194
+ ?? (error.deleted === true ? { title: '文件已在磁盘删除', official: false, message: String(error.error ?? '') } : null)
1195
+ if (!gone) { failLoad(path, sid, seq, String(error.error ?? error)); return }
1196
+ stampLoad(path, sid, seq, gone.message)
1197
+ setLoadError(null)
1198
+ setError(null)
1199
+ setGoneFile({ ...gone, path, sessionId: sid })
1200
+ setStatus(gone.official ? '文件已删除 · 差异栏可 Keep 结案' : '文件已删除')
1201
+ }
1202
+
1203
+ /** @private @author ddj 2026年10月28号 / 2026年10月31号 @param path Missing text file. @param sid Owning session. @param seq Loader generation. @sideEffects Loads deletion evidence without creating or saving the file. */
1177
1204
  async function loadMissing(path, sid, seq) {
1178
1205
  try {
1179
1206
  const result = await rpc('edrv.missingReview', { sessionId: sid, path })
1180
1207
  if (!loadCurrent(path, seq)) return
1181
- if (!result.ok) { failLoad(path, sid, seq, result.error); return }
1182
- if (!result.review) { loadContent(path, sid, true); return }
1208
+ if (!result.ok) { settleGone(path, sid, seq, result); return }
1209
+ if (!result.review) {
1210
+ // 宿主观测到文件仍在磁盘(与读取时的 file-missing 竞态):重读一次即可;
1211
+ // 同一文件反复抖动时收尾为终态,绝不再递归重读。
1212
+ const attempts = (missingReadRef.current.get(path) ?? 0) + 1
1213
+ missingReadRef.current.set(path, attempts)
1214
+ if (attempts <= 2) { loadContent(path, sid, true); return }
1215
+ settleGone(path, sid, seq, { deleted: true, error: '文件状态在磁盘上反复变化,请稍后重试,或关闭该标签页' })
1216
+ return
1217
+ }
1218
+ missingReadRef.current.delete(path)
1183
1219
  if (dirtyRef.current[path] || savingRef.current.has(path)) { failLoad(path, sid, seq, '文件已删除,但有未保存编辑;请先处理本地缓冲'); return }
1184
1220
  const preview = { ...result.review, sessionId: sid, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
1185
1221
  missingRef.current.set(path, preview)
1186
1222
  setMissingPreview(preview)
1223
+ setGoneFile(null)
1187
1224
  setContent('')
1188
1225
  setContentPath(path)
1189
1226
  setLoadError(null)
@@ -1198,6 +1235,7 @@ export function EditorView(props) {
1198
1235
  const seq = ++loadSeqRef.current
1199
1236
  setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
1200
1237
  setLoadError(null)
1238
+ setGoneFile(null)
1201
1239
  setLargeCandidate(null)
1202
1240
  setPagedFile(null)
1203
1241
  setPageChanged(null)
@@ -4668,6 +4706,25 @@ export function EditorView(props) {
4668
4706
  React.createElement('div', { className: 'edrv-loading-percent' }, Math.round(progress) + '%'),
4669
4707
  retry ? React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: retry }, '重试') : null)
4670
4708
 
4709
+ /**
4710
+ * 渲染缺失文件终态面板:说明 + 重试 + 关闭页签(不显示进度条,避免卡在 0%)。
4711
+ * @private @author ddj 2026年10月31号
4712
+ * @param gone 终态信息
4713
+ * @param retry 重新读取回调
4714
+ * @param close 关闭该标签页回调
4715
+ * @returns 终态面板 React 元素
4716
+ */
4717
+ const goneBody = (gone, retry, close) => React.createElement('div', { className: 'edrv-empty edrv-loading', 'aria-live': 'polite', role: 'status' },
4718
+ React.createElement('div', { className: 'edrv-loading-title' }, gone.title),
4719
+ React.createElement('div', { style: { fontSize: 12, maxWidth: 460, lineHeight: 1.6 } }, gone.message),
4720
+ gone.official
4721
+ ? React.createElement('div', { style: { fontSize: 12, maxWidth: 460, lineHeight: 1.6, opacity: 0.8 } },
4722
+ '待审改动在差异栏:Keep 保留删除结果并结案;Undo 需要该文件的原始内容,磁盘已删除时不可用。')
4723
+ : null,
4724
+ React.createElement('div', { style: { display: 'flex', gap: 8 } },
4725
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: retry }, '重试'),
4726
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: close }, '关闭标签页')))
4727
+
4671
4728
  let body
4672
4729
  if (activePlan) {
4673
4730
  const labels = PLAN_MD_LABELS
@@ -4696,6 +4753,12 @@ export function EditorView(props) {
4696
4753
  body = React.createElement('div', { className: 'edrv-empty' },
4697
4754
  React.createElement('div', null, '暂无打开的文件'),
4698
4755
  React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;' + (chordOf('edrv.showCommands') ?? 'Ctrl+Shift+P') + ' 打开命令栏;agent 修改文件后顶部会出现差异角标'))
4756
+ } else if (goneNow) {
4757
+ // 终态:文件已在磁盘删除且没有可用预览。不再显示 0% 进度条,也不无限重试。
4758
+ body = goneBody(goneNow, () => {
4759
+ missingReadRef.current.delete(goneNow.path)
4760
+ loadContent(active, sessionId, true)
4761
+ }, () => { void closeTab(goneNow.path) })
4699
4762
  } else if (content === null && loadError && isSessionMissingError(loadError)) {
4700
4763
  // 跨端会话缺失:终态提示而非加载失败卡死(会话仅在一端激活,本端 RPC 均被拒)
4701
4764
  body = React.createElement('div', { className: 'edrv-empty edrv-loading', 'aria-live': 'polite' },
@@ -2,9 +2,10 @@
2
2
  import { createHash, randomUUID } from 'node:crypto'
3
3
  import { resolve } from 'node:path'
4
4
  import type { DiffRecord, MissingReview } from './shared/types.js'
5
+ import type { MissingRefusal } from './shared/rpc.js'
5
6
  import type { Ctx, Session } from './store.js'
6
7
  import { archiveRecords, policyOf, READ_CAP, resolveTargetIn, targetPathOf } from './store.js'
7
- import { hasOfficial } from './reviewDb.js'
8
+ import { hasOfficial, officialPending } from './reviewDb.js'
8
9
  import { recordResolved } from './model.js'
9
10
 
10
11
  interface FrozenReview {
@@ -18,6 +19,32 @@ interface FrozenReview {
18
19
  const frozen = new Map<string, FrozenReview>()
19
20
  const locks = new Set<string>()
20
21
 
22
+ /**
23
+ * @public
24
+ * @author ddj 2026年10月31号
25
+ * @description: 给缺失文件预览的可预期拒绝挂稳定错误码。客户端据此显示终态说明,
26
+ * 而不是把内部错误原文当「加载失败」反复重试;也不让上游把 SQLite 报错原文透给编辑器。
27
+ * @param code 稳定错误码(official-covered / no-records / no-evidence / too-large)
28
+ * @param message 可读原因
29
+ * @returns 带 code 的 Error
30
+ */
31
+ export function refusal(code: MissingRefusal, message: string): Error {
32
+ return Object.assign(new Error(message), { code })
33
+ }
34
+
35
+ /**
36
+ * @public
37
+ * @author ddj 2026年10月31号
38
+ * @description: 从任意错误里取回缺失文件预览的稳定拒绝码;不是本模块抛出的错误一律返回 undefined。
39
+ * @param error 捕获到的错误
40
+ * @returns 稳定拒绝码或 undefined
41
+ */
42
+ export function refusalCodeOf(error: unknown): MissingRefusal | undefined {
43
+ const code = (error as { code?: unknown } | null)?.code
44
+ return code === 'official-covered' || code === 'official-decided' || code === 'no-records'
45
+ || code === 'no-evidence' || code === 'too-large' ? code : undefined
46
+ }
47
+
21
48
  /** @private @author ddj 2026年10月28号 @param target Resolved absolute path. @returns Platform file identity. */
22
49
  function fileKey(target: string): string {
23
50
  const normalized = resolve(target).replace(/\\/g, '/')
@@ -67,13 +94,25 @@ export async function missingReview(ctx: Ctx, session: Session, cwd: string, buc
67
94
  // 解析结果可能是不透明 FsTarget,凡按路径字符串使用的分支都必须先还原。
68
95
  const abs = targetPathOf(ctx, target)
69
96
  if (!abs) throw new Error('无法解析目标文件路径')
70
- if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
97
+ // 官方记录覆盖该文件时不再给整文件删除预览:删除内容只能用差异栏的 Keep 结案,
98
+ // 但必须带稳定错误码,客户端才能显示「已删除」终态而不是卡在加载失败。
99
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) {
100
+ if (!officialPending(cwd, abs) && !officialPending(cwd, path)) {
101
+ throw refusal('official-decided', '该文件已在磁盘删除,官方审查记录均已处理:可直接关闭该标签页')
102
+ }
103
+ throw refusal('official-covered', '该文件已有官方审查记录(磁盘上已删除):请在差异栏对该待审条目执行 Keep 结案;'
104
+ + '整文件删除预览只适用于待审历史记录')
105
+ }
71
106
  if (!await fileAbsent(ctx, cwd, abs)) throw new Error('文件已重新出现,请刷新')
72
107
  const records = await fileRecords(ctx, cwd, bucket, abs)
73
108
  const latest = records.at(-1)
74
- if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
75
- if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
76
- if (Buffer.byteLength(latest.after, 'utf8') > READ_CAP) throw new Error('历史快照过大,无法整文件预览')
109
+ if (!records.some((record) => !record.archived && !recordResolved(record))) {
110
+ throw refusal('no-records', '磁盘上已没有该文件,且没有待审历史记录:可直接关闭该标签页')
111
+ }
112
+ if (!latest || typeof latest.after !== 'string') {
113
+ throw refusal('no-evidence', '磁盘上已没有该文件,且缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
114
+ }
115
+ if (Buffer.byteLength(latest.after, 'utf8') > READ_CAP) throw refusal('too-large', '磁盘上已没有该文件,且历史快照过大,无法整文件预览')
77
116
 
78
117
  for (const [token, item] of frozen) if (item.expires < Date.now()) frozen.delete(token)
79
118
  if (frozen.size >= 256) throw new Error('缺失文件预览过多,请稍后重试')