dsh-vscode-mode 0.14.5 → 0.14.7

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.
@@ -22,7 +22,7 @@ import { childEnv } from '../childEnv.js'
22
22
  import { stopTree } from '../processTree.js'
23
23
  import { join } from 'node:path'
24
24
  import { createFrameParser, encodeMessage } from '../lsp/jsonrpc.js'
25
- import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath } from './sourcePath.js'
25
+ import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath, rankSourceCandidates } from './sourcePath.js'
26
26
  import { bpAcksOfResponse, DAP_ACTIONS, needsConfigurationDone, requestOfConfig, threadIdOfStopped, type DapAction, type DapBreakpointInput, type DapBreakpointAck, type DapDebugConfig, type DapEventView, type DapFrameView, type DapPhase, type DapPollResult, type DapScopeView, type DapStateView, type DapVariableView } from '../shared/dap.js'
27
27
  import { dapRequest, eventBody, initializeArgs, isDapEvent, isDapResponse, responseBody, type DapEvent, type DapMessage, type DapRequest } from './protocol.js'
28
28
  import type { DapAdapterSpec } from './provider.js'
@@ -458,18 +458,50 @@ export class DapSession {
458
458
  return '会话在初始化完成前终止(attach/连接目标失败)' + dotrushLog
459
459
  }
460
460
 
461
- /** findFileReq 反向应答:按完整 chunkname 排序工作区候选并缓存首命中。 */
461
+ /**
462
+ * 按当前语言过滤并排序源码候选,供反向应答与栈帧解析共用。
463
+ * @private
464
+ * @author ddj 2026年10月09号
465
+ * @param chunk 适配器上报的源码标识
466
+ * @returns 语言合法、按路径相关度排序的候选
467
+ */
468
+ private async sourceCandidates(chunk: string): Promise<string[]> {
469
+ const files = await this.deps.findFiles(this.workspacePath, chunk)
470
+ const exts = this.frameExts()
471
+ const allowed = exts.length ? files.filter((file) => hasSourceExtension(file, exts)) : files
472
+ return rankSourceCandidates(chunk, allowed, this.workspacePath)
473
+ }
474
+
475
+ /**
476
+ * 更新源码标识及 basename 缓存;无合法候选时删除旧映射。
477
+ * @private
478
+ * @author ddj 2026年10月09号
479
+ * @param chunk 适配器上报的源码标识
480
+ * @param file 合法候选首项,空值表示未解析
481
+ * @description 副作用:更新当前会话源码缓存
482
+ */
483
+ private cacheSource(chunk: string, file: string): void {
484
+ for (const key of [sourceKeyOf(chunk), sourceKeyOf(sourceBaseOf(chunk))]) {
485
+ if (!key) continue
486
+ if (file) this.sourceFiles.set(key, file)
487
+ else this.sourceFiles.delete(key)
488
+ }
489
+ }
490
+
491
+ /**
492
+ * findFileReq 反向应答:只返回当前语言合法源码并缓存首命中。
493
+ * @private
494
+ * @author ddj 2026年10月09号
495
+ * @param body 反向请求参数
496
+ * @description 副作用:更新源码缓存并发送 findFileRsp
497
+ */
462
498
  private async respondFindFile(body: Record<string, unknown>): Promise<void> {
463
499
  const seq = typeof body.seq === 'number' ? body.seq : -1
464
500
  const chunk = String(body.file ?? '')
465
- const files = await this.deps.findFiles(this.workspacePath, chunk)
501
+ const files = await this.sourceCandidates(chunk)
466
502
  log.debug('[dap-trace] findFileReq seq=' + seq + ' chunk=' + chunk + ' candidates=' + files.length + ' first=' + (files[0] ?? ''))
467
503
  this.deps.trace?.(this.workspacePath, '[DEBUG findFileReq] seq=' + seq + ' chunk=' + chunk + ' candidates=' + files.length + ' first=' + (files[0] ?? ''))
468
- const first = files[0]
469
- if (first) {
470
- this.sourceFiles.set(sourceKeyOf(chunk), first)
471
- this.sourceFiles.set(sourceKeyOf(sourceBaseOf(chunk)), first)
472
- }
504
+ this.cacheSource(chunk, files[0] ?? '')
473
505
  this.send({ seq: this.msgSeq++, type: 'request', command: 'findFileRsp', arguments: { seq, files } })
474
506
  }
475
507
 
@@ -583,27 +615,43 @@ export class DapSession {
583
615
  if (phase === 'idle') { this.events = []; this.config = null; this.pid = undefined; this.error = undefined }
584
616
  }
585
617
 
586
- /** 适配器上报路径 → 帧视图(file 始终保留可打开的规范路径)。 */
618
+ /**
619
+ * 适配器源码标识转帧视图;完整合法路径优先,错误语言缓存须重新解析。
620
+ * @private
621
+ * @author ddj 2026年10月09号
622
+ * @param item 适配器栈帧
623
+ * @param index 栈帧下标(EmmyLua frameId)
624
+ * @returns 保留原始行号、只含合法源码路径的帧视图
625
+ * @description 副作用:按需查找源码并更新会话缓存
626
+ */
587
627
  private async frameViewOf(item: unknown, index: number): Promise<DapFrameView> {
588
628
  const raw = item as Record<string, unknown>
589
629
  const source = (raw.source ?? {}) as Record<string, unknown>
590
630
  const sourcePath = typeof source.path === 'string' ? source.path : ''
591
631
  const sourceName = typeof source.name === 'string' ? source.name : ''
632
+ const exts = this.frameExts()
592
633
  const cached = this.sourceFiles.get(sourceKeyOf(sourcePath)) ?? this.sourceFiles.get(sourceKeyOf(sourceName))
593
- let selected = cached || sourcePath || sourceName
594
- const needsLookup = !selected || !hasSourceExtension(selected, this.frameExts())
634
+ const validPath = sourcePath && !isBareChunk(sourcePath)
635
+ && (exts.length ? hasSourceExtension(sourcePath, exts) : sourceBaseOf(sourcePath).includes('.'))
636
+ const validCache = cached && (!exts.length || hasSourceExtension(cached, exts))
637
+ let selected = validPath ? sourcePath : validCache ? cached : sourcePath || sourceName
638
+ const needsLookup = !validPath && !validCache
639
+ if (cached && !validCache) {
640
+ this.cacheSource(sourcePath, '')
641
+ this.cacheSource(sourceName, '')
642
+ }
595
643
  log.debug('[dap-trace] frame rawPath=' + sourcePath + ' name=' + sourceName + ' cached=' + (cached ?? '') + ' needsLookup=' + needsLookup + ' selected=' + selected)
596
644
  this.deps.trace?.(this.workspacePath, '[DEBUG frame] rawPath=' + sourcePath + ' name=' + sourceName + ' cached=' + (cached ?? '') + ' needsLookup=' + needsLookup + ' selected=' + selected)
597
- if (!cached && needsLookup) {
598
- const candidates = await this.deps.findFiles(this.workspacePath, sourceName || sourcePath)
645
+ if (needsLookup) {
646
+ const candidates = await this.sourceCandidates(sourceName || sourcePath)
599
647
  log.debug('[dap-trace] frameLookup source=' + (sourceName || sourcePath) + ' candidates=' + candidates.length + ' first=' + (candidates[0] ?? ''))
600
648
  this.deps.trace?.(this.workspacePath, '[DEBUG frameLookup] source=' + (sourceName || sourcePath) + ' candidates=' + candidates.length + ' first=' + (candidates[0] ?? ''))
601
649
  if (candidates[0]) {
602
650
  selected = candidates[0]
603
- this.sourceFiles.set(sourceKeyOf(sourceName || sourcePath), candidates[0])
604
- } else if (isBareChunk(sourceName || sourcePath)) {
605
- // 反查失败 + 裸 chunkname:无可打开路径。置空而非回退原串,
606
- // 避免客户端按工作区根拼出必然不存在的路径、打开注定失败的页签。
651
+ this.cacheSource(sourceName || sourcePath, candidates[0])
652
+ } else if (exts.length || isBareChunk(sourceName || sourcePath)) {
653
+ // 反查失败且当前语言已知或只有裸 chunk:不回退到错误语言文件,
654
+ // 也不让客户端按工作区根拼出不存在的 chunk 路径。
607
655
  selected = ''
608
656
  }
609
657
  }
package/src/model.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
6
  import type { Decision, DiffRecord, Hunk, RecordSummary } from './shared/types.js'
7
- import { applyLocations, fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
7
+ import { applyLocations, fingerprint, isNoopHunk, locateHunks, normalizeForCompare, normalizeHunk, preciseHunk } from './shared/diff.js'
8
8
 
9
9
  /** 工作区记录桶上限(超过后按 at 最旧剔除)。 */
10
10
  export const MAX_RECORDS = 200
@@ -157,6 +157,117 @@ export function reconstructOriginal(records: DiffRecord[], content: string): { c
157
157
  }
158
158
  }
159
159
 
160
+ // #region 过程抵消
161
+
162
+ /**
163
+ * @public
164
+ * @author ddj 2026年10月10号
165
+ * 判断记录是否完全未决策;缺少逐块状态时沿用 pending 的 call 状态。
166
+ * @param record 审查记录
167
+ * @returns 未归档、未覆盖且没有用户决策时为 true
168
+ */
169
+ export function fullyPending(record: DiffRecord): boolean {
170
+ return !record.archived && !record.superseded && record.decisions.call === 'pending'
171
+ && record.decisions.perHunk.every((value) => value === 'pending')
172
+ }
173
+
174
+ /**
175
+ * @public
176
+ * @author ddj 2026年10月10号
177
+ * 比较整组 hunk 是否逐块精确逆反;不把新建或纯 no-op 当作抵消。
178
+ * @param prior 先前修改的差异块
179
+ * @param later 后续修改的差异块
180
+ * @returns 文本归一化后整组逆反且包含真实修改时为 true
181
+ */
182
+ export function hunksInverse(prior: Hunk[], later: Hunk[]): boolean {
183
+ if (!prior.length || prior.length !== later.length) return false
184
+ let changed = false
185
+ for (let idx = 0; idx < prior.length; idx++) {
186
+ const a = normalizeHunk(prior[idx])
187
+ const b = normalizeHunk(later[idx])
188
+ if (a.oldText !== b.newText || a.newText !== b.oldText) return false
189
+ if (!isNoopHunk(a)) changed = true
190
+ }
191
+ return changed
192
+ }
193
+
194
+ /**
195
+ * @private
196
+ * @author ddj 2026年10月10号
197
+ * 同路径优先按捕获批次排序;旧记录仅使用有效时间,不拿 callId 伪造先后证据。
198
+ * @param a 第一条记录
199
+ * @param b 第二条记录
200
+ * @returns 负数表示 a 更早,零表示先后无法区分
201
+ */
202
+ function recordOrder(a: DiffRecord, b: DiffRecord): number {
203
+ if (a.path !== b.path) return a.path.localeCompare(b.path)
204
+ if (Number.isInteger(a.batch) && Number.isInteger(b.batch)
205
+ && a.batch > 0 && b.batch > 0 && a.batch !== b.batch) return a.batch - b.batch
206
+ const delta = Date.parse(a.at) - Date.parse(b.at)
207
+ return Number.isFinite(delta) ? delta : 0
208
+ }
209
+
210
+ /**
211
+ * @private
212
+ * @author ddj 2026年10月10号
213
+ * 确认完整文件快照往返,防止相同文本在不同位置的反向修改被误隐藏。
214
+ * @param prior 先前记录
215
+ * @param later 后续记录
216
+ * @returns 两端快照互换且文件存在性未变化时为 true;缺少证据时保留审查
217
+ */
218
+ function snapshotsRestore(prior: DiffRecord, later: DiffRecord): boolean {
219
+ if (prior.create || later.create) return false
220
+ if (typeof prior.before !== 'string' || typeof prior.after !== 'string'
221
+ || typeof later.before !== 'string' || typeof later.after !== 'string') return false
222
+ return normalizeForCompare(prior.before) === normalizeForCompare(later.after)
223
+ && normalizeForCompare(prior.after) === normalizeForCompare(later.before)
224
+ }
225
+
226
+ /**
227
+ * @public
228
+ * @author ddj 2026年10月10号
229
+ * 查找最近一条未决的整记录逆反;必须有文件快照证明原内容确实恢复。
230
+ * @param records 工作区记录(不修改输入)
231
+ * @param record 新记录
232
+ * @returns 可安全配对的先前记录,证据不足或部分撤销时为 null
233
+ */
234
+ export function findInversePrior(records: DiffRecord[], record: DiffRecord): DiffRecord | null {
235
+ if (!fullyPending(record)) return null
236
+ let found: DiffRecord | null = null
237
+ for (const prior of records) {
238
+ if (prior.callId === record.callId || prior.path !== record.path || !fullyPending(prior)) continue
239
+ if (recordOrder(prior, record) >= 0 || !hunksInverse(prior.hunks, record.hunks)) continue
240
+ if (!snapshotsRestore(prior, record)) continue
241
+ if (!found || recordOrder(found, prior) < 0) found = prior
242
+ }
243
+ return found
244
+ }
245
+
246
+ /**
247
+ * @public
248
+ * @author ddj 2026年10月10号
249
+ * 按捕获顺序贪心配对历史抵消记录,每条最多使用一次;桶上限为 200。
250
+ * @param records 工作区记录(不修改输入或记录状态)
251
+ * @returns 可以整对归档的记录对
252
+ */
253
+ export function findCanceledPairs(records: DiffRecord[]): Array<[DiffRecord, DiffRecord]> {
254
+ const available: DiffRecord[] = []
255
+ const pairs: Array<[DiffRecord, DiffRecord]> = []
256
+ const ordered = [...records].sort(recordOrder)
257
+ for (const record of ordered) {
258
+ const prior = findInversePrior(available, record)
259
+ if (!prior) {
260
+ if (fullyPending(record)) available.push(record)
261
+ continue
262
+ }
263
+ pairs.push([prior, record])
264
+ available.splice(available.indexOf(prior), 1)
265
+ }
266
+ return pairs
267
+ }
268
+
269
+ // #endregion
270
+
160
271
  /** 文件当前最大批次号(无记录为 0)。 */
161
272
  export function fileMaxBatch(records: Map<string, DiffRecord>, path: string): number {
162
273
  let m = 0
package/src/rpc.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  READ_CAP,
14
14
  appendArchiveEntries,
15
15
  archiveRecords,
16
+ archiveCanceled,
16
17
  autoArchiveStale,
17
18
  policyOf,
18
19
  resolveTarget,
@@ -613,6 +614,7 @@ export function buildHandlers(
613
614
  const staleStart = performance.now()
614
615
  // #endregion
615
616
  await autoArchiveStale(ctx, sc.session, sc.cwd, bucket)
617
+ await archiveCanceled(ctx, sc.session, sc.cwd, bucket)
616
618
  // #region debug log
617
619
  staleMs = performance.now() - staleStart
618
620
  // #endregion
package/src/shared/rpc.ts CHANGED
@@ -24,6 +24,7 @@ import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, D
24
24
  import { DAP_ACTIONS } from './dap.js'
25
25
  import { appUrl } from './appUrl.js'
26
26
  import type { LoggerLevel } from './logger.js'
27
+ import type { SvnViewRequest, SvnViewPayload } from './svnExplorer.js'
27
28
 
28
29
  /** webServer 精确路由。 */
29
30
  export const RPC_PATH = '/edrv/rpc'
@@ -464,6 +465,8 @@ export interface RpcRequestMap {
464
465
  'snippets.entries': { sessionId?: string }
465
466
  'svn.status': { sessionId?: string; force?: boolean }
466
467
  'svn.changes': { sessionId?: string; force?: boolean }
468
+ 'svn.explorerStatus': { sessionId?: string; force?: boolean }
469
+ 'svn.explorerView': SvnViewRequest
467
470
  'svn.diffBase': { sessionId?: string; path: string }
468
471
  'svn.revert': { sessionId?: string; paths: string[] }
469
472
  'svn.add': { sessionId?: string; paths: string[] }
@@ -624,6 +627,8 @@ export interface RpcOkMap {
624
627
  'snippets.entries': { entries: SnippetEntry[] }
625
628
  'svn.status': SvnStatusPayload
626
629
  'svn.changes': { wcRoot: string; entries: SvnChangeEntry[]; truncated: boolean }
630
+ 'svn.explorerStatus': { entries: SvnChangeEntry[]; truncated: boolean; prefix: string }
631
+ 'svn.explorerView': SvnViewPayload
627
632
  'svn.diffBase': { base: string | null; working: string; reason?: string; error?: string; binary?: boolean; encodingHint?: boolean }
628
633
  'svn.revert': { count: number; summary: string; output: string }
629
634
  'svn.add': { count: number; summary: string; output: string }
@@ -642,7 +647,7 @@ export interface RpcOkMap {
642
647
  'svn.tortoise': { launched: string }
643
648
  'svn.patchText': { text: string; truncated: boolean; binary: boolean }
644
649
  'svn.diffSum': { entries: SvnSumEntry[]; truncated: boolean }
645
- 'svn.remoteStatus': { outdated: SvnRemoteOutdatedEntry[]; againstRev: number | null }
650
+ 'svn.remoteStatus': { outdated: SvnRemoteOutdatedEntry[]; againstRev: number | null; truncated: boolean }
646
651
  'svn.conflictArtifacts': { artifacts: SvnConflictArtifact[] }
647
652
  'svn.diffLocalPair': SvnDiffRevResult
648
653
  'svn.fileSizes': { sizes: Array<{ path: string; size: number | null }> }
package/src/shared/svn.ts CHANGED
@@ -413,6 +413,61 @@ export function svnBadgeTitle(entry: SvnChangeEntry): string {
413
413
  return letter + ' ' + SVN_STATUS_LABEL[entry.status]
414
414
  }
415
415
 
416
+ /** 文件夹子树聚合状态(资源管理器目录徽标数据源)。 */
417
+ export interface SvnFolderAgg {
418
+ /** 子树内最强子项状态(决胜见 SVN_FOLDER_STATUS_RANK)。 */
419
+ status: SvnItemStatus
420
+ /** 子树内变更条目总数(含更深层目录下的)。 */
421
+ count: number
422
+ }
423
+
424
+ /**
425
+ * 文件夹聚合决胜优先级(强→弱):冲突/障碍/缺失等 danger 态先于常规修改,
426
+ * 未版本控制最弱;未列出的状态(normal/external/ignored)不参与聚合。
427
+ */
428
+ export const SVN_FOLDER_STATUS_RANK: Partial<Record<SvnItemStatus, number>> = {
429
+ conflicted: 90,
430
+ obstructed: 80,
431
+ missing: 70,
432
+ incomplete: 60,
433
+ deleted: 50,
434
+ replaced: 40,
435
+ modified: 30,
436
+ added: 20,
437
+ unversioned: 10,
438
+ }
439
+
440
+ /**
441
+ * 变更清单 → 目录路径 → 子树聚合状态表(P2-15 资源管理器文件夹徽标)。
442
+ * svn status 只报告目录自身状态、子项变更不上卷,受版本控制目录因此缺席清单;
443
+ * 此处对每条变更沿路径拆出全部祖先目录,累计计数并按 SVN_FOLDER_STATUS_RANK
444
+ * 取最强状态。目录自身被报告(A/?/属性M 等)时调用方以直接条目优先,本表只兜底。
445
+ * @author ddj 2026年10月09号
446
+ * @param entries 变更清单(host `svn.changes` 原始条目,路径 `/` 分隔)
447
+ * @returns 目录相对路径 → 聚合状态(根路径 '' 不入表;入参为空返回空表)
448
+ */
449
+ export function svnFolderStatusOf(entries: readonly SvnChangeEntry[]): Record<string, SvnFolderAgg> {
450
+ const result: Record<string, SvnFolderAgg> = {}
451
+ for (const entry of entries) {
452
+ if (!isSvnChange(entry)) continue
453
+ const parts = String(entry.path ?? '').replace(/\\/g, '/').split('/').filter(Boolean)
454
+ const rank = SVN_FOLDER_STATUS_RANK[entry.status] ?? 0
455
+ let dir = ''
456
+ // 最后一段是条目自身,只向上聚合到祖先目录(目录自身的行走直接条目)
457
+ for (let i = 0; i < parts.length - 1; i++) {
458
+ dir = dir ? dir + '/' + parts[i] : parts[i]
459
+ const prev = result[dir]
460
+ if (!prev) {
461
+ result[dir] = { status: entry.status, count: 1 }
462
+ continue
463
+ }
464
+ prev.count += 1
465
+ if (rank > (SVN_FOLDER_STATUS_RANK[prev.status] ?? 0)) prev.status = entry.status
466
+ }
467
+ }
468
+ return result
469
+ }
470
+
416
471
  // --endregion
417
472
 
418
473
  // --region P2 动作文案与判定
@@ -0,0 +1,97 @@
1
+ /** Explorer-only SVN presentation; change-list/menu semantics stay untouched. @author ddj 2026年10月09号 */
2
+ import type { SvnChangeEntry, SvnItemStatus, SvnRemoteOutdatedEntry } from './svn.js'
3
+ import { SVN_FOLDER_STATUS_RANK, SVN_STATUS_LABEL } from './svn.js'
4
+
5
+ export type SvnIconKind = 'clean' | 'modified' | 'conflict' | 'added' | 'deleted' | 'unversioned' | 'ignored' | 'external'
6
+ export interface SvnExplorerIndex {
7
+ entries: Map<string, SvnChangeEntry>
8
+ folders: Map<string, { status: SvnItemStatus; count: number }>
9
+ truncated: boolean
10
+ }
11
+ export interface SvnExplorerMark { kind: SvnIconKind; title: string }
12
+ export interface SvnViewRow { path: string; isDir: boolean }
13
+ export type SvnRefreshReason = 'read' | 'paths' | 'full' | 'calibrate'
14
+ export interface SvnViewRequest {
15
+ sessionId?: string; rows: SvnViewRow[]; reason?: SvnRefreshReason; paths?: string[]
16
+ clientId?: string; eventId?: string; revision?: number; generation?: number
17
+ }
18
+ export interface SvnViewMark { path: string; kind: SvnIconKind | null; count: number; stale: boolean }
19
+ export interface SvnViewPayload {
20
+ scopeId: string; prefix: string; revision: number; generation: number; observedGeneration: number
21
+ confirmedAt: number; complete: boolean; refreshing: boolean; scanMs: number; error: string
22
+ mismatch: boolean; rows: SvnViewMark[]
23
+ }
24
+ const ICON_KIND: Record<SvnItemStatus, SvnIconKind> = {
25
+ normal: 'clean', modified: 'modified', replaced: 'modified', added: 'added',
26
+ deleted: 'deleted', missing: 'deleted', incomplete: 'conflict', obstructed: 'conflict',
27
+ conflicted: 'conflict', unversioned: 'unversioned', ignored: 'ignored', external: 'external',
28
+ }
29
+
30
+ /** @public @author ddj 2026年10月09号 @param entry Local status. @returns Effective content/property status. */
31
+ export function localStatusOf(entry: SvnChangeEntry): SvnItemStatus {
32
+ if (entry.props === 'conflicted') return 'conflicted'
33
+ if (entry.status === 'normal' && entry.props === 'modified') return 'modified'
34
+ return entry.status
35
+ }
36
+
37
+ /** @private @author ddj 2026年10月09号 @param path Relative path. @returns Ancestors, excluding self/root. */
38
+ function parentsOf(path: string): string[] {
39
+ const result: string[] = []
40
+ let dir = path
41
+ while (dir.includes('/')) {
42
+ dir = dir.slice(0, dir.lastIndexOf('/'))
43
+ result.push(dir)
44
+ }
45
+ return result
46
+ }
47
+
48
+ /** @public @author ddj 2026年10月09号 @param entries Confirmed verbose statuses. @param truncated Incomplete snapshot. @returns Indexed entries and descendant changes. */
49
+ export function explorerIndexOf(entries: readonly SvnChangeEntry[], truncated = false): SvnExplorerIndex {
50
+ const index: SvnExplorerIndex = { entries: new Map(), folders: new Map(), truncated }
51
+ for (const entry of entries) {
52
+ index.entries.set(entry.path, entry)
53
+ const status = localStatusOf(entry)
54
+ if (status === 'normal' || status === 'ignored' || status === 'external') continue
55
+ for (const dir of parentsOf(entry.path)) {
56
+ const prev = index.folders.get(dir)
57
+ if (!prev) { index.folders.set(dir, { status, count: 1 }); continue }
58
+ prev.count++
59
+ if ((SVN_FOLDER_STATUS_RANK[status] ?? 0) > (SVN_FOLDER_STATUS_RANK[prev.status] ?? 0)) prev.status = status
60
+ }
61
+ }
62
+ return index
63
+ }
64
+
65
+ /** @public @author ddj 2026年10月09号 @param index Confirmed snapshot. @param path Row path. @param isDir Directory row. @returns Overlay or null; absent entries never imply clean. */
66
+ export function explorerMarkOf(index: SvnExplorerIndex | null, path: string, isDir: boolean): SvnExplorerMark | null {
67
+ if (!index) return null
68
+ let entry = index.entries.get(path)
69
+ if (!entry) {
70
+ for (const parent of parentsOf(path)) {
71
+ const ancestor = index.entries.get(parent)
72
+ if (ancestor?.status === 'ignored' || ancestor?.status === 'unversioned') { entry = ancestor; break }
73
+ }
74
+ }
75
+ let status = entry ? localStatusOf(entry) : null
76
+ const agg = isDir ? index.folders.get(path) : null
77
+ if (agg && (!status || (SVN_FOLDER_STATUS_RANK[agg.status] ?? 0) > (SVN_FOLDER_STATUS_RANK[status] ?? 0))) status = agg.status
78
+ if (!status) return null
79
+ if (status === 'normal' && (entry?.versioned !== true || (isDir && index.truncated))) return null
80
+ const title = status === 'normal' ? '本地正常(已受控,未修改;不表示仓库无更新)' : SVN_STATUS_LABEL[status]
81
+ return { kind: ICON_KIND[status], title: title + (agg ? ';子项 ' + agg.count + ' 处变更' : '') }
82
+ }
83
+
84
+ /** @public @author ddj 2026年10月09号 @param entries Remote changes. @returns Exact and ancestor update counts. */
85
+ export function remoteIndexOf(entries: readonly SvnRemoteOutdatedEntry[]): Map<string, number> {
86
+ const result = new Map<string, number>()
87
+ for (const entry of entries) {
88
+ for (const path of [entry.path, ...parentsOf(entry.path)]) result.set(path, (result.get(path) ?? 0) + 1)
89
+ }
90
+ return result
91
+ }
92
+
93
+ /** @public @author ddj 2026年10月09号 @param path WC-relative path. @param prefix Session path under WC. @returns Session-relative path or null outside session. */
94
+ export function explorerPathOf(path: string, prefix: string): string | null {
95
+ if (!prefix || prefix === '.') return path
96
+ return path.startsWith(prefix + '/') ? path.slice(prefix.length + 1) : null
97
+ }
package/src/store.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { ArchiveBatch, DiffRecord, SidecarData } from './shared/types.js'
7
7
  import type { ReadState } from './shared/diff.js'
8
8
  import { fingerprint, isNoopHunk, locateHunks, normalizeForCompare, normalizeHunk, preciseHunk } from './shared/diff.js'
9
- import { archiveEntryFor, normalizeRecord } from './model.js'
9
+ import { archiveEntryFor, findCanceledPairs, findInversePrior, normalizeRecord } from './model.js'
10
10
  import { SIDECAR, SIDECAR_ARCHIVE } from './paths.js'
11
11
  import { log } from './log.js'
12
12
  import { debugRecord } from './debugLog.js'
@@ -246,6 +246,41 @@ export async function autoArchiveStale(ctx: Ctx, session: Session, cwd: string,
246
246
  return stale.length
247
247
  }
248
248
 
249
+ /**
250
+ * @public
251
+ * @author ddj 2026年10月10号
252
+ * 归档整记录精确抵消对;捕获时仅检查新记录,列表维护时检查整个桶。
253
+ * @param ctx DSH 上下文
254
+ * @param session 当前会话
255
+ * @param cwd 工作区
256
+ * @param bucket 活跃记录桶
257
+ * @param record 可选的新捕获记录
258
+ * @returns 本次归档记录数(不是配对数)
259
+ * @throws 归档失败时恢复内存标志并透传错误,已保存的捕获记录不受影响
260
+ */
261
+ export async function archiveCanceled(
262
+ ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, record?: DiffRecord,
263
+ ): Promise<number> {
264
+ const records = [...bucket.values()]
265
+ const prior = record ? findInversePrior(records, record) : null
266
+ const pairs = record ? (prior ? [[prior, record]] : []) : findCanceledPairs(records)
267
+ const canceled = pairs.flat()
268
+ if (!canceled.length) return 0
269
+ const original = canceled.map((rec) => ({ rec, at: rec.at, superseded: rec.superseded }))
270
+ const at = new Date().toISOString()
271
+ for (const rec of canceled) { rec.superseded = true; rec.at = at }
272
+ try {
273
+ await archiveRecords(ctx, session, cwd, bucket, canceled, '编辑过程自抵消(新增后还原),自动跳过审查')
274
+ } catch (error) {
275
+ for (const item of original) {
276
+ item.rec.superseded = item.superseded
277
+ item.rec.at = item.at
278
+ }
279
+ throw error
280
+ }
281
+ return canceled.length
282
+ }
283
+
249
284
  /** 读取归档 sidecar 文本(缺失/失败 → null)。 */
250
285
  export async function readArchiveText(ctx: Ctx, cwd: string | null): Promise<string | null> {
251
286
  const fs = ctx.get('fs')