dsh-vscode-mode 0.4.6 → 0.5.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.
Files changed (44) hide show
  1. package/README.md +32 -2
  2. package/lib/client.js +8854 -4023
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1842 -65
  5. package/lib/index.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/client/index.ts +3 -0
  8. package/src/client/monaco/lsp/providers.ts +28 -6
  9. package/src/client/navHighlight.ts +96 -0
  10. package/src/client/paths.ts +2 -0
  11. package/src/client/rpc.ts +32 -12
  12. package/src/client/sidebar/SidebarView.ts +6 -2
  13. package/src/client/sidebar/menuItems.ts +93 -2
  14. package/src/client/sidebar/panels/FileExplorer.ts +18 -1
  15. package/src/client/sidebar/panels/SvnPanel.ts +303 -0
  16. package/src/client/sidebar/panels/index.ts +44 -3
  17. package/src/client/sidebar/types.ts +20 -0
  18. package/src/client/styles/editor.css +177 -0
  19. package/src/client/svnActions.ts +153 -0
  20. package/src/client/svnLog.ts +356 -0
  21. package/src/client/svnStatus.ts +363 -0
  22. package/src/client/svnStore.ts +150 -0
  23. package/src/client/tabMenu.ts +51 -0
  24. package/src/client/ui/EditorView.ts +583 -24
  25. package/src/client/ui/LogDialog.ts +333 -0
  26. package/src/client/ui/McpSettings.ts +98 -0
  27. package/src/client/ui/ModalShell.ts +79 -0
  28. package/src/client/ui/SideBySideDiff.tsx +139 -0
  29. package/src/client/ui/SnippetsPicker.ts +70 -58
  30. package/src/client/ui/SvnDiffPanel.ts +50 -0
  31. package/src/client/ui/SvnLogDialog.ts +1141 -0
  32. package/src/client/ui/SvnLogStats.ts +243 -0
  33. package/src/client/ui/commandCatalog.ts +72 -0
  34. package/src/debugLog.ts +81 -12
  35. package/src/fileOpenSettings.ts +40 -3
  36. package/src/index.ts +5 -1
  37. package/src/rpc.ts +41 -4
  38. package/src/shared/rpc.ts +53 -1
  39. package/src/shared/svn.ts +377 -0
  40. package/src/shared/svnActions.ts +296 -0
  41. package/src/svn.ts +870 -0
  42. package/src/svnLog.ts +315 -0
  43. package/src/svnText.ts +175 -0
  44. package/src/svnXml.ts +110 -0
@@ -0,0 +1,363 @@
1
+ /**
2
+ * dsh-vscode-mode client — SVN 能力状态层与动作执行。
3
+ *
4
+ * 数据源(status/changes)统一经 svnStore 工厂创建:scope 缓存 + 在途去重 + 事件广播 +
5
+ * 「失败不写缓存」语义由工厂单点保证,本模块只负责各自的 select 与消费侧投影。
6
+ * 状态:ensureSvnStatus → `edrv:svn-status`(命令/菜单显隐数据源)。
7
+ * 变更(P2):ensureSvnChanges → `edrv:svn-changes`(面板列表 + 文件树徽标查表)。
8
+ * 动作:svnUpdate / svnTortoise / svnRevert / svnAdd / svnDiffBase 统一执行与反馈文案。
9
+ * 作者 ddj 2026年09月16号
10
+ */
11
+ import type { SvnAction, SvnChangeEntry, SvnItemStatus, SvnStatusPayload, SvnUpdateEntry } from '../shared/svn.js'
12
+ import { SVN_TORTOISE_LABELS, SVN_UPDATE_LABEL } from '../shared/svn.js'
13
+ import { svnActionOn, svnActionsFor } from '../shared/svnActions.js'
14
+ import type { SvnActionContext } from '../shared/svnActions.js'
15
+ import { createSvnStore } from './svnStore.js'
16
+ import { rpc } from './rpc.js'
17
+
18
+ /** SVN 能力状态数据源(未加载 = null,菜单/命令随之隐藏)。 */
19
+ const statusStore = createSvnStore<'svn.status', SvnStatusPayload>({
20
+ method: 'svn.status',
21
+ event: 'edrv:svn-status',
22
+ select: (res) => ({ value: res as unknown as SvnStatusPayload }),
23
+ })
24
+
25
+ /** 工作副本变更数据源(未加载 = null;已加载无变更 = 空数组)。 */
26
+ const changesStore = createSvnStore<'svn.changes', SvnChangeEntry[]>({
27
+ method: 'svn.changes',
28
+ event: 'edrv:svn-changes',
29
+ select: (res) => ({
30
+ value: Array.isArray(res.entries) ? (res.entries as SvnChangeEntry[]) : [],
31
+ meta: { truncated: res.truncated === true, wcRoot: res.wcRoot },
32
+ }),
33
+ })
34
+
35
+ /**
36
+ * 拉取(或命中缓存的)SVN 状态:成功后写缓存并广播。
37
+ * @author ddj 2026年09月16号
38
+ * @param sessionId 会话 id(缺省时 host 按唯一活跃会话解析)
39
+ * @param scope 状态作用域(工作区优先;无 scope 不拉取)
40
+ * @param force 跳过客户端与 host 缓存强制重测
41
+ */
42
+ export function ensureSvnStatus(sessionId: string | undefined, scope: string | null | undefined, force = false): void {
43
+ statusStore.ensure(sessionId, scope, force)
44
+ }
45
+
46
+ /** 读取指定 scope 的状态(未加载返回 null)。 */
47
+ export function getSvnStatus(scope: string | null | undefined): SvnStatusPayload | null {
48
+ return statusStore.get(scope)
49
+ }
50
+
51
+ /** 命令面板可用性:最近一次加载判定「受管理且 svn CLI 可用」。 */
52
+ export function svnFeatureReady(): boolean {
53
+ const latest = statusStore.latest()
54
+ return Boolean(latest?.managed && latest.svnCli)
55
+ }
56
+
57
+ /** 最近一次加载的 SVN 能力状态(动作目录求值用;未加载返回 null)。 */
58
+ export function svnCurrentStatus(): SvnStatusPayload | null {
59
+ return statusStore.latest()
60
+ }
61
+
62
+ /** 命令面板可用性:最近一次加载判定「受管理且 TortoiseProc 可用」。 */
63
+ export function tortoiseUsable(): boolean {
64
+ const latest = statusStore.latest()
65
+ return Boolean(latest?.managed && latest.tortoise)
66
+ }
67
+
68
+ /**
69
+ * 强制重测(工作区「刷新」按钮/设置变更后):清缓存并 force 拉取。
70
+ * @author ddj 2026年09月16号
71
+ * @param sessionId 会话 id
72
+ * @param scope 状态作用域
73
+ */
74
+ export function refreshSvnStatus(sessionId: string | undefined, scope: string | null | undefined): void {
75
+ statusStore.refresh(sessionId, scope)
76
+ }
77
+
78
+ /**
79
+ * 拉取(或命中缓存的)工作副本变更:成功后写缓存并广播 `edrv:svn-changes`。
80
+ * 未受管理/CLI 不可用时保持「未加载」语义(不写空数组,避免把「未知」当「无变更」)。
81
+ * @author ddj 2026年09月16号
82
+ * @param sessionId 会话 id
83
+ * @param scope 状态作用域
84
+ * @param force 跳过 host 端 TTL 强制重查
85
+ */
86
+ export function ensureSvnChanges(sessionId: string | undefined, scope: string | null | undefined, force = false): void {
87
+ changesStore.ensure(sessionId, scope, force)
88
+ }
89
+
90
+ /**
91
+ * 读取指定 scope 的变更清单(未加载返回 null;已加载但无变更返回空数组)。
92
+ * @author ddj 2026年09月16号
93
+ * @param scope 状态作用域
94
+ * @returns 变更清单或 null
95
+ */
96
+ export function getSvnChanges(scope: string | null | undefined): SvnChangeEntry[] | null {
97
+ return changesStore.get(scope)
98
+ }
99
+
100
+ /**
101
+ * 变更清单是否被条目上限截断(面板提示用)。
102
+ * @author ddj 2026年09月16号
103
+ * @param scope 状态作用域
104
+ * @returns 是否截断
105
+ */
106
+ export function svnChangesCapped(scope: string | null | undefined): boolean {
107
+ return changesStore.meta(scope).truncated === true
108
+ }
109
+
110
+ /**
111
+ * 路径 → 变更条目映射(文件树徽标 O(1) 查表;未加载返回空表)。
112
+ * 同一路径出现多条时保留首条(svn status 对单路径只报一条)。
113
+ * @author ddj 2026年09月16号
114
+ * @param scope 状态作用域
115
+ * @returns 相对路径(`/` 分隔)→ 条目
116
+ */
117
+ export function svnChangeMapOf(scope: string | null | undefined): Record<string, SvnChangeEntry> {
118
+ const entries = getSvnChanges(scope)
119
+ if (!entries) return {}
120
+ const map: Record<string, SvnChangeEntry> = {}
121
+ for (const entry of entries) {
122
+ if (!(entry.path in map)) map[entry.path] = entry
123
+ }
124
+ return map
125
+ }
126
+
127
+ /**
128
+ * 强制刷新变更(面板 ⟳ / 提交后 / update 后):清缓存并 force 重查。
129
+ * @author ddj 2026年09月16号
130
+ * @param sessionId 会话 id
131
+ * @param scope 状态作用域
132
+ */
133
+ export function refreshSvnChanges(sessionId: string | undefined, scope: string | null | undefined): void {
134
+ changesStore.refresh(sessionId, scope)
135
+ }
136
+
137
+ /**
138
+ * 丢弃变更缓存(工作副本被外部改动后强制下次重查)。
139
+ * @author ddj 2026年09月16号
140
+ * @param scope 状态作用域(缺省清空全部)
141
+ */
142
+ export function clearSvnChanges(scope?: string | null): void {
143
+ changesStore.clear(scope)
144
+ }
145
+
146
+ /** 命令面板可用性:最近一次加载判定「受管理且 svn CLI 可用且存在变更」。 */
147
+ export function svnChangesReady(): boolean {
148
+ const latest = statusStore.latest()
149
+ const entries = changesStore.latest()
150
+ return Boolean(latest?.managed && latest.svnCli && entries && entries.length > 0)
151
+ }
152
+
153
+ /** 动作执行统一结果(message 已可直接落状态栏/notify)。 */
154
+ export interface SvnActionOutcome {
155
+ ok: boolean
156
+ message: string
157
+ /** update 的条目级明细(仅 svnUpdate 填充)。 */
158
+ entries?: SvnUpdateEntry[]
159
+ /** update 的冲突路径清单(仅 svnUpdate 填充)。 */
160
+ conflicts?: string[]
161
+ }
162
+
163
+ /**
164
+ * 执行 svn update(CLI,全平台;path 工作区相对,'' = 工作区根)。
165
+ * @author ddj 2026年09月16号
166
+ * @param sessionId 会话 id
167
+ * @param path 目标相对路径
168
+ * @returns 执行结果与反馈文案
169
+ */
170
+ export async function svnUpdate(sessionId: string | undefined, path: string): Promise<SvnActionOutcome> {
171
+ try {
172
+ const res = await rpc('svn.update', { sessionId, path })
173
+ if (!res.ok) return { ok: false, message: 'SVN 更新失败:' + res.error }
174
+ const conflicts = res.conflicts.length ? ';冲突:' + res.conflicts.join('、') : ''
175
+ // P3:附带条目级明细,供编辑区结果条逐条展示(冲突高亮)
176
+ return { ok: true, message: 'SVN 更新:' + res.summary + conflicts, entries: res.entries ?? [], conflicts: res.conflicts ?? [] }
177
+ } catch (error) {
178
+ return { ok: false, message: 'SVN 更新失败:' + String(error) }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 发射 TortoiseSVN 对话框(Windows 过渡增强)。
184
+ * @author ddj 2026年09月16号
185
+ * @param sessionId 会话 id
186
+ * @param action Tortoise 动作
187
+ * @param path 目标相对路径(文件/目录)
188
+ * @returns 执行结果与反馈文案
189
+ */
190
+ export async function svnTortoise(sessionId: string | undefined, action: SvnAction, path: string): Promise<SvnActionOutcome> {
191
+ try {
192
+ const res = await rpc('svn.tortoise', { sessionId, action, path })
193
+ if (!res.ok) return { ok: false, message: 'TortoiseSVN 启动失败:' + res.error }
194
+ return { ok: true, message: '已打开:' + SVN_TORTOISE_LABELS[action] }
195
+ } catch (error) {
196
+ return { ok: false, message: 'TortoiseSVN 启动失败:' + String(error) }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * 基线差异结果(左侧 BASE 可为 null:added/未提交条目没有 pristine)。
202
+ * reason 为 'no-pristine' 时属正常业务分支,调用方应提示而非报错。
203
+ */
204
+ export interface SvnDiffBaseOutcome {
205
+ ok: boolean
206
+ /** 左侧(BASE 版本)内容;null = 无 pristine。 */
207
+ base: string | null
208
+ /** 右侧(工作区磁盘内容)。 */
209
+ working: string
210
+ /** 无 BASE 时的原因('no-pristine' | 'cat-failed')。 */
211
+ reason?: string
212
+ /** 失败提示文案(ok=false,或 ok=true 时的补充说明)。 */
213
+ message: string
214
+ }
215
+
216
+ /**
217
+ * 读取基线差异(BASE 左 / 工作区右)。
218
+ * @author ddj 2026年09月16号
219
+ * @param sessionId 会话 id
220
+ * @param path 工作区相对路径
221
+ * @returns 双侧内容或错误文案
222
+ */
223
+ export async function svnDiffBase(sessionId: string | undefined, path: string): Promise<SvnDiffBaseOutcome> {
224
+ try {
225
+ const res = await rpc('svn.diffBase', { sessionId, path })
226
+ if (!res.ok) return { ok: false, base: null, working: '', message: '读取基线失败:' + res.error }
227
+ if (res.base === null) {
228
+ const message = res.reason === 'no-pristine'
229
+ ? '该文件尚无基线版本(未提交的新增文件)'
230
+ : '读取基线失败' + (res.error ? ':' + res.error : '')
231
+ return { ok: true, base: null, working: res.working, message }
232
+ }
233
+ return { ok: true, base: res.base, working: res.working, message: '已打开基线差异' }
234
+ } catch (error) {
235
+ return { ok: false, base: null, working: '', message: '读取基线失败:' + String(error) }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 批量还原(`svn revert -R`,CLI 全平台;新增文件不会被删除)。
241
+ * @author ddj 2026年09月16号
242
+ * @param sessionId 会话 id
243
+ * @param paths 工作区相对路径列表
244
+ * @returns 执行结果与反馈文案
245
+ */
246
+ export async function svnRevert(sessionId: string | undefined, paths: string[]): Promise<SvnActionOutcome> {
247
+ try {
248
+ const res = await rpc('svn.revert', { sessionId, paths })
249
+ if (!res.ok) return { ok: false, message: 'SVN 还原失败:' + res.error }
250
+ return { ok: true, message: 'SVN 还原:' + res.summary }
251
+ } catch (error) {
252
+ return { ok: false, message: 'SVN 还原失败:' + String(error) }
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 批量加入版本控制(`svn add`)。
258
+ * @author ddj 2026年09月16号
259
+ * @param sessionId 会话 id
260
+ * @param paths 工作区相对路径列表
261
+ * @returns 执行结果与反馈文案
262
+ */
263
+ export async function svnAdd(sessionId: string | undefined, paths: string[]): Promise<SvnActionOutcome> {
264
+ try {
265
+ const res = await rpc('svn.add', { sessionId, paths })
266
+ if (!res.ok) return { ok: false, message: '加入版本控制失败:' + res.error }
267
+ return { ok: true, message: '加入版本控制:' + res.summary }
268
+ } catch (error) {
269
+ return { ok: false, message: '加入版本控制失败:' + String(error) }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Monaco 右键菜单 SVN 动作条目(编辑区动态注册用)。
275
+ *
276
+ * 与树/页签菜单同源:条目来自 shared 动作目录(surface='tab'),显隐规则经 svnActionOn
277
+ * 求值(含「自研替换时间线」的能力覆盖判定),故不会再出现「编辑区右键只有 Tortoise 项」
278
+ * 或「自研项漏挂某入口」的不一致。
279
+ */
280
+ export interface SvnEditorAction {
281
+ /** Monaco action id(稳定前缀 `edrv.svnEditor*`,与命令栏 id 不同通道)。 */
282
+ id: string
283
+ /** 菜单展示文案。 */
284
+ label: string
285
+ /** 动作类别:update = 工作区更新(全平台)、cli = 其他自研 CLI 动作、tortoise = 官方对话框。 */
286
+ kind: 'update' | 'cli' | 'tortoise'
287
+ /** 直接执行的自研动作 id(kind = cli/update 时)。 */
288
+ actionId?: string
289
+ /** kind = tortoise 时的 Tortoise 动作。 */
290
+ tortoiseAction?: SvnAction
291
+ }
292
+
293
+ /** 动作 id → Monaco action id 后缀(保持历史 id 稳定:既有 id 不因重构而变)。 */
294
+ const EDITOR_ACTION_ID: Record<string, string> = {
295
+ update: 'Update',
296
+ 'diff-base': 'Diff',
297
+ add: 'Add',
298
+ 'revert-cli': 'Revert',
299
+ log: 'Log',
300
+ 'tortoise-commit': 'Commit',
301
+ 'tortoise-log': 'TortoiseLog',
302
+ 'tortoise-diff': 'TortoiseDiff',
303
+ 'tortoise-blame': 'Blame',
304
+ 'tortoise-revert': 'TortoiseRevert',
305
+ }
306
+
307
+ /**
308
+ * 按状态推导编辑区右键 SVN 动作表(纯函数;未检出/未受管理返回空 = 整组隐藏)。
309
+ *
310
+ * 复用 surface='tab' 的动作集合(页签与编辑区都针对「当前文件」这一语义),
311
+ * 并注入活动文件的版本控制状态供状态门禁求值。被自研能力覆盖的 Tortoise 项
312
+ * (差异/还原/日志)按时间线自动隐藏,留提交与追溯。
313
+ *
314
+ * @author ddj 2026年09月16号
315
+ * @param status SVN 能力状态(未加载传 null)
316
+ * @param target 活动文件的版本控制信息(缺省按「受版本控制、可比较」处理)
317
+ * @returns 动作描述表
318
+ */
319
+ export function svnEditorActions(
320
+ status: SvnStatusPayload | null,
321
+ target?: { versioned?: boolean; status?: SvnItemStatus; diffable?: boolean },
322
+ ): SvnEditorAction[] {
323
+ if (!status?.managed) return []
324
+ const ctx: SvnActionContext = {
325
+ managed: status.managed,
326
+ svnCli: status.svnCli,
327
+ tortoise: status.tortoise,
328
+ svnFeatures: status.svnFeatures,
329
+ // 编辑区展示的是**一个文件**,故按 target='file' 求值:这样「比较/加入/还原/日志」
330
+ // 等面向文件的门禁(targets: file/directory)才会命中。若按 'editor' 求值,这些动作
331
+ // 会因目标类型不匹配而整体消失——P1 动作矩阵里编辑区右键本应有更新/提交/日志/差异/追溯/还原。
332
+ target: 'file',
333
+ versioned: target?.versioned ?? true,
334
+ status: target?.status,
335
+ diffable: target?.diffable ?? true,
336
+ }
337
+ const actions: SvnEditorAction[] = []
338
+ for (const def of svnActionsFor('tab')) {
339
+ if (!svnActionOn(def, ctx)) continue
340
+ const id = 'edrv.svnEditor' + (EDITOR_ACTION_ID[def.id] ?? def.id)
341
+ if ((def.capability ?? 'cli') === 'tortoise') {
342
+ actions.push({
343
+ id,
344
+ label: def.label,
345
+ kind: 'tortoise',
346
+ tortoiseAction: tortoiseActionOf(def.id),
347
+ })
348
+ } else {
349
+ actions.push({ id, label: def.label, kind: def.id === 'update' ? 'update' : 'cli', actionId: def.id })
350
+ }
351
+ }
352
+ return actions
353
+ }
354
+
355
+ /**
356
+ * Tortoise 动作 id(`tortoise-<action>`)→ SvnAction。
357
+ * @author ddj 2026年09月16号
358
+ * @param id 动作 id
359
+ * @returns Tortoise 动作名
360
+ */
361
+ function tortoiseActionOf(id: string): SvnAction {
362
+ return id.replace(/^tortoise-/, '') as SvnAction
363
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * dsh-vscode-mode client — SVN 数据源通用缓存层(scope 缓存 + 在途去重 + 事件广播)。
3
+ *
4
+ * 为什么抽工厂:status/changes 两套实现完全同形(cache/inFlight/latest/emit 四件套),
5
+ * P3 的 log 与 P4 的 commitPlan 还要再来两遍。重复的不只是十几行样板,更关键的是
6
+ * 「未加载(null) vs 已加载但为空([])」与「失败不写缓存」这两条语义——各写一遍必然漂移。
7
+ *
8
+ * 语义约定(所有 SVN 数据源一致):
9
+ * - `get` 未加载返回 null(调用方据此显示「读取中」而非「空」)
10
+ * - 失败(ok:false / 抛错)**不写缓存**,保持「未加载」,避免把「未知」当「无数据」
11
+ * - 在途请求按 key 去重:同一 key 并发只看一次 RPC
12
+ * - `force` 绕过客户端缓存(host 侧 TTL 由 force 参数继续透传)
13
+ * 作者 ddj 2026年09月16号
14
+ */
15
+ import type { RpcMethod, RpcRequestMap, RpcResult } from '../shared/rpc.js'
16
+ import { rpc } from './rpc.js'
17
+
18
+ /**
19
+ * 轻量诊断 trace(dsh-debug 埋点协议):写入 window.__DSH_DEBUG_LOG__(环形 500 条),
20
+ * 浏览器端 readback 定位 RPC/事件链路问题;异常静默,绝不干扰主流程。
21
+ * @author ddj 2026年09月18号
22
+ * @param event 事件名(建议 `模块.动作`)
23
+ * @param data 附加诊断数据(不含敏感信息)
24
+ */
25
+ export function dshTrace(event: string, data?: unknown): void {
26
+ try {
27
+ if (typeof window === 'undefined') return
28
+ const w = window as unknown as { __DSH_DEBUG_LOG__?: Array<Record<string, unknown>> }
29
+ const buf = (w.__DSH_DEBUG_LOG__ = w.__DSH_DEBUG_LOG__ || [])
30
+ buf.push({ t: Date.now(), plugin: 'dsh-vscode-mode', event, data })
31
+ if (buf.length > 500) buf.splice(0, buf.length - 500)
32
+ } catch { /* 诊断不干扰主流程 */ }
33
+ }
34
+
35
+ /** 一个数据源的最小配置。 */
36
+ export interface SvnStoreConfig<M extends RpcMethod, T> {
37
+ /** 对应的 RPC 方法名。 */
38
+ method: M
39
+ /**
40
+ * 请求参数(除 sessionId/force 外的固定部分按 key 计算)。
41
+ * @param key 缓存键(scope 或 `scope::目标` 复合键)
42
+ */
43
+ args?: (key: string) => Omit<RpcRequestMap[M], 'sessionId' | 'force'>
44
+ /**
45
+ * 从成功载荷中抽出要缓存的数据与附带标记。
46
+ * @param res 成功响应
47
+ */
48
+ select: (res: ({ ok: true } & Record<string, unknown>)) => { value: T; meta?: Record<string, unknown> }
49
+ /** 广播事件名(`edrv:svn-*`);缺省不广播。 */
50
+ event?: string
51
+ }
52
+
53
+ /** 数据源实例(供渲染层读取与命令判定)。 */
54
+ export interface SvnStore<M extends RpcMethod, T> {
55
+ /**
56
+ * 拉取(命中缓存或已在途则跳过)。
57
+ * @param extra 调用期动态参数(如「加载更多」的 limit;覆盖 args 的同名字段,P1-8)
58
+ */
59
+ ensure: (sessionId: string | undefined, key: string | null | undefined, force?: boolean, extra?: Record<string, unknown>) => void
60
+ /** 读缓存(未加载返回 null)。 */
61
+ get: (key: string | null | undefined) => T | null
62
+ /** 附带标记(如 truncated)。 */
63
+ meta: (key: string | null | undefined) => Record<string, unknown>
64
+ /** 最近一次加载的值(跨 key 镜像;命令可用性判定用)。 */
65
+ latest: () => T | null
66
+ /** 清缓存并强制重取。 */
67
+ refresh: (sessionId: string | undefined, key: string | null | undefined, extra?: Record<string, unknown>) => void
68
+ /** 丢弃缓存(不重取)。 */
69
+ clear: (key?: string | null) => void
70
+ /** 用现成数据直接写入(动作后本地乐观更新/就地改值)。 */
71
+ put: (key: string, value: T, meta?: Record<string, unknown>) => void
72
+ }
73
+
74
+ /**
75
+ * 创建 SVN 数据源缓存实例。
76
+ * @author ddj 2026年09月16号
77
+ * @param config 数据源配置
78
+ * @returns 数据源实例
79
+ */
80
+ export function createSvnStore<M extends RpcMethod, T>(config: SvnStoreConfig<M, T>): SvnStore<M, T> {
81
+ const cache = new Map<string, T>()
82
+ const metas = new Map<string, Record<string, unknown>>()
83
+ const inFlight = new Map<string, Promise<void>>()
84
+ let latestValue: T | null = null
85
+
86
+ const emit = (key: string): void => {
87
+ if (!config.event || typeof window === 'undefined') return
88
+ window.dispatchEvent(new CustomEvent(config.event, { detail: { scope: key, key } }))
89
+ }
90
+
91
+ const ensure = (sessionId: string | undefined, key: string | null | undefined, force = false, extra?: Record<string, unknown>): void => {
92
+ if (!key || inFlight.has(key)) return
93
+ if (!force && cache.has(key)) return
94
+ const fixed = config.args ? config.args(key) : {}
95
+ const task = rpc(config.method, { ...fixed, ...extra, sessionId, force: force || undefined } as RpcRequestMap[M])
96
+ .then((res) => {
97
+ if (!res.ok) {
98
+ dshTrace('svnStore.rpcError', { method: config.method, key, error: (res as { error?: string }).error })
99
+ return
100
+ }
101
+ const picked = config.select(res as { ok: true } & Record<string, unknown>)
102
+ cache.set(key, picked.value)
103
+ if (picked.meta) metas.set(key, picked.meta)
104
+ latestValue = picked.value
105
+ emit(key)
106
+ })
107
+ .catch((err) => {
108
+ // 拉取失败保持旧值;未加载过即保持「未加载」(诊断 trace 供 dsh-debug 读回定位)
109
+ dshTrace('svnStore.fetchError', { method: config.method, key, error: String(err) })
110
+ })
111
+ .finally(() => {
112
+ inFlight.delete(key)
113
+ })
114
+ inFlight.set(key, task as Promise<void>)
115
+ }
116
+
117
+ const refresh = (sessionId: string | undefined, key: string | null | undefined, extra?: Record<string, unknown>): void => {
118
+ if (!key) return
119
+ cache.delete(key)
120
+ ensure(sessionId, key, true, extra)
121
+ }
122
+
123
+ return {
124
+ ensure,
125
+ get: (key) => (key ? cache.get(key) ?? null : null),
126
+ meta: (key) => (key ? metas.get(key) ?? {} : {}),
127
+ latest: () => latestValue,
128
+ refresh,
129
+ clear: (key) => {
130
+ if (key) { cache.delete(key); metas.delete(key) }
131
+ else { cache.clear(); metas.clear() }
132
+ },
133
+ put: (key, value, meta) => {
134
+ cache.set(key, value)
135
+ if (meta) metas.set(key, meta)
136
+ latestValue = value
137
+ emit(key)
138
+ },
139
+ }
140
+ }
141
+
142
+ /** 复合缓存键(同一数据源按多个维度缓存,如日志按 target 区分)。 */
143
+ export function svnStoreKey(scope: string, target?: string | null): string {
144
+ return target ? scope + '\u0000' + target : scope
145
+ }
146
+
147
+ /** 类型守卫:成功响应(便于 select 里安全取字段)。 */
148
+ export function isOkResult<M extends RpcMethod>(res: RpcResult<M>): res is { ok: true } & Record<string, unknown> {
149
+ return Boolean(res && (res as { ok?: unknown }).ok === true)
150
+ }
@@ -13,6 +13,10 @@
13
13
  * 作者 ddj 2026年09月11号
14
14
  */
15
15
  import { closeAll, closeOthers, closeRight, closeSaved, isTreeRevealable, type TabLike } from './tabActions.js'
16
+ import { isSvnDiffable } from '../shared/svn.js'
17
+ import type { SvnFeature, SvnItemStatus } from '../shared/svn.js'
18
+ import { svnActionOn, svnActionsFor } from '../shared/svnActions.js'
19
+ import type { SvnActionContext } from '../shared/svnActions.js'
16
20
 
17
21
  /** 菜单构建输入(EditorView 每次打开菜单时按最新状态快照传入)。 */
18
22
  export interface TabMenuState {
@@ -30,6 +34,14 @@ export interface TabMenuState {
30
34
  hasSession: boolean
31
35
  /** 「添加到对话」动作集是否可用。 */
32
36
  canAddToConversation: boolean
37
+ /** SVN 能力可用(受管理且 svn CLI 可用;SVN 更新项显隐)。 */
38
+ svnReady?: boolean
39
+ /** TortoiseProc 可用(Windows 过渡增强组显隐)。 */
40
+ tortoiseReady?: boolean
41
+ /** 已由自研界面提供等价能力的功能集合(落实「自研替换时间线」;缺省不隐藏任何 Tortoise 项)。 */
42
+ svnFeatures?: readonly SvnFeature[]
43
+ /** 右键目标文件的 SVN 状态(未在变更清单里为 undefined;决定 CLI 三项显隐)。 */
44
+ svnStatusOfTarget?: SvnItemStatus
33
45
  /** 「关闭」项的键位提示(缺省无提示;由调用方读 chordOf 注入)。 */
34
46
  closeChord?: string | null
35
47
  }
@@ -52,6 +64,44 @@ export const CLOSE_MENU_IDS = ['close', 'close-others', 'close-right', 'close-sa
52
64
  /** 本架构不支持的条目 id(参考图有、浏览器单编辑器实例无法实现)——显式登记以防误加。 */
53
65
  export const UNSUPPORTED_MENU_IDS = ['split-right', 'split-move', 'move-new-window', 'copy-new-window'] as const
54
66
 
67
+ /**
68
+ * 构建 SVN 组条目(动作清单来自 shared/svnActions.ts,与树菜单/命令栏同一份规则)。
69
+ *
70
+ * 收敛收益:此前 CLI 三项的状态矩阵在本文件与 sidebar/menuItems 各写一份,
71
+ * 极易漂移(P2 实测两处表述已略有差异)。现在只做「元数据 → 菜单条目」映射。
72
+ * @author ddj 2026年09月16号
73
+ * @param state 菜单状态快照
74
+ * @returns SVN 组条目(可为空数组)
75
+ */
76
+ function svnEntriesOf(state: TabMenuState): TabMenuEntry[] {
77
+ if (state.svnReady !== true && state.tortoiseReady !== true) return []
78
+ const status = state.svnStatusOfTarget
79
+ const context: SvnActionContext = {
80
+ managed: true,
81
+ svnCli: state.svnReady === true,
82
+ tortoise: state.tortoiseReady === true,
83
+ // 自研能力集合:驱动「自研替换时间线」(隐藏已被自研覆盖的 Tortoise 项)
84
+ svnFeatures: state.svnFeatures,
85
+ target: 'file',
86
+ versioned: status !== 'unversioned' && status !== 'ignored',
87
+ status,
88
+ diffable: isSvnDiffable(state.path, status),
89
+ }
90
+ const entries: TabMenuEntry[] = []
91
+ for (const action of svnActionsFor('tab')) {
92
+ if (!svnActionOn(action, context)) continue
93
+ entries.push({
94
+ id: 'svn-' + action.id,
95
+ label: action.label,
96
+ danger: action.danger === true,
97
+ // 分隔线属入口级外观:页签菜单的 SVN 组恒以分隔线起头(挂在前一组「固定」之后),
98
+ // 故由本入口决定,不放进共享元数据(否则会连带改动树菜单的分组外观)
99
+ separator: entries.length === 0,
100
+ })
101
+ }
102
+ return entries
103
+ }
104
+
55
105
  /**
56
106
  * 构建页签右键菜单条目。
57
107
  * @author ddj 2026年09月11号
@@ -82,6 +132,7 @@ export function buildTabMenu(state: TabMenuState): TabMenuEntry[] {
82
132
  label: pinned ? '取消固定' : '固定',
83
133
  separator: true,
84
134
  },
135
+ ...svnEntriesOf(state),
85
136
  ]
86
137
  }
87
138