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.
- package/README.md +32 -2
- package/lib/client.js +8854 -4023
- package/lib/client.js.map +1 -1
- package/lib/index.js +1842 -65
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/index.ts +3 -0
- package/src/client/monaco/lsp/providers.ts +28 -6
- package/src/client/navHighlight.ts +96 -0
- package/src/client/paths.ts +2 -0
- package/src/client/rpc.ts +32 -12
- package/src/client/sidebar/SidebarView.ts +6 -2
- package/src/client/sidebar/menuItems.ts +93 -2
- package/src/client/sidebar/panels/FileExplorer.ts +18 -1
- package/src/client/sidebar/panels/SvnPanel.ts +303 -0
- package/src/client/sidebar/panels/index.ts +44 -3
- package/src/client/sidebar/types.ts +20 -0
- package/src/client/styles/editor.css +177 -0
- package/src/client/svnActions.ts +153 -0
- package/src/client/svnLog.ts +356 -0
- package/src/client/svnStatus.ts +363 -0
- package/src/client/svnStore.ts +150 -0
- package/src/client/tabMenu.ts +51 -0
- package/src/client/ui/EditorView.ts +583 -24
- package/src/client/ui/LogDialog.ts +333 -0
- package/src/client/ui/McpSettings.ts +98 -0
- package/src/client/ui/ModalShell.ts +79 -0
- package/src/client/ui/SideBySideDiff.tsx +139 -0
- package/src/client/ui/SnippetsPicker.ts +70 -58
- package/src/client/ui/SvnDiffPanel.ts +50 -0
- package/src/client/ui/SvnLogDialog.ts +1141 -0
- package/src/client/ui/SvnLogStats.ts +243 -0
- package/src/client/ui/commandCatalog.ts +72 -0
- package/src/debugLog.ts +81 -12
- package/src/fileOpenSettings.ts +40 -3
- package/src/index.ts +5 -1
- package/src/rpc.ts +41 -4
- package/src/shared/rpc.ts +53 -1
- package/src/shared/svn.ts +377 -0
- package/src/shared/svnActions.ts +296 -0
- package/src/svn.ts +870 -0
- package/src/svnLog.ts +315 -0
- package/src/svnText.ts +175 -0
- 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
|
+
}
|
package/src/client/tabMenu.ts
CHANGED
|
@@ -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
|
|