dsh-vscode-mode 0.10.0 → 0.12.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 (49) hide show
  1. package/README.md +62 -11
  2. package/lib/client.js +2879 -1030
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2227 -1056
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/activePathStore.ts +28 -0
  8. package/src/client/commandBridge.ts +9 -41
  9. package/src/client/dap/hover.ts +2 -7
  10. package/src/client/index.ts +116 -14
  11. package/src/client/keybindings.ts +19 -73
  12. package/src/client/luaReservedWords.ts +10 -0
  13. package/src/client/monaco/lsp/keywords.ts +121 -0
  14. package/src/client/monaco/lsp/lspClient.ts +68 -6
  15. package/src/client/monaco/lsp/memberScope.ts +90 -0
  16. package/src/client/monaco/lsp/providers.ts +14 -4
  17. package/src/client/settingsDocRouter.ts +52 -0
  18. package/src/client/shortcutsOfficial.ts +451 -0
  19. package/src/client/sidebar/menuItems.ts +1 -0
  20. package/src/client/sidebar/panels/RulesPanel.ts +15 -11
  21. package/src/client/sidebar/types.ts +2 -0
  22. package/src/client/styles/editor.css +143 -37
  23. package/src/client/svnActions.ts +4 -4
  24. package/src/client/ui/ContextMenu.ts +4 -2
  25. package/src/client/ui/EditorView.ts +142 -135
  26. package/src/client/ui/McpSettings.ts +4 -5
  27. package/src/client/ui/QuickOpen.ts +10 -11
  28. package/src/client/ui/ShortcutSettings.ts +294 -0
  29. package/src/client/ui/SvnLogDialog.ts +200 -69
  30. package/src/client/ui/SvnUpdateDialog.ts +472 -0
  31. package/src/client/ui/commandCatalog.ts +36 -41
  32. package/src/client/ui/svnUpdateModel.ts +135 -0
  33. package/src/fileOpenSettings.ts +3 -0
  34. package/src/index.ts +2 -0
  35. package/src/lsp/completionCap.ts +80 -0
  36. package/src/lsp/manager.ts +42 -10
  37. package/src/lsp/rpc.ts +8 -5
  38. package/src/lsp/server.ts +18 -4
  39. package/src/rpc.ts +74 -2
  40. package/src/rules.ts +100 -44
  41. package/src/settingsDocPath.ts +30 -0
  42. package/src/shared/keybindings.ts +253 -0
  43. package/src/shared/rpc.ts +12 -2
  44. package/src/shared/rules.ts +9 -1
  45. package/src/shared/svn.ts +38 -0
  46. package/src/svn.ts +102 -7
  47. package/src/svnText.ts +22 -13
  48. package/src/svnUpdateJob.ts +588 -0
  49. package/src/client/ui/KeybindingsPanel.ts +0 -190
@@ -0,0 +1,451 @@
1
+ /**
2
+ * dsh-vscode-mode client — 官方 shortcuts 服务桥接层。
3
+ * 职责四件:① 运行时探测 ctx.shortcuts(可选服务,有界重试,不进 inject——旧版 DSH 无此服务
4
+ * 时不阻断装配);② safeRegister 逐命令注册(官方 register 对重复 id/保留默认/组合重叠抛错,
5
+ * 冲突时去掉默认键位降级注册,绝不阻断插件加载);③ 官方命令定义构建(commandCatalog → 官方
6
+ * command,resolve 统一走插件注册表 run,可用性为假返回 pass 不吞键);④ 旧 keybindings
7
+ * 设置一次性迁移进官方存储。
8
+ * 作者 ddj 2026年10月
9
+ */
10
+ import { bindingToChord, chordToBinding, defaultKeybindings, normalizeKeybindings } from '../shared/keybindings.js'
11
+ import { matchEvent, parseChords } from './keybindings.js'
12
+ import type { OfficialBinding, ShortcutProfileKey } from '../shared/keybindings.js'
13
+ import { log } from './log.js'
14
+
15
+ const bridgeLog = log.child('shortcuts-official')
16
+
17
+ /** 迁移比对用的默认键位表副本(模块态,避免每次调用重建)。 */
18
+ const KEYBINDING_DEFAULTS_OF: Record<string, string> = defaultKeybindings()
19
+
20
+ //#region 官方服务鸭子类型(结构对齐 @deepseek-ai/dsh-client-shortcuts,不 import 官方包)
21
+
22
+ /** 快照存储(createSnapshotStore 形状)。 */
23
+ export interface SnapshotStoreLike<T> {
24
+ getSnapshot(): T
25
+ subscribe(listener: () => void): () => void
26
+ }
27
+
28
+ /** 官方目录行(catalog 快照项)。 */
29
+ export interface ShortcutCatalogRow {
30
+ id: string
31
+ label: string
32
+ binding: OfficialBinding | null
33
+ modified: boolean
34
+ issue: string | null
35
+ conflicts: string[]
36
+ keys: string[]
37
+ aria?: string
38
+ }
39
+
40
+ /** 官方配置快照。 */
41
+ export interface ShortcutConfigSnapshot {
42
+ revision: string
43
+ sequence: number
44
+ status: 'loading' | 'ready' | 'unreadable'
45
+ error: string | null
46
+ usingDefaults: boolean
47
+ }
48
+
49
+ /** 官方派发上下文。 */
50
+ export interface ShortcutInvokeContext {
51
+ region: string
52
+ modal: string | null
53
+ target?: unknown
54
+ source?: string
55
+ }
56
+
57
+ /** 官方 resolve 结果。 */
58
+ export type ShortcutResolution =
59
+ | { status: 'handled'; run: () => void }
60
+ | { status: 'pass' }
61
+ | { status: 'blocked'; reason?: string }
62
+
63
+ /** 官方可编辑命令定义(register 入参)。 */
64
+ export interface ShortcutCommandLike {
65
+ id: string
66
+ label: () => string
67
+ aliases?: string[]
68
+ defaults?: Partial<Record<ShortcutProfileKey, OfficialBinding>>
69
+ regions: string[]
70
+ modals: string[]
71
+ resolve: (context: ShortcutInvokeContext) => ShortcutResolution
72
+ }
73
+
74
+ /** 官方偏好编辑操作。 */
75
+ export type ShortcutEditLike =
76
+ | { type: 'set'; id: string; binding: OfficialBinding }
77
+ | { type: 'reset'; id: string }
78
+ | { type: 'reset-all' }
79
+
80
+ /** 官方 shortcuts 服务(ShortcutsService 结构子集,鸭子类型)。 */
81
+ export interface ShortcutsServiceLike {
82
+ register(command: ShortcutCommandLike): () => void
83
+ describeBinding(binding: OfficialBinding | null): { binding: OfficialBinding | null; keys: string[]; issue: string | null; conflicts: string[] }
84
+ edit(edit: ShortcutEditLike, revision: string): Promise<{ status: string; issue?: string | null; conflicts?: string[] }>
85
+ recording(active: boolean): Promise<void>
86
+ catalog: SnapshotStoreLike<ShortcutCatalogRow[]>
87
+ config: SnapshotStoreLike<ShortcutConfigSnapshot>
88
+ fixedCatalog: SnapshotStoreLike<unknown[]>
89
+ platform: string
90
+ runtime: string
91
+ /** ShortcutRegistry 实例(菜单路径 invoke;用于程序化打开官方弹窗)。 */
92
+ registry?: { invoke(id: string, context: ShortcutInvokeContext): unknown }
93
+ }
94
+
95
+ //#endregion
96
+
97
+ /** 探测节奏(与插件其他可选服务探测一致:15 次 × 2s)。 */
98
+ export interface ProbeOptions {
99
+ schedule: (fn: () => void, ms: number) => void
100
+ attempts?: number
101
+ intervalMs?: number
102
+ }
103
+
104
+ /** 结构探测:get('shortcuts') 命中且具备核心能力才算服务就绪(设置组件运行时自愈探测复用)。 */
105
+ export function detectShortcuts(ctx: { get: (name: string) => unknown }): ShortcutsServiceLike | null {
106
+ try {
107
+ const service = ctx.get('shortcuts') as ShortcutsServiceLike | undefined
108
+ if (service && typeof service.register === 'function' && typeof service.edit === 'function' && service.catalog && service.config) return service
109
+ } catch {
110
+ /* 服务缺失/构造失败按未命中处理 */
111
+ }
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * 探测官方 shortcuts 服务:立即探测一次,未命中按节奏有界重试;命中或用尽均回调一次。
117
+ * @author ddj 2026年10月
118
+ * @param ctx 客户端上下文
119
+ * @param options schedule/重试节奏 + onReady 回调
120
+ * @returns 取消探测的注销函数
121
+ */
122
+ export function awaitShortcutsService(ctx: { get: (name: string) => unknown }, options: ProbeOptions & { onReady: (service: ShortcutsServiceLike | null) => void }): () => void {
123
+ const maxAttempts = options.attempts ?? 15
124
+ const interval = options.intervalMs ?? 2000
125
+ let attempts = 0
126
+ let settled = false
127
+ const tick = (): void => {
128
+ if (settled) return
129
+ const service = detectShortcuts(ctx)
130
+ if (service !== null) {
131
+ settled = true
132
+ options.onReady(service)
133
+ return
134
+ }
135
+ if (attempts >= maxAttempts) {
136
+ settled = true
137
+ options.onReady(null)
138
+ return
139
+ }
140
+ attempts += 1
141
+ options.schedule(tick, interval)
142
+ }
143
+ tick()
144
+ return () => {
145
+ settled = true
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 逐命令安全注册:官方 register 抛错(重复 id/保留默认/组合重叠)时去掉默认键位重试一次,
151
+ * 保证命令仍进官方目录(可被用户手动绑定),注册路径绝不抛错、绝不阻断装配。
152
+ * @author ddj 2026年10月
153
+ * @param service 官方服务
154
+ * @param command 官方命令定义
155
+ * @returns 注销函数(注册失败返回空操作)
156
+ */
157
+ export function safeRegister(service: ShortcutsServiceLike, command: ShortcutCommandLike): () => void {
158
+ try {
159
+ return service.register(command)
160
+ } catch (error) {
161
+ if (!command.defaults || Object.keys(command.defaults).length === 0) {
162
+ bridgeLog.warn('官方快捷键注册失败(' + command.id + '):' + String(error))
163
+ return () => {}
164
+ }
165
+ bridgeLog.warn('官方快捷键默认键位被拒(' + command.id + '),降级为未绑定:' + String(error))
166
+ try {
167
+ return service.register({ ...command, defaults: {} })
168
+ } catch (retryError) {
169
+ bridgeLog.warn('官方快捷键注册失败(' + command.id + '):' + String(retryError))
170
+ return () => {}
171
+ }
172
+ }
173
+ }
174
+
175
+ /** 插件指令注册表执行子集(commandRegistry 形状)。 */
176
+ export interface CommandRunner {
177
+ isAvailable(id: string): boolean
178
+ run(id: string): boolean
179
+ }
180
+
181
+ /**
182
+ * 官方服务未就绪时复用旧版窗口键位语义;服务出现后注销,避免双执行。
183
+ * @author ddj 2026年09月28号
184
+ * @param defs 按目录序排列的插件命令
185
+ * @param runner 指令注册表
186
+ * @param chords 当前旧设置键位获取函数
187
+ * @param target 键盘事件目标(缺省 window,测试可注入)
188
+ * @returns 注销监听的函数
189
+ */
190
+ export function installLegacyKeys(
191
+ defs: ReadonlyArray<{ id: string }>, runner: CommandRunner, chords: () => Record<string, string>,
192
+ target: Pick<EventTarget, 'addEventListener' | 'removeEventListener'> | null = typeof window === 'undefined' ? null : window,
193
+ ): () => void {
194
+ if (!target) return () => {}
195
+ const cache = new Map<string, ReturnType<typeof parseChords>>()
196
+ const onKey = (event: Event): void => {
197
+ const key = event as KeyboardEvent
198
+ const current = chords()
199
+ for (const def of defs) {
200
+ const chord = current[def.id]
201
+ if (!chord) continue
202
+ let bindings = cache.get(chord)
203
+ if (!bindings) {
204
+ bindings = parseChords(chord)
205
+ cache.set(chord, bindings)
206
+ }
207
+ if (!matchEvent(key, bindings) || !runner.isAvailable(def.id)) continue
208
+ key.preventDefault()
209
+ key.stopPropagation()
210
+ runner.run(def.id)
211
+ return
212
+ }
213
+ }
214
+ target.addEventListener('keydown', onKey, true)
215
+ return () => target.removeEventListener('keydown', onKey, true)
216
+ }
217
+
218
+ /**
219
+ * 插件命令定义 → 官方命令定义:默认键位取 SHORTCUT_PROFILES(缺省 = 不绑定);
220
+ * resolve 可用性为假返回 pass(不吞键,与旧桥「不可用不执行也不吞键」语义一致),
221
+ * 执行统一走注册表 run(自带可用性复核与异常捕获)。
222
+ * @author ddj 2026年10月
223
+ * @param def 插件命令定义
224
+ * @param runner 插件指令注册表
225
+ * @returns 官方命令定义
226
+ */
227
+ export function officialCommandOf(def: { id: string; label: string }, runner: CommandRunner): ShortcutCommandLike {
228
+ return {
229
+ id: def.id,
230
+ label: () => def.label,
231
+ // 必须为数组:官方弹窗渲染排名列表时 `...row.aliases` 直接展开,undefined 会让弹窗崩溃
232
+ aliases: [],
233
+ defaults: undefined,
234
+ regions: ['page', 'editable'],
235
+ modals: [],
236
+ resolve: () => {
237
+ if (!runner.isAvailable(def.id)) return { status: 'pass' }
238
+ return {
239
+ status: 'handled',
240
+ run: () => {
241
+ runner.run(def.id)
242
+ },
243
+ }
244
+ },
245
+ }
246
+ }
247
+
248
+ /**
249
+ * 注册全部命令进官方服务(逐命令 safeRegister 隔离,defaults 由调用方按 id 注入 SHORTCUT_PROFILES)。
250
+ * @author ddj 2026年10月
251
+ * @param service 官方服务
252
+ * @param defs 插件命令定义(id/label)
253
+ * @param runner 插件指令注册表
254
+ * @param profiles id → 官方 profile 默认键位
255
+ * @returns 聚合注销函数
256
+ */
257
+ export function registerOfficialShortcuts(
258
+ service: ShortcutsServiceLike,
259
+ defs: ReadonlyArray<{ id: string; label: string }>,
260
+ runner: CommandRunner,
261
+ profiles: Readonly<Record<string, Partial<Record<ShortcutProfileKey, OfficialBinding>>>>,
262
+ ): () => void {
263
+ const disposers: Array<() => void> = []
264
+ for (const def of defs) {
265
+ const command = officialCommandOf(def, runner)
266
+ command.defaults = profiles[def.id] ?? {}
267
+ disposers.push(safeRegister(service, command))
268
+ }
269
+ return () => {
270
+ while (disposers.length) disposers.pop()?.()
271
+ }
272
+ }
273
+
274
+ /**
275
+ * 订阅官方目录 → 重建 edrv.* 弦表(chordOf 数据源);目录变化即通知。
276
+ * 只收录 issue 为空的行(conflict 行保留——用户显式改键后 tooltip 仍显示其选择,
277
+ * 冲突状态由设置页徽标呈现)。
278
+ * @author ddj 2026年10月
279
+ * @param service 官方服务
280
+ * @param onChange 弦表回调(每次全量替换)
281
+ * @returns 注销函数
282
+ */
283
+ export function bindCatalogChords(service: ShortcutsServiceLike, onChange: (chords: Readonly<Record<string, string>>) => void): () => void {
284
+ const sync = (): void => {
285
+ const chords: Record<string, string> = {}
286
+ try {
287
+ for (const row of service.catalog.getSnapshot()) {
288
+ if (!row.id.startsWith('edrv.') || row.binding === null) continue
289
+ if (row.issue !== null) continue
290
+ const chord = bindingToChord(row.binding)
291
+ if (chord !== null) chords[row.id] = chord
292
+ }
293
+ } catch (error) {
294
+ bridgeLog.warn('官方目录弦表重建失败:' + String(error))
295
+ }
296
+ onChange(chords)
297
+ }
298
+ sync()
299
+ return service.catalog.subscribe(sync)
300
+ }
301
+
302
+ //#region 旧设置迁移
303
+
304
+ /** 旧设置 scope 最小形状(compat settingsBridge 绑定产物的最小面,结构与 SettingsScopeLike 兼容)。 */
305
+ export interface LegacySettingsScope {
306
+ getSnapshot(): { status?: string; value?: { keybindings?: unknown } }
307
+ }
308
+
309
+ /** 迁移结果:done=true 表示无需/已完成迁移(含「无差异」),false 表示中止待重试。 */
310
+ export interface MigrationResult {
311
+ imported: number
312
+ skipped: number
313
+ done: boolean
314
+ }
315
+
316
+ /**
317
+ * 官方 edit 单次写入;stale 表示其他窗口已更新,不自动用新 revision 重放。
318
+ * @author ddj 2026年09月28号
319
+ * @param service 官方快捷键服务
320
+ * @param edit 本次操作
321
+ * @returns 是否保存成功
322
+ */
323
+ async function editOnce(service: ShortcutsServiceLike, edit: ShortcutEditLike): Promise<boolean> {
324
+ try {
325
+ const result = await service.edit(edit, service.config.getSnapshot().revision)
326
+ if (result.status === 'saved') return true
327
+ bridgeLog.warn('官方快捷键写入未成功(' + edit.type + (edit.type === 'set' ? ' ' + edit.id : '') + '):' + result.status)
328
+ } catch (error) {
329
+ bridgeLog.warn('官方快捷键写入异常(' + edit.type + '):' + String(error))
330
+ }
331
+ return false
332
+ }
333
+
334
+ /**
335
+ * 逐条恢复插件自定义键位;官方 reset-all 会清空所有产品命令的覆盖,不能使用。
336
+ * @author ddj 2026年09月28号
337
+ * @param service 官方快捷键服务
338
+ * @returns 完成数量及首个失败命令(有失败时)
339
+ */
340
+ export async function resetPluginKeys(service: ShortcutsServiceLike): Promise<{ reset: number; failed?: string }> {
341
+ const ids = service.catalog.getSnapshot()
342
+ .filter((row) => row.id.startsWith('edrv.') && row.modified)
343
+ .map((row) => row.id)
344
+ let reset = 0
345
+ for (const id of ids) {
346
+ if (!await editOnce(service, { type: 'reset', id })) return { reset, failed: id }
347
+ reset += 1
348
+ }
349
+ return { reset }
350
+ }
351
+
352
+ const MIGRATION_KEY = 'dsh-vscode-mode.shortcuts-migrated.v1.'
353
+
354
+ /**
355
+ * 当前快捷键运行平台的迁移标记键;不同 Web/Desktop 和操作系统分别处理。
356
+ * @author ddj 2026年09月28号
357
+ * @param service 官方快捷键服务
358
+ * @returns 本机迁移标记键
359
+ */
360
+ function migrationKey(service: ShortcutsServiceLike): string {
361
+ return MIGRATION_KEY + service.runtime + '.' + service.platform
362
+ }
363
+
364
+ /**
365
+ * 检查当前端是否已处理旧设置;存储不可用时继续按目录状态判定。
366
+ * @author ddj 2026年09月28号
367
+ * @param service 官方快捷键服务
368
+ * @returns 是否已处理
369
+ */
370
+ function hasMigrated(service: ShortcutsServiceLike): boolean {
371
+ try {
372
+ return typeof localStorage !== 'undefined' && localStorage.getItem(migrationKey(service)) === '1'
373
+ } catch {
374
+ return false
375
+ }
376
+ }
377
+
378
+ /**
379
+ * 标记当前端已处理;旧设置永不清空,供其他平台及人工恢复使用。
380
+ * @author ddj 2026年09月28号
381
+ * @param service 官方快捷键服务
382
+ */
383
+ function markMigrated(service: ShortcutsServiceLike): void {
384
+ try {
385
+ if (typeof localStorage !== 'undefined') localStorage.setItem(migrationKey(service), '1')
386
+ } catch {
387
+ bridgeLog.warn('无法持久化旧快捷键迁移标记,本次配置仍保留')
388
+ }
389
+ }
390
+
391
+ /**
392
+ * 旧 keybindings 设置按当前 runtime/platform 一次性导入官方存储:
393
+ * - 仅处理与 KEYBINDING_DEFAULTS 有差异的单候选键位;多候选、空键位、冲突或
394
+ * 官方不可表示项不迁移,完整旧值仍保留(官方每条命令当前只允许一个绑定);
395
+ * - 官方已有自定义值时不覆盖,stale 冲突也不自动重放;
396
+ * - 旧配置供其他平台及人工恢复使用,绝不清空;当前端成功处理后记录本地标记,
397
+ * 防止重启后把已由用户重置的官方键位重新导入。
398
+ * @author ddj 2026年10月
399
+ * @param service 官方服务
400
+ * @param scope 旧设置 scope(settings bridge 绑定产物)
401
+ * @returns 迁移结果
402
+ */
403
+ export async function migrateLegacyKeybindings(service: ShortcutsServiceLike, scope: LegacySettingsScope): Promise<MigrationResult> {
404
+ const snapshot = scope.getSnapshot()
405
+ if (snapshot.status !== 'ready') return { imported: 0, skipped: 0, done: false }
406
+ if (service.config.getSnapshot().status !== 'ready') return { imported: 0, skipped: 0, done: false }
407
+ if (hasMigrated(service)) return { imported: 0, skipped: 0, done: true }
408
+ const stored = normalizeKeybindings(snapshot.value?.keybindings)
409
+ const overrides = Object.entries(stored).filter(([id, chord]) => chord !== KEYBINDING_DEFAULTS_OF[id])
410
+ if (overrides.length === 0) return { imported: 0, skipped: 0, done: true }
411
+ let imported = 0
412
+ let skipped = 0
413
+ for (const [id, chord] of overrides) {
414
+ if (!chord.trim() || chord.includes('|')) {
415
+ skipped += 1
416
+ bridgeLog.warn('迁移跳过(空键位或多候选,原值保留):' + id)
417
+ continue
418
+ }
419
+ const binding = chordToBinding(chord)
420
+ if (binding === null) {
421
+ skipped += 1
422
+ bridgeLog.warn('迁移跳过(官方不可表示):' + id + ' = ' + chord)
423
+ continue
424
+ }
425
+ if (service.catalog.getSnapshot().some((row) => row.id === id && row.modified)) {
426
+ skipped += 1
427
+ bridgeLog.warn('迁移跳过(官方存储已有自定义值):' + id)
428
+ continue
429
+ }
430
+ let probe: { conflicts: string[]; issue: string | null }
431
+ try {
432
+ probe = service.describeBinding(binding)
433
+ } catch (error) {
434
+ bridgeLog.warn('迁移中止(describeBinding 异常):' + String(error))
435
+ return { imported, skipped, done: false }
436
+ }
437
+ if (probe.conflicts.length > 0 || probe.issue !== null) {
438
+ skipped += 1
439
+ bridgeLog.warn('迁移跳过(与官方键冲突/保留):' + id + ' = ' + chord)
440
+ continue
441
+ }
442
+ const saved = await editOnce(service, { type: 'set', id, binding })
443
+ if (!saved) return { imported, skipped, done: false }
444
+ imported += 1
445
+ }
446
+ markMigrated(service)
447
+ bridgeLog.info('旧快捷键设置已处理(旧值保留):导入 ' + imported + ' 条,跳过 ' + skipped + ' 条')
448
+ return { imported, skipped, done: true }
449
+ }
450
+
451
+ //#endregion
@@ -504,6 +504,7 @@ function treeRunCtx(target: TreeMenuTarget, ctx: SidebarCtx): SvnActionRunCtx {
504
504
  path: target.path,
505
505
  notify: (message) => ctx.notify?.(message),
506
506
  openSvnDiff: (p) => ctx.openSvnDiff?.(p),
507
+ openSvnUpdate: (p) => ctx.openSvnUpdate?.(p),
507
508
  openSvnLog: ctx.openSvnLog,
508
509
  refreshChanges: ctx.refreshSvnChanges,
509
510
  // 确认对话框可注入(测试/宿主可替换;缺省走 window.confirm)
@@ -152,6 +152,8 @@ function RuleRow(props) {
152
152
  React.createElement('div', { className: 'edrv-rules-line1' },
153
153
  React.createElement('span', { className: 'edrv-rules-file' }, rule.file),
154
154
  !props.narrow ? React.createElement('span', { className: 'edrv-rules-path' }, '[' + rule.relHint + rule.file + ']') : null,
155
+ props.narrow && rule.scope === 'user' && rule.origin === 'legacy'
156
+ ? React.createElement('span', { className: 'edrv-rules-path', title: rule.absPath }, '旧目录') : null,
155
157
  !rule.enabled ? React.createElement('span', { className: 'edrv-rules-offtag' }, '已停用') : null),
156
158
  React.createElement('div', { className: 'edrv-rules-sub' },
157
159
  React.createElement('span', { className: 'edrv-rules-sublabel' }, '类型:'),
@@ -288,7 +290,7 @@ export function RulesPanel(props) {
288
290
  if (scope === 'project' && !cwd) { notify('当前会话没有工作区,无法新建项目规则'); return }
289
291
  const parsed = parseRuleFm(NEW_TEMPLATE)
290
292
  setForm({
291
- mode: 'new', scope, workspacePath: scope === 'project' ? cwd : undefined,
293
+ mode: 'new', scope, origin: scope === 'user' ? 'current' : undefined, workspacePath: scope === 'project' ? cwd : undefined,
292
294
  relHint: scope === 'project' ? '.dsh/rules/' : 'rules/', file: '', content: NEW_TEMPLATE,
293
295
  fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', '), error: '', saving: false,
294
296
  })
@@ -299,12 +301,12 @@ export function RulesPanel(props) {
299
301
  * @param rule 目标规则
300
302
  */
301
303
  const openEdit = (rule) => {
302
- rpc('rules.read', { scope: rule.scope, workspacePath: rule.scope === 'project' ? rule.workspacePath : undefined, file: rule.file })
304
+ rpc('rules.read', { scope: rule.scope, origin: rule.origin, workspacePath: rule.scope === 'project' ? rule.workspacePath : undefined, file: rule.file })
303
305
  .then((res) => {
304
306
  if (!res || !res.ok) { notify(res?.error ?? '读取规则失败'); return }
305
307
  const parsed = parseRuleFm(res.content)
306
308
  setForm({
307
- mode: 'edit', scope: rule.scope, workspacePath: rule.workspacePath, relHint: rule.relHint,
309
+ mode: 'edit', scope: rule.scope, origin: rule.origin, workspacePath: rule.workspacePath, relHint: rule.relHint,
308
310
  file: rule.file, content: res.content,
309
311
  fmType: parsed.type, desc: parsed.description, globs: parsed.globs.join(', '), error: '', saving: false,
310
312
  })
@@ -342,7 +344,7 @@ export function RulesPanel(props) {
342
344
  if (!file) { setForm({ ...next, saving: false, error: '文件名不能为空' }); return }
343
345
  if (form.fmType === 'auto' && globList(form.globs).length === 0) { setForm({ ...next, saving: false, error: '自动规则需要至少一个 glob(逗号分隔)' }); return }
344
346
  setForm(next)
345
- rpc('rules.save', { scope: form.scope, workspacePath: form.workspacePath, file, content: form.content })
347
+ rpc('rules.save', { scope: form.scope, origin: form.origin, workspacePath: form.workspacePath, file, content: form.content })
346
348
  .then((res) => {
347
349
  if (res && res.ok) {
348
350
  setForm(null)
@@ -357,8 +359,10 @@ export function RulesPanel(props) {
357
359
  * @param rule 目标规则
358
360
  */
359
361
  const removeRule = (rule) => {
360
- if (!window.confirm('删除规则 ' + rule.file + '?(不可恢复)')) return
361
- rpc('rules.remove', { scope: rule.scope, workspacePath: rule.workspacePath, file: rule.file })
362
+ const reveal = rule.scope === 'user' && rule.origin !== 'legacy'
363
+ ? '\n若旧目录有同名规则,删除后它将重新生效。' : ''
364
+ if (!window.confirm('删除规则 ' + rule.absPath + '?(不可恢复)' + reveal)) return
365
+ rpc('rules.remove', { scope: rule.scope, origin: rule.origin, workspacePath: rule.workspacePath, file: rule.file })
362
366
  .then((res) => {
363
367
  if (res && res.ok) { notify('已删除:' + rule.file); load() } else notify(res?.error ?? '删除失败')
364
368
  }).catch((e) => notify('删除异常: ' + String(e)))
@@ -372,13 +376,13 @@ export function RulesPanel(props) {
372
376
  const enabled = !rule.enabled
373
377
  const applyLocal = (on) => setData((prev) => prev ? {
374
378
  ...prev,
375
- user: prev.user.map((r) => (r.scope === 'user' && r.file === rule.file ? { ...r, enabled: on } : r)),
379
+ user: prev.user.map((r) => (rule.scope === 'user' && r.file === rule.file && (r.origin ?? 'current') === (rule.origin ?? 'current') ? { ...r, enabled: on } : r)),
376
380
  projects: prev.projects.map((p) => (p.workspacePath === rule.workspacePath
377
381
  ? { ...p, rules: p.rules.map((r) => (r.file === rule.file ? { ...r, enabled: on } : r)) }
378
382
  : p)),
379
383
  } : prev)
380
384
  applyLocal(enabled)
381
- rpc('rules.toggle', { scope: rule.scope, workspacePath: rule.workspacePath, file: rule.file, enabled })
385
+ rpc('rules.toggle', { scope: rule.scope, origin: rule.origin, workspacePath: rule.workspacePath, file: rule.file, enabled })
382
386
  .then((res) => {
383
387
  if (res && res.ok) { applyLocal(res.rule?.enabled === true); notify(res.rule?.enabled === false ? '已停用:' + rule.file : '已启用:' + rule.file) }
384
388
  else { applyLocal(!enabled); notify(res?.error ?? '切换失败') }
@@ -411,7 +415,7 @@ export function RulesPanel(props) {
411
415
  if (!activeProject()) return '当前工作区未注册为 DSH workspace,暂不能管理其项目规则'
412
416
  return '<工作区>/.dsh/rules/ 还没有规则,点击「+ 新建规则」创建'
413
417
  }
414
- return '还没有用户规则(~/.dsh/rules/),点击「+ 新建规则」创建'
418
+ return '还没有用户规则(当前 DSH_HOME/rules/ 或旧目录 ~/.dsh/rules/),点击「+ 新建规则」创建'
415
419
  }
416
420
 
417
421
  const renderBody = () => {
@@ -433,13 +437,13 @@ export function RulesPanel(props) {
433
437
  onToggle: (rule) => toggleRule({ ...rule, workspacePath: rule.scope === 'project' ? cwd : undefined }),
434
438
  }
435
439
  return React.createElement('div', { className: 'edrv-rules-list' },
436
- rows.map((rule) => React.createElement(RuleRow, { key: rule.scope + ':' + rule.file, rule, ...common })))
440
+ rows.map((rule) => React.createElement(RuleRow, { key: rule.scope + ':' + (rule.origin ?? 'project') + ':' + rule.file, rule, ...common })))
437
441
  }
438
442
 
439
443
  return React.createElement('div', { className: 'edrv-rules-panel', ref: panelRef },
440
444
  renderTabs(),
441
445
  renderWsLine(),
442
446
  React.createElement('div', { className: 'edrv-rules-hint' },
443
- tab === 'user' ? '用户规则对所有会话生效(存放于 ~/.dsh/rules/)' : '项目规则仅对所在工作区的会话生效(存放于 <工作区>/.dsh/rules/)'),
447
+ tab === 'user' ? '读取 DSH_HOME/rules/ 与 ~/.dsh/rules/;同名时当前目录优先,新建保存到当前目录' : '项目规则仅对所在工作区的会话生效(存放于 <工作区>/.dsh/rules/)'),
444
448
  renderBody())
445
449
  }
@@ -45,6 +45,8 @@ export interface SidebarCtx {
45
45
  openSvnLocalPair?: (left: string, right: string, leftLabel: string, rightLabel: string, title: string) => void
46
46
  /** 重查 SVN 变更清单(CLI 更新/还原/加入版本控制后由菜单动作调用)。 */
47
47
  refreshSvnChanges?: () => void
48
+ /** 打开 SVN 更新窗口(树菜单与其它入口共用)。 */
49
+ openSvnUpdate?: (path: string) => void
48
50
  /** 打开 SVN 日志弹窗(P3;菜单/命令栏「查看日志」)。 */
49
51
  openSvnLog?: (path?: string) => void
50
52
  /** 确认对话框(破坏性动作守卫;缺省走 window.confirm,测试可注入)。 */