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.
- package/README.md +6 -4
- package/lib/client.js +969 -166
- package/lib/client.js.map +1 -1
- package/lib/index.js +980 -19
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/client/events.ts +11 -5
- package/src/client/rpc.ts +10 -0
- package/src/client/sidebar/menuItems.ts +7 -6
- package/src/client/sidebar/panels/FileExplorer.ts +89 -72
- package/src/client/sidebar/panels/explorerView.ts +165 -0
- package/src/client/sidebar/panels/useExplorerSvn.ts +141 -0
- package/src/client/state/editorFindState.ts +102 -0
- package/src/client/state/editorViewState.ts +56 -0
- package/src/client/styles/editor.css +12 -0
- package/src/client/svnNotice.ts +24 -0
- package/src/client/ui/EditorView.ts +27 -50
- package/src/client/ui/SvnOverlay.ts +24 -0
- package/src/dap/manager.ts +65 -17
- package/src/model.ts +112 -1
- package/src/rpc.ts +2 -0
- package/src/shared/rpc.ts +6 -1
- package/src/shared/svn.ts +55 -0
- package/src/shared/svnExplorer.ts +97 -0
- package/src/store.ts +36 -1
- package/src/svn.ts +108 -6
- package/src/svnExplorer.ts +56 -0
- package/src/svnExplorerCache.ts +304 -0
- package/src/svnStatusStream.ts +135 -0
package/src/dap/manager.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
594
|
-
|
|
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 (
|
|
598
|
-
const candidates = await this.
|
|
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.
|
|
604
|
-
} else if (isBareChunk(sourceName || sourcePath)) {
|
|
605
|
-
//
|
|
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')
|