dsh-vscode-mode 0.26.0 → 0.27.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.
@@ -274,11 +274,11 @@ export function EditorView(props) {
274
274
  const [monacoErr, setMonacoErr] = React.useState(null)
275
275
  const [records, setRecords] = React.useState({})
276
276
  const [review, setReview] = React.useState({ page: null, error: '' })
277
+ React.useEffect(() => { setReview({ page: null, error: '' }) }, [sessionId])
277
278
  React.useEffect(() => {
278
- setReview({ page: null, error: '' })
279
- if (!sessionId) return undefined
279
+ if (!sessionId || props.visible === false) return undefined
280
280
  return subscribeReview(sessionId, setReview)
281
- }, [sessionId])
281
+ }, [sessionId, props.visible])
282
282
  const [tabs, setTabs] = React.useState([])
283
283
  const [active, setDiskActive] = React.useState(null)
284
284
  const [planTabs, setPlanTabs] = React.useState([])
@@ -1454,12 +1454,12 @@ export function EditorView(props) {
1454
1454
 
1455
1455
  // 挂载:接入会话级共享订阅(单定时器由 recordsFeed 持有,本组件不再自建 interval)
1456
1456
  React.useEffect(() => {
1457
- if (!sessionId) return undefined
1457
+ if (!sessionId || props.visible === false) return undefined
1458
1458
  const offRecords = subscribeRecords(sessionId, applyRecords)
1459
1459
  const offErrors = subscribeErrors(sessionId, (message) => setError('list异常:' + message))
1460
1460
  refreshRecords()
1461
1461
  return () => { offRecords(); offErrors() }
1462
- }, [sessionId])
1462
+ }, [sessionId, props.visible])
1463
1463
 
1464
1464
  /**
1465
1465
  * 展示外部同步提示(按路径+类型去重:同一文件重复回调不重置已关闭的提示)。
@@ -1560,12 +1560,21 @@ export function EditorView(props) {
1560
1560
  })
1561
1561
 
1562
1562
  React.useEffect(() => {
1563
- const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : [])
1563
+ let alive = true
1564
+ let loading = false
1565
+ const publishLsp = (servers) => { if (alive) setLspServers(Array.isArray(servers) ? servers : []) }
1566
+ const refreshLsp = async () => {
1567
+ if (loading || props.visible === false || document.hidden) return
1568
+ loading = true
1569
+ try { publishLsp(await refreshStatus(true)) }
1570
+ finally { loading = false }
1571
+ }
1564
1572
  const unsubscribe = onLspProgress(publishLsp)
1565
- void refreshStatus(true).then(publishLsp)
1566
- const timer = setInterval(() => { void refreshStatus(true).then(publishLsp) }, 1500)
1567
- return () => { unsubscribe(); clearInterval(timer) }
1568
- }, [sessionId])
1573
+ void refreshLsp()
1574
+ const timer = setInterval(refreshLsp, 1500)
1575
+ document.addEventListener('visibilitychange', refreshLsp)
1576
+ return () => { alive = false; unsubscribe(); clearInterval(timer); document.removeEventListener('visibilitychange', refreshLsp) }
1577
+ }, [sessionId, props.visible])
1569
1578
 
1570
1579
  // LSP 会话:EditorView 持有权威 sessionId,直接写入 lspClient 并广播。
1571
1580
  // 直接调用不依赖事件时序(订阅可能晚于首次派发),广播供其它消费方复用。
@@ -1609,6 +1618,9 @@ export function EditorView(props) {
1609
1618
  const fileOpenRef = React.useRef(null)
1610
1619
  /** @private @author ddj 2026年10月08号 @param e Routed file request. Always uses the current workspace and save/preview callbacks. */
1611
1620
  fileOpenRef.current = (e) => {
1621
+ if (e?.detail?.sessionId && e.detail.sessionId !== sessionId) return
1622
+ if (e?.detail?.ownerId && e.detail.ownerId !== props?.ownerId) return
1623
+ if (!e?.detail?.ownerId && props?.visible === false) return
1612
1624
  const p = e?.detail?.path
1613
1625
  if (!p) return
1614
1626
  // G9:入口统一归一为工作区相对路径(差异栏/对话链接/LSP 跳转等都经此事件)
@@ -4108,6 +4120,8 @@ export function EditorView(props) {
4108
4120
  }
4109
4121
  const tabMenuActions = (path) => ({
4110
4122
  'add-to-conversation': () => addRefToChat(path),
4123
+ 'forge-open': () => props.forge?.open(sessionId),
4124
+ 'forge-local': () => props.forge?.open(sessionId, true),
4111
4125
  'split-left': () => splitTab(path, 'left'),
4112
4126
  'split-right': () => splitTab(path, 'right'),
4113
4127
  'split-top': () => splitTab(path, 'top'),
@@ -4144,6 +4158,7 @@ export function EditorView(props) {
4144
4158
  cwd,
4145
4159
  hasSession: Boolean(sessionId),
4146
4160
  canAddToConversation: Boolean(addToConversation),
4161
+ forgeReady: props.forge?.available() === true,
4147
4162
  canSplit: workbench.groups.length < GROUP_LIMIT && !workbench.floats.some((item) => item.group === ownerOf(workbench, tabMenu.path)?.id),
4148
4163
  canFloat: workbench.groups.length < GROUP_LIMIT,
4149
4164
  canReturn: ownerOf(workbench, tabMenu.path)?.id !== MAIN_GROUP,
@@ -4442,6 +4457,25 @@ export function EditorView(props) {
4442
4457
  function consumeDrag() { return dragRef.current?.consumeClick() ?? false }
4443
4458
  /** @private @author ddj 2026年10月14号 @param path File. @param event Context trigger. */
4444
4459
  function paneMenu(path, event) { setTabMenu({ path, x: event.clientX, y: event.clientY }) }
4460
+ const menuOwner = React.useRef({ sessionId, scope, alive: true })
4461
+ menuOwner.current.sessionId = sessionId
4462
+ menuOwner.current.scope = scope
4463
+ const menuValid = React.useRef(null)
4464
+ React.useEffect(() => {
4465
+ menuOwner.current.alive = props.visible !== false
4466
+ return () => { menuOwner.current.alive = false; if (menuValid.current) props.officialMenus?.invalidate(menuValid.current) }
4467
+ }, [sessionId, scope, props.visible, props.officialMenus])
4468
+ React.useEffect(() => {
4469
+ if (!tabMenu || !props.officialMenus || !sessionId) return
4470
+ const capturedSession = sessionId
4471
+ const capturedScope = scope
4472
+ const path = tabMenu.path
4473
+ const groupId = ownerOf(benchRef.current, path)?.id
4474
+ const valid = () => menuOwner.current.alive && menuOwner.current.sessionId === capturedSession && menuOwner.current.scope === capturedScope && ownerOf(benchRef.current, path)?.id === groupId && tabsRef.current.some((tab) => tab.path === path)
4475
+ menuValid.current = valid
4476
+ const items = tabMenuEntries().map((entry) => ({ ...entry, run: entry.onClick ?? (() => {}) }))
4477
+ if (props.officialMenus.open('tab', sessionId, path, { x: tabMenu.x, y: tabMenu.y }, items, valid)) setTabMenu(null)
4478
+ }, [tabMenu, props.officialMenus, sessionId, scope])
4445
4479
  /** @private @author ddj 2026年10月14号 @param group Auxiliary group. Return documents rather than closing them. */
4446
4480
  function returnPane(group) { acceptLayout(returnGroup(benchRef.current, group)) }
4447
4481
  /** @private @author ddj 2026年10月14号 @param group Float id. @param rect Viewport-safe bounds. */
@@ -5047,6 +5081,9 @@ export function EditorView(props) {
5047
5081
  editor: () => editorRef.current,
5048
5082
  outlineSources: props.outlineSources,
5049
5083
  fileMenuItems: props.fileMenuItems,
5084
+ officialMenus: props.officialMenus,
5085
+ visible: props.visible,
5086
+ forge: props.forge,
5050
5087
  addToConversation,
5051
5088
  svn: svnStatus,
5052
5089
  // SVN 变更:面板列表 + 文件树徽标查表(客户端合成,不改 listDir 契约)
@@ -11,7 +11,7 @@ import React from 'react'
11
11
  import { pickSessionScope } from '../sessionScope.js'
12
12
  import { EditorView } from './EditorView.js'
13
13
  import { setSideEditorMounted } from '../sidebarBridge.js'
14
- import { editorMountEpoch, markEditorActive, markEditorMounted, parseOfficialFileAddress, resolveNavLine, resolveNavOpen } from '../officialSidebar.js'
14
+ import { editorMountEpoch, markEditorActive, markEditorMounted, parseOfficialFileAddress, resolveNavLine, resolveNavColumn, resolveNavOpen } from '../officialSidebar.js'
15
15
 
16
16
  /**
17
17
  * 官方侧边栏 Tab 正文组件(keyed slot sidebar.right.pane.tab 装配)。
@@ -22,10 +22,14 @@ export function OfficialSideTab(props) {
22
22
  const sessionId = props?.sessionId
23
23
  const useTabInfo = props?.useTabInfo
24
24
  let navigation
25
+ let tab
26
+ let visible = true
25
27
  // useTabInfo 是 slot 框架按组件实例生成的 hook,须在组件顶层无条件调用
26
28
  if (typeof useTabInfo === 'function') {
27
29
  try {
28
- navigation = useTabInfo()?.tab?.navigation
30
+ tab = useTabInfo()?.tab
31
+ navigation = tab?.navigation
32
+ visible = tab?.visible !== false
29
33
  } catch {
30
34
  navigation = undefined
31
35
  }
@@ -67,16 +71,16 @@ export function OfficialSideTab(props) {
67
71
  // file 资源 Tab:地址即文件(解码出真实路径,透传 params.line 行号定位)
68
72
  const fileHit = parseOfficialFileAddress(navigation?.address)
69
73
  if (fileHit) {
70
- const line = navigation?.params?.line
74
+ const line = resolveNavLine(navigation?.params)
71
75
  window.dispatchEvent(new CustomEvent('edrv:open-editor', {
72
- detail: { path: fileHit.path, line: typeof line === 'number' ? line : undefined, preview: navigation?.params?.preview },
76
+ detail: { sessionId, ownerId: tab?.id, path: fileHit.path, line, column: resolveNavColumn(navigation?.params), preview: navigation?.params?.preview },
73
77
  }))
74
78
  return
75
79
  }
76
80
  // 页类型 Tab:params 携带打开请求(line = 行引用/工具行跳转的行号定位)
77
81
  const request = resolveNavOpen(navigation?.params)
78
82
  window.dispatchEvent(new CustomEvent('edrv:open-editor', {
79
- detail: { path: request.path, focusDiff: request.focusDiff, line: resolveNavLine(navigation?.params), preview: navigation?.params?.preview },
83
+ detail: { sessionId, ownerId: tab?.id, path: request.path, focusDiff: request.focusDiff, line: resolveNavLine(navigation?.params), column: resolveNavColumn(navigation?.params), preview: navigation?.params?.preview },
80
84
  }))
81
85
  })
82
86
 
@@ -84,11 +88,15 @@ export function OfficialSideTab(props) {
84
88
  key: sessionId,
85
89
  sessionId,
86
90
  layout: 'side',
91
+ visible,
92
+ ownerId: tab?.id,
87
93
  schedule: props?.schedule,
88
94
  addToConversation: props?.addToConversation,
89
95
  sidebarPanels: props?.sidebarPanels,
90
96
  outlineSources: props?.outlineSources,
91
97
  fileMenuItems: props?.fileMenuItems,
98
+ officialMenus: props?.officialMenus,
99
+ forge: props?.forge,
92
100
  fileOpeners: props?.fileOpeners,
93
101
  sessions: props?.sessions,
94
102
  })
@@ -2,15 +2,32 @@
2
2
  * 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
3
3
  * @author ddj 2026年10月29号
4
4
  */
5
- import { cwdOf } from '../registry.js'
6
- import { resolveTarget } from '../store.js'
5
+ import { cwdOfSession } from '../store.js'
7
6
  import type { Ctx } from '../store.js'
8
7
  import { log } from '../log.js'
8
+ import { toolTargets, patchDiagnostics } from '../toolTargets.js'
9
+ import { rawText, rawReviewFile } from '../reviewEvidence.js'
9
10
 
10
11
  /** 可被采信为待审来源的写文件工具。 */
11
- const WRITE_TOOLS = ['edit', 'write', 'str_replace_editor']
12
+ const WRITE_TOOLS = ['edit', 'write', 'str_replace_editor', 'apply_patch']
12
13
  /** 单文件文本上限:超过即标注 oversized,不静默截断。 */
13
14
  const MAX_TEXT = 1024 * 1024
15
+ const captureGaps = new Map<string, { cwd: string; sessionId: string; message: string }>()
16
+
17
+ /** @public @author ddj 2026年11月04号 @param cwd Workspace identity. @returns Bounded runtime-only capture diagnostics. */
18
+ export function pendingGaps(cwd: string): string[] {
19
+ return [...captureGaps.values()].filter((gap) => gap.cwd === cwd).map((gap) => gap.message)
20
+ }
21
+
22
+ /** @private @author ddj 2026年11月04号 @param ctx Host scope. @param exec Original call. @param path Failed target. @param error Evidence failure. Records no writable state. */
23
+ function recordGap(scope: { cwd: string; sessionId: string }, exec: WriteExec, path: string, error: unknown): void {
24
+ const { cwd, sessionId } = scope
25
+ const key = JSON.stringify([cwd, sessionId, exec.id, path])
26
+ captureGaps.set(key, { cwd, sessionId, message: path + ': ' + String(error) })
27
+ const local = [...captureGaps].filter(([, gap]) => gap.cwd === cwd)
28
+ while (local.length > 50) captureGaps.delete(local.shift()![0])
29
+ while (captureGaps.size > 500) captureGaps.delete(captureGaps.keys().next().value!)
30
+ }
14
31
 
15
32
  /** 一次已确认的写入。 */
16
33
  export interface PendingWrite {
@@ -33,12 +50,14 @@ export interface PendingCapture {
33
50
  interface CaptureState {
34
51
  busy: Set<string>
35
52
  stopped: boolean
53
+ disposed: Set<string>
36
54
  }
37
55
 
38
56
  /** 工具的写工具参数形状;只取本采集关心的字段。 */
39
57
  interface WriteExec {
58
+ id?: string
40
59
  name?: string
41
- arguments?: { file_path?: unknown; path?: unknown }
60
+ arguments?: { file_path?: unknown; path?: unknown; input?: unknown }
42
61
  agent?: { session?: { id?: string; cwd?: string } }
43
62
  }
44
63
 
@@ -50,12 +69,19 @@ interface WriteExec {
50
69
  * @returns 可释放的采集器
51
70
  */
52
71
  export function installPending(ctx: Ctx, sink: (write: PendingWrite) => void): PendingCapture {
53
- const state: CaptureState = { busy: new Set<string>(), stopped: false }
72
+ const state: CaptureState = { busy: new Set<string>(), stopped: false, disposed: new Set<string>() }
54
73
  ctx.on('tools/execute', (exec: WriteExec, next: () => Promise<unknown>) => aroundTool(ctx, state, sink, exec, next))
74
+ ctx.on('session/disposed', (session: { id: string }) => {
75
+ state.disposed.add(session.id)
76
+ for (const [key, gap] of captureGaps) {
77
+ if (gap.sessionId === session.id) captureGaps.delete(key)
78
+ }
79
+ })
55
80
  return {
56
81
  dispose(): void {
57
82
  state.stopped = true
58
83
  state.busy.clear()
84
+ captureGaps.clear()
59
85
  },
60
86
  }
61
87
  }
@@ -68,20 +94,24 @@ export function installPending(ctx: Ctx, sink: (write: PendingWrite) => void): P
68
94
  * @param exec 工具分发上下文
69
95
  * @returns 采集计划或 null
70
96
  */
71
- async function planOf(ctx: Ctx, state: CaptureState, exec: WriteExec): Promise<CapturePlan | null> {
72
- const session = exec.agent?.session
73
- const requested = exec.arguments?.file_path ?? exec.arguments?.path
74
- if (state.stopped || !session?.id || typeof requested !== 'string') return null
75
- if (!WRITE_TOOLS.includes(String(exec.name))) return null
76
- const cwd = cwdOf(session as never)
77
- if (!cwd) return null
78
- const fs = ctx.get('fs')
79
- const target = await resolveTarget(ctx, session as never, requested)
97
+ async function planOf(scope: { cwd: string; sessionId: string; fs: any }, state: CaptureState, requested: string): Promise<CapturePlan | null> {
98
+ const { cwd, sessionId, fs } = scope
99
+ if (state.stopped || state.disposed.has(sessionId)) return null
100
+ const target = await fs.resolve(requested, { cwd })
80
101
  const identity = fs.processPath(target)
81
- if (typeof identity !== 'string' || !identity || state.busy.has(identity)) return null
82
- const before = await readText(fs, target)
83
- if (before !== null && before.length > MAX_TEXT) return null
84
- return { cwd: String(cwd), sessionId: String(session.id), path: requested, identity, before, session: session as never }
102
+ if (state.stopped || state.disposed.has(sessionId) || typeof identity !== 'string' || !identity || state.busy.has(identity)) return null
103
+ state.busy.add(identity)
104
+ try {
105
+ const before = await readText(fs, target)
106
+ if (state.stopped || state.disposed.has(sessionId) || (before !== null && before.length > MAX_TEXT)) {
107
+ state.busy.delete(identity)
108
+ return null
109
+ }
110
+ return { cwd: String(cwd), sessionId, path: requested, identity, before, target, fs }
111
+ } catch (error) {
112
+ state.busy.delete(identity)
113
+ throw error
114
+ }
85
115
  }
86
116
 
87
117
  /** 一次采集所需的固定信息。 */
@@ -91,7 +121,8 @@ interface CapturePlan {
91
121
  path: string
92
122
  identity: string
93
123
  before: string | null
94
- session: never
124
+ target: unknown
125
+ fs: any
95
126
  }
96
127
 
97
128
  /**
@@ -108,58 +139,76 @@ async function aroundTool(
108
139
  ctx: Ctx, state: CaptureState, sink: (write: PendingWrite) => void,
109
140
  exec: WriteExec, next: () => Promise<unknown>,
110
141
  ): Promise<unknown> {
111
- let plan: CapturePlan | null = null
142
+ if (state.stopped || !WRITE_TOOLS.includes(String(exec.name))) return next()
143
+ const session = exec.agent?.session
144
+ const cwd = session ? cwdOfSession(ctx, session as never) : null
145
+ if (!session?.id || !cwd) return next()
146
+ const scope = { cwd, sessionId: session.id, fs: ctx.get('fs') }
147
+ const plans: CapturePlan[] = []
112
148
  try {
113
- plan = await planOf(ctx, state, exec)
114
- } catch (error) {
115
- log.debug('待审采集跳过:' + String(error))
116
- }
117
- if (!plan) return next()
149
+ const paths = await toolTargets(exec)
150
+ for (const path of paths) {
151
+ try {
152
+ if (plans.length >= 16) throw new Error('capture target budget exceeded')
153
+ const plan = await planOf(scope, state, path)
154
+ if (plan) plans.push(plan)
155
+ } catch (error) { if (!state.stopped && !state.disposed.has(scope.sessionId)) recordGap(scope, exec, path, error); log.debug('待审采集跳过:' + path + ': ' + String(error)) }
156
+ }
157
+ } catch (error) { if (!state.stopped && !state.disposed.has(scope.sessionId)) recordGap(scope, exec, String(exec.name), error); log.debug('待审目标不可用:' + String(error)) }
118
158
 
119
- state.busy.add(plan.identity)
120
159
  try {
121
- return await next()
160
+ const result = await next()
161
+ if (exec.name === 'apply_patch' && !state.stopped && !state.disposed.has(scope.sessionId)) {
162
+ for (const diagnostic of patchDiagnostics(result)) recordGap(scope, exec, 'apply_patch', diagnostic)
163
+ }
164
+ return result
122
165
  } finally {
123
- state.busy.delete(plan.identity)
124
- try {
125
- reportWrite(ctx, sink, plan)
126
- } catch (error) {
127
- log.debug('待审上报失败:' + String(error))
166
+ for (const plan of plans) {
167
+ try { await reportWrite(state, sink, plan) }
168
+ catch (error) { if (!state.stopped && !state.disposed.has(scope.sessionId)) recordGap(scope, exec, plan.path, error); log.debug('待审上报失败:' + String(error)) }
169
+ finally { state.busy.delete(plan.identity) }
128
170
  }
129
171
  }
130
172
  }
131
173
 
132
174
  /**
133
- * @author ddj 2026年10月29号
134
- * @description: 写后读回并比较,内容一致视为无净变化不产生条目。
135
- * @param ctx Host 生命周期
175
+ * @author ddj 2026年11月04号
176
+ * @description: 固定目标写后读回;内容一致不产生条目,卸载后不再上报。
177
+ * @param state 采集生命周期状态
136
178
  * @param sink 已确认写入的接收方
137
179
  * @param plan 采集计划
138
180
  * @returns 无;读回失败只记日志
139
181
  */
140
- function reportWrite(ctx: Ctx, sink: (write: PendingWrite) => void, plan: CapturePlan): void {
141
- const fs = ctx.get('fs')
142
- void resolveTarget(ctx, plan.session, plan.path).then(async (target: unknown) => {
143
- const after = await readText(fs, target)
144
- if (after !== null && after.length > MAX_TEXT) return
145
- if (plan.before === after) return
146
- sink({ cwd: plan.cwd, sessionId: plan.sessionId, path: plan.path, identity: plan.identity, before: plan.before, after, now: Date.now() })
147
- }).catch((error: unknown) => log.debug('待审读回失败:' + String(error)))
182
+ async function reportWrite(state: CaptureState, sink: (write: PendingWrite) => void, plan: CapturePlan): Promise<void> {
183
+ if (state.stopped || state.disposed.has(plan.sessionId)) return
184
+ // 目录、provider 和目标均在写前冻结,切目录或卸载不能将内容串到其他工作区。
185
+ const after = await readText(plan.fs, plan.target)
186
+ if (state.stopped || state.disposed.has(plan.sessionId)) return
187
+ if (after !== null && after.length > MAX_TEXT) throw new Error('capture text budget exceeded')
188
+ for (const [key, gap] of captureGaps) {
189
+ const identity = JSON.parse(key) as unknown[]
190
+ if (gap.cwd === plan.cwd && gap.sessionId === plan.sessionId && identity[3] === plan.path) captureGaps.delete(key)
191
+ }
192
+ if (plan.before === after) return
193
+ sink({ cwd: plan.cwd, sessionId: plan.sessionId, path: plan.path, identity: plan.identity, before: plan.before, after, now: Date.now() })
148
194
  }
149
195
 
150
196
  /**
151
197
  * @author ddj 2026年10月29号
152
- * @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
198
+ * @description: 读取全文;只有明确缺失返回 null,不可读或超限错误保留失败语义。
153
199
  * @param fs 文件系统
154
200
  * @param target 目标
155
201
  * @returns 文本或 null
156
202
  */
157
- export async function readText(fs: any, target: any): Promise<string | null> {
203
+ export async function readText(fs: any, target: any, signal?: AbortSignal): Promise<string | null> {
158
204
  try {
159
- return String(await fs.readText(target))
205
+ if (typeof fs.readBytes === 'function') {
206
+ return rawText(rawReviewFile(await fs.readBytes(target, signal, MAX_TEXT)))
207
+ }
208
+ throw new Error('Raw byte capture unavailable')
160
209
  } catch (error) {
161
210
  const code = (error as { code?: string }).code
162
- if (code === 'ENOENT' || code === 'FS_NOT_FOUND' || code === 'FS_TOO_LARGE') return null
211
+ if (code === 'ENOENT' || code === 'FS_NOT_FOUND') return null
163
212
  throw error
164
213
  }
165
214
  }
@@ -5,10 +5,9 @@
5
5
  import type { PendingDecide, PendingDetail, PendingHistoryMove, PendingItem, PendingPage } from '../shared/pendingReview.js'
6
6
  import type { ReviewBlockState } from '../shared/reviewRegions.js'
7
7
  import { reviewBlocks } from '../shared/reviewRegions.js'
8
- import { unlink } from 'node:fs/promises'
9
- import { resolve, sep } from 'node:path'
8
+ import { addedFiles } from '../reviewDb.js'
10
9
  import type { PendingWrite } from './capture.js'
11
- import { readText } from './capture.js'
10
+ import { readText, pendingGaps } from './capture.js'
12
11
  import type { ShellLike, VcsImportOptions } from './vcs.js'
13
12
  import { importVcsChanges } from './vcs.js'
14
13
  import type { DecideOutcome, WriteResult } from './decide.js'
@@ -18,7 +17,7 @@ import type { PendingEntry } from './kernel.js'
18
17
  import type { PendingFlags, StoredEntry } from './persist.js'
19
18
  import type { KeepRecord } from './persist.js'
20
19
  import {
21
- dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pushHistory,
20
+ dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pendingDb, pushHistory,
22
21
  readOp, saveBlocks, saveDecisions, saveEntry, setHistoryState, writeOp,
23
22
  } from './persist.js'
24
23
 
@@ -121,19 +120,43 @@ export function itemOf(stored: StoredEntry, baselineEmpty = false): PendingItem
121
120
  return item
122
121
  }
123
122
 
123
+ /**
124
+ * @private
125
+ * @author ddj 2026年11月04号
126
+ * @description 从条目来源判断创建基线;任何决定历史都阻止展示层复活已推进的旧基线。
127
+ * @param cwd 工作区
128
+ * @param stored 累计条目
129
+ * @param sourceAdds 单次读取内的来源创建集合缓存
130
+ * @returns 是否将展示基线置空
131
+ */
132
+ function emptyBaseOf(cwd: string, stored: StoredEntry, sourceAdds = new Map<string, Set<string>>()): boolean {
133
+ if (isSettled(stored.entry)) return false
134
+ const decided = pendingDb(cwd).prepare('SELECT 1 FROM pending_history WHERE entry_id=? LIMIT 1').get(stored.entry.id)
135
+ if (decided) return false
136
+ for (const sourceId of stored.entry.sessionIds) {
137
+ let createdPaths = sourceAdds.get(sourceId)
138
+ if (!createdPaths) {
139
+ createdPaths = addedFiles(cwd, sourceId)
140
+ sourceAdds.set(sourceId, createdPaths)
141
+ }
142
+ if (createdPaths.has(stored.entry.id)) return true
143
+ }
144
+ return false
145
+ }
146
+
124
147
  /**
125
148
  * @author ddj 2026年10月29号
126
- * @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
149
+ * @description: 读工作区全部待审条目,按更新时间升序;来源会话仅用于追溯。
127
150
  * @param cwd 工作区
128
- * @param sessionId 当前会话
129
- * @param wholeAdd 本次会话新建的文件身份集合,命中时基线置空
151
+ * @param _sessionId 兼容旧调用签名,不再用于可见性过滤
130
152
  * @returns 列表
131
153
  */
132
- export function loadPage(cwd: string, sessionId?: string, wholeAdd?: ReadonlySet<string>): PendingPage {
154
+ export function loadPage(cwd: string, _sessionId?: string): PendingPage {
133
155
  const all = loadEntries(cwd)
134
- const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all
135
- const items = visible.map((stored) => itemOf(stored, wholeAdd?.has(stored.entry.id) === true))
136
- const gaps: string[] = []
156
+ const visible = all
157
+ const sourceAdds = new Map<string, Set<string>>()
158
+ const items = visible.map((stored) => itemOf(stored, emptyBaseOf(cwd, stored, sourceAdds)))
159
+ const gaps: string[] = pendingGaps(cwd)
137
160
  for (const stored of visible) {
138
161
  if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`)
139
162
  }
@@ -151,9 +174,10 @@ export function loadPage(cwd: string, sessionId?: string, wholeAdd?: ReadonlySet
151
174
  export function loadDetail(cwd: string, id: string, baselineEmpty = false): PendingDetail | null {
152
175
  const stored = loadEntries(cwd).find((item) => item.entry.id === id)
153
176
  if (!stored) return null
154
- const diff = diffOf(stored.entry, baselineEmpty)
177
+ const emptyBase = baselineEmpty || emptyBaseOf(cwd, stored)
178
+ const diff = diffOf(stored.entry, emptyBase)
155
179
  return {
156
- item: itemOf(stored, baselineEmpty),
180
+ item: itemOf(stored, emptyBase),
157
181
  diff,
158
182
  blocks: blockStatesOf(stored.entry),
159
183
  capability: capabilityOf(stored.entry, stored.flags),
@@ -230,19 +254,19 @@ export function writerFor(
230
254
  } catch {
231
255
  return { ok: false, reason: 'resolve-failed' }
232
256
  }
233
- const current = await readText(fs, target).catch(() => undefined)
234
- if (current === undefined) return { ok: false, reason: 'read-failed' }
235
- // 撤销「Revert 删除新建文件」时文件已不存在,只有显式允许重建才放行
236
- const recreate = allowCreate && current === null
237
- if (!recreate && current !== expected) return { ok: false, reason: 'diverged' }
238
- const currentStat = await fs.stat(target, signal).catch(() => undefined)
257
+ const snapshot = await stableRead(fs, target, signal)
258
+ if (!snapshot.ok) return snapshot
259
+ // 缺失仅允许撤销/重做重建;原子 createIfAbsent 防止检查后被其他写入抢占。
260
+ const recreate = allowCreate && snapshot.content === null
261
+ if (!recreate && snapshot.content !== expected) return { ok: false, reason: 'diverged' }
262
+ if (snapshot.content === null && !recreate) return { ok: false, reason: 'missing' }
263
+ const intent = recreate
264
+ ? { kind: 'createIfAbsent' }
265
+ : { kind: 'replaceIfVersion', version: snapshot.version }
239
266
  try {
240
- // 第五个实参是沙箱策略;缺它时 provider 会以 FS_SANDBOX_DENIED 拒绝写入
241
- const written = await fs.writeText(
242
- target, content ?? '', currentStat ? { kind: 'replaceIfVersion', version: currentStat.version } : undefined, signal, policy,
243
- )
267
+ const written = await fs.writeText(target, content ?? '', intent, signal, policy)
244
268
  const readBack = await readText(fs, target)
245
- if ((content ?? '') !== (readBack ?? '')) return { ok: false, reason: 'read-back-mismatch' }
269
+ if (content !== readBack) return { ok: false, reason: 'read-back-mismatch' }
246
270
  if (content === null && readBack !== null) return { ok: false, reason: 'delete-failed' }
247
271
  void written
248
272
  return { ok: true, content }
@@ -274,7 +298,7 @@ export async function applyDecide(
274
298
  blocks: beforeBlocks,
275
299
  now: Date.now(),
276
300
  write: (content) => {
277
- if (content === null) return deleteTarget(fs, cwd, stored.entry.id, signal)
301
+ if (content === null) return deleteTarget(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)
278
302
  return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)(content)
279
303
  },
280
304
  })
@@ -445,7 +469,7 @@ export async function undoDecide(
445
469
  async function writeBack(
446
470
  fs: any, cwd: string, stored: StoredEntry, target: HistoryState, signal?: AbortSignal, policy?: unknown,
447
471
  ): Promise<WriteResult> {
448
- if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, signal)
472
+ if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)
449
473
  const write = writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy, true)
450
474
  return write(target.entry.newText)
451
475
  }
@@ -480,48 +504,62 @@ function parseSnapshot(text: string): HistorySnapshot | null {
480
504
  }
481
505
  }
482
506
 
507
+ /** 稳定快照;缺失与读取失败严格区分,不允许省略现存文件的版本。 */
508
+ type StableFile = { ok: true; content: string | null; version?: string } | { ok: false; reason: string }
509
+
483
510
  /**
484
- * @author ddj 2026年10月29号
485
- * @description: Revert 的删除分支:FileSystem 抽象没有删除能力,只有确认仍在工作区内才用宿主 unlink。
486
- * @param fs 文件系统
487
- * @param cwd 工作区
488
- * @param identity 目标身份
511
+ * @private
512
+ * @author ddj 2026年11月04号
513
+ * @description 版本—内容—版本读取,防止将新版本令牌与旧内容拼成写入依据。
514
+ * @param fs 文件提供方
515
+ * @param target 已解析目标
489
516
  * @param signal 取消信号
490
- * @returns 删除结果
517
+ * @returns 稳定快照或拒绝原因
491
518
  */
492
- async function deleteTarget(fs: any, cwd: string, identity: string, signal?: AbortSignal): Promise<WriteResult> {
493
- let target: any
494
- try {
495
- target = await fs.resolve(identity, { cwd, signal })
496
- } catch {
497
- return { ok: false, reason: 'resolve-failed' }
498
- }
499
- const currentStat = await fs.stat(target, signal).catch(() => undefined)
500
- if (!currentStat) return { ok: true, content: null }
501
- const absolute = String(fs.processPath(target))
502
- if (!insideWorkspace(cwd, absolute)) return { ok: false, reason: 'outside-workspace' }
519
+ async function stableRead(fs: any, target: any, signal?: AbortSignal): Promise<StableFile> {
503
520
  try {
504
- await unlink(absolute)
521
+ const before = await fs.stat(target, signal)
522
+ if (!before) return { ok: true, content: null }
523
+ if (before.type !== 'file' || !before.version) return { ok: false, reason: 'unguarded-file' }
524
+ const content = await readText(fs, target, signal)
525
+ if (content === null) return { ok: false, reason: 'revision-mismatch' }
526
+ const after = await fs.stat(target, signal)
527
+ if (after?.type !== 'file' || after.version !== before.version) return { ok: false, reason: 'revision-mismatch' }
528
+ return { ok: true, content, version: before.version }
505
529
  } catch (error) {
506
530
  return { ok: false, reason: reasonOf(error) }
507
531
  }
508
- const after = await fs.stat(target, signal).catch(() => undefined)
509
- return after ? { ok: false, reason: 'delete-failed' } : { ok: true, content: null }
510
532
  }
511
533
 
512
534
  /**
513
- * @author ddj 2026年10月29号
514
- * @description: 判断宿主路径是否位于工作区内,Windows 下按大小写不敏感比较。
515
- * @param cwd 工作区
516
- * @param absolute 目标绝对路径
517
- * @returns 是否在工作区内
535
+ * @private
536
+ * @author ddj 2026年11月04号
537
+ * @description 通过 provider 受控删除,保留会话策略与原子版本校验;旧 Host 不回落直删。
538
+ * @param fs 文件提供方
539
+ * @param cwd 工作目录
540
+ * @param identity 目标身份
541
+ * @param expected 应有内容
542
+ * @param signal 取消信号
543
+ * @param policy 会话沙箱策略
544
+ * @returns 删除结果;结果不明不提交决定
518
545
  */
519
- function insideWorkspace(cwd: string, absolute: string): boolean {
520
- const root = resolve(cwd)
521
- const candidate = resolve(absolute)
522
- const normalize = (value: string) => (process.platform === 'win32' ? value.toLowerCase() : value)
523
- const rootKey = normalize(root).endsWith(sep) ? normalize(root) : normalize(root) + sep
524
- return normalize(candidate).startsWith(rootKey)
546
+ async function deleteTarget(
547
+ fs: any, cwd: string, identity: string, expected: string, signal?: AbortSignal, policy?: unknown,
548
+ ): Promise<WriteResult> {
549
+ if (typeof fs.deleteFile !== 'function') return { ok: false, reason: 'delete-unavailable' }
550
+ try {
551
+ const target = await fs.resolve(identity, { cwd, signal })
552
+ const snapshot = await stableRead(fs, target, signal)
553
+ if (!snapshot.ok) return snapshot
554
+ if (snapshot.content === null) return { ok: true, content: null }
555
+ if (snapshot.content !== expected) return { ok: false, reason: 'diverged' }
556
+
557
+ await fs.deleteFile(target, { version: snapshot.version }, signal, policy)
558
+ const after = await fs.stat(target, signal)
559
+ return after ? { ok: false, reason: 'delete-failed' } : { ok: true, content: null }
560
+ } catch (error) {
561
+ return { ok: false, reason: reasonOf(error) }
562
+ }
525
563
  }
526
564
 
527
565
  /**