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
@@ -5,6 +5,8 @@
5
5
  * `|` 连接多个候选(任一命中即触发),如 `Alt+ArrowLeft|Ctrl+Alt+-`。
6
6
  * 约束:本模块被 host 与 client 两半共用,**禁止 import client 侧模块**(React/浏览器 API)。
7
7
  * 与 client/ui/commandCatalog 的一致性由 tests/commands.test.ts 断言兜底。
8
+ * 官方机制(2026-10):SHORTCUT_PROFILES 描述各命令在官方 ctx.shortcuts 的 profile 默认键位
9
+ * (由 client 注册官方服务时使用);chordToBinding/bindingToChord 负责插件弦与官方绑定互转。
8
10
  * 作者 ddj 2026年08月26号 / 2026年09月10号
9
11
  */
10
12
 
@@ -66,3 +68,254 @@ export function normalizeKeybindings(raw: unknown): Record<string, string> {
66
68
  }
67
69
  return out
68
70
  }
71
+
72
+ //#region 官方 shortcuts 机制映射层(纯数据/纯函数,禁 import client 侧与官方包)
73
+
74
+ /** 官方 shortcuts 服务的修饰符标记('primary' = 桌面端按平台展开为 Ctrl/Cmd,web 同语义)。 */
75
+ export type OfficialModifier = 'primary' | 'control' | 'alt' | 'shift' | 'meta'
76
+
77
+ /** 官方绑定形状(物理 code + 修饰符;与 @deepseek-ai/dsh-client-shortcuts 绑定契约同构,鸭子类型)。 */
78
+ export interface OfficialBinding {
79
+ code: string
80
+ modifiers: OfficialModifier[]
81
+ secondCode?: string
82
+ }
83
+
84
+ /** 官方 profile 键(desktop|web × macos|windows|linux)。 */
85
+ export type ShortcutProfileKey =
86
+ | 'desktop:macos' | 'desktop:windows' | 'desktop:linux'
87
+ | 'web:macos' | 'web:windows' | 'web:linux'
88
+
89
+ /** 命令 id → 各 profile 官方默认键位(缺 profile = 该端不绑定)。 */
90
+ export type ShortcutProfiles = Partial<Record<ShortcutProfileKey, OfficialBinding>>
91
+
92
+ /** 显示主键 → 官方物理 code 对照(官方 code 白名单:Key[A-Z]|Digit[0-9]|F1-24|具名键)。 */
93
+ const DISPLAY_KEY_TO_CODE: Record<string, string> = {
94
+ '-': 'Minus', '=': 'Equal', '[': 'BracketLeft', ']': 'BracketRight',
95
+ ';': 'Semicolon', "'": 'Quote', ',': 'Comma', '.': 'Period',
96
+ '/': 'Slash', '\\': 'Backslash', '`': 'Backquote',
97
+ Enter: 'Enter', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete',
98
+ Escape: 'Escape', Space: 'Space',
99
+ ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowLeft: 'ArrowLeft', ArrowRight: 'ArrowRight',
100
+ }
101
+
102
+ /** 官方具名 code(非 Key/Digit/F 前缀且可回显的部分)。 */
103
+ const NAMED_CODES = ['Enter', 'Tab', 'Backspace', 'Delete', 'Escape', 'Space',
104
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'] as const
105
+
106
+ /** 官方 code → 显示主键(符号键反向对照)。 */
107
+ const CODE_TO_DISPLAY_KEY: Record<string, string> = {
108
+ Minus: '-', Equal: '=', BracketLeft: '[', BracketRight: ']',
109
+ Semicolon: ';', Quote: "'", Comma: ',', Period: '.',
110
+ Slash: '/', Backslash: '\\', Backquote: '`',
111
+ }
112
+
113
+ /** 显示主键 → 官方 code(PageUp/PageDown/Home/End/Insert 等官方不支持 → null)。 */
114
+ function keyToCode(key: string): string | null {
115
+ if (Object.hasOwn(DISPLAY_KEY_TO_CODE, key)) return DISPLAY_KEY_TO_CODE[key]
116
+ if (key.length === 1 && key >= 'a' && key <= 'z') return 'Key' + key.toUpperCase()
117
+ if (key.length === 1 && key >= 'A' && key <= 'Z') return 'Key' + key
118
+ if (key.length === 1 && key >= '0' && key <= '9') return 'Digit' + key
119
+ if (/^F([1-9]|1[0-9]|2[0-4])$/u.test(key)) return key
120
+ return null
121
+ }
122
+
123
+ /** 官方 code → 显示主键(不可回显 → null)。 */
124
+ function codeToKey(code: string): string | null {
125
+ if (Object.hasOwn(CODE_TO_DISPLAY_KEY, code)) return CODE_TO_DISPLAY_KEY[code]
126
+ if (/^Key[A-Z]$/u.test(code)) return code.slice(3)
127
+ if (/^Digit[0-9]$/u.test(code)) return code.slice(5)
128
+ if (/^F([1-9]|1[0-9]|2[0-4])$/u.test(code)) return code
129
+ if ((NAMED_CODES as readonly string[]).includes(code)) return code
130
+ return null
131
+ }
132
+
133
+ /** 插件弦的修饰符 token → 官方修饰符(Ctrl 与 Cmd 同为 primary,与 matchEvent 的互认语义一致)。 */
134
+ const CHORD_MODIFIER_TOKENS: Record<string, OfficialModifier> = {
135
+ ctrl: 'primary', control: 'primary', cmd: 'primary', meta: 'primary',
136
+ shift: 'shift', alt: 'alt',
137
+ }
138
+
139
+ /**
140
+ * 插件键位弦(单候选)→ 官方绑定。
141
+ * 不可表示的主键(PageUp/PageDown/Home/End/Insert 等)返回 null,调用方跳过。
142
+ * @author ddj 2026年10月
143
+ * @param chord 键位弦(如 `Ctrl+Alt+-` / `F9` / `Alt+ArrowLeft`)
144
+ * @returns 官方绑定或 null
145
+ */
146
+ export function chordToBinding(chord: string): OfficialBinding | null {
147
+ const parts = String(chord ?? '').split('+').map((part) => part.trim()).filter(Boolean)
148
+ if (!parts.length) return null
149
+ const modifiers = new Set<OfficialModifier>()
150
+ let key = ''
151
+ for (const part of parts) {
152
+ const modifier = CHORD_MODIFIER_TOKENS[part.toLowerCase()]
153
+ if (modifier) {
154
+ modifiers.add(modifier)
155
+ continue
156
+ }
157
+ if (key) return null
158
+ key = part
159
+ }
160
+ if (!key) return null
161
+ if (key === ' ') key = 'Space'
162
+ else if (key.length === 1 && key >= 'a' && key <= 'z') key = key.toUpperCase()
163
+ const code = keyToCode(key)
164
+ return code === null ? null : { code, modifiers: [...modifiers] }
165
+ }
166
+
167
+ /**
168
+ * 官方绑定 → 插件显示键位弦(primary 显示为 Ctrl,与历史 tooltip 文案保持一致)。
169
+ * @author ddj 2026年10月
170
+ * @param binding 官方绑定
171
+ * @returns 插件弦(不可表示 → null)
172
+ */
173
+ export function bindingToChord(binding: OfficialBinding): string | null {
174
+ if (!binding || typeof binding !== 'object') return null
175
+ const key = codeToKey(binding.code)
176
+ if (key === null) return null
177
+ let suffix = ''
178
+ if (binding.secondCode !== undefined) {
179
+ const secondKey = codeToKey(binding.secondCode)
180
+ if (secondKey === null) return null
181
+ suffix = '+' + secondKey
182
+ }
183
+ const parts: string[] = []
184
+ if (binding.modifiers.includes('primary')) parts.push('Ctrl')
185
+ if (binding.modifiers.includes('shift')) parts.push('Shift')
186
+ if (binding.modifiers.includes('alt')) parts.push('Alt')
187
+ return [...parts, key + suffix].join('+')
188
+ }
189
+
190
+ /** 官方绑定字面量构造(表格可读性辅助)。 */
191
+ function bind(code: string, modifiers: OfficialModifier[]): OfficialBinding {
192
+ return { code, modifiers }
193
+ }
194
+
195
+ /**
196
+ * 命令 id → 官方 profile 默认键位(与 KEYBINDING_DEFAULTS 一一对应;client 注册官方服务的数据源)。
197
+ * 取值约束(对齐官方 bindingIssue/isWebBindingAllowed 校验,错报会在注册时抛错):
198
+ * - desktop:macos/windows:官方不做保留校验,用 VS Code 同款 chord;
199
+ * - desktop:linux / web:linux:Arrow*、裸 F 键、全 Shift、Ctrl+V/C/X/Z/Y/Q/H/A、Meta 均为保留键 → 不声明;
200
+ * - web:macos/windows:单修饰符组合不在白名单 → 用 primary+alt / primary+shift 变体(官方 ui-layout 同款);
201
+ * - Ctrl+PageDown/PageUp 候选废弃(官方 code 白名单无 PageUp/PageDown);
202
+ * - edrv.toggleSidebar 留空:Ctrl+B 与官方 sidebar.left.toggle(Mod+B)冲突,注册会抛错且冲突键互相阻断。
203
+ * @author ddj 2026年10月
204
+ */
205
+ export const SHORTCUT_PROFILES: Record<string, ShortcutProfiles> = {
206
+ 'edrv.toggleSidebar': {},
207
+ 'edrv.save': {
208
+ 'desktop:macos': bind('KeyS', ['primary']),
209
+ 'desktop:windows': bind('KeyS', ['primary']),
210
+ 'desktop:linux': bind('KeyS', ['primary']),
211
+ },
212
+ // 官方 workspace.files 已占用桌面 primary+P 和 Web primary+alt+P;插件 QuickOpen 使用非冲突组合。
213
+ 'edrv.quickOpen': {
214
+ 'desktop:macos': bind('KeyP', ['primary', 'alt']),
215
+ 'desktop:windows': bind('KeyP', ['primary', 'alt']),
216
+ 'desktop:linux': bind('KeyP', ['primary', 'alt']),
217
+ 'web:macos': bind('KeyP', ['primary', 'alt', 'shift']),
218
+ 'web:windows': bind('KeyP', ['primary', 'alt', 'shift']),
219
+ },
220
+ 'edrv.searchInFiles': {
221
+ 'desktop:macos': bind('KeyF', ['primary', 'shift']),
222
+ 'desktop:windows': bind('KeyF', ['primary', 'shift']),
223
+ 'desktop:linux': bind('KeyF', ['primary', 'shift']),
224
+ 'web:macos': bind('KeyF', ['primary', 'shift']),
225
+ 'web:windows': bind('KeyF', ['primary', 'shift']),
226
+ },
227
+ // Ctrl+Shift+V(primary+KeyV)在 linux 为保留键(官方 bindingIssue 不豁免 shift),desktop:linux 不声明
228
+ 'edrv.toggleMarkdownPreview': {
229
+ 'desktop:macos': bind('KeyV', ['primary', 'shift']),
230
+ 'desktop:windows': bind('KeyV', ['primary', 'shift']),
231
+ 'web:macos': bind('KeyV', ['primary', 'shift']),
232
+ 'web:windows': bind('KeyV', ['primary', 'shift']),
233
+ },
234
+ // 导航历史:主候选 Alt+Arrow 在 linux/web 为保留或白名单外;web 取第二候选(Ctrl+Alt+- / Ctrl+Shift+-)
235
+ 'edrv.navigateBack': {
236
+ 'desktop:macos': bind('ArrowLeft', ['alt']),
237
+ 'desktop:windows': bind('ArrowLeft', ['alt']),
238
+ 'web:macos': bind('Minus', ['primary', 'alt']),
239
+ 'web:windows': bind('Minus', ['primary', 'alt']),
240
+ },
241
+ 'edrv.navigateForward': {
242
+ 'desktop:macos': bind('ArrowRight', ['alt']),
243
+ 'desktop:windows': bind('ArrowRight', ['alt']),
244
+ 'web:macos': bind('Minus', ['primary', 'shift']),
245
+ 'web:windows': bind('Minus', ['primary', 'shift']),
246
+ },
247
+ // 页签循环:Ctrl+PageDown/PageUp 候选官方不可表示,仅保留 Ctrl+Alt+Arrow 主候选
248
+ 'edrv.nextTab': {
249
+ 'desktop:macos': bind('ArrowRight', ['primary', 'alt']),
250
+ 'desktop:windows': bind('ArrowRight', ['primary', 'alt']),
251
+ 'web:macos': bind('ArrowRight', ['primary', 'alt']),
252
+ 'web:windows': bind('ArrowRight', ['primary', 'alt']),
253
+ },
254
+ 'edrv.prevTab': {
255
+ 'desktop:macos': bind('ArrowLeft', ['primary', 'alt']),
256
+ 'desktop:windows': bind('ArrowLeft', ['primary', 'alt']),
257
+ 'web:macos': bind('ArrowLeft', ['primary', 'alt']),
258
+ 'web:windows': bind('ArrowLeft', ['primary', 'alt']),
259
+ },
260
+ 'edrv.goToLine': {
261
+ 'desktop:macos': bind('KeyG', ['primary']),
262
+ 'desktop:windows': bind('KeyG', ['primary']),
263
+ 'desktop:linux': bind('KeyG', ['primary']),
264
+ },
265
+ 'edrv.showCommands': {
266
+ 'desktop:macos': bind('KeyP', ['primary', 'shift']),
267
+ 'desktop:windows': bind('KeyP', ['primary', 'shift']),
268
+ 'desktop:linux': bind('KeyP', ['primary', 'shift']),
269
+ 'web:macos': bind('KeyP', ['primary', 'shift']),
270
+ 'web:windows': bind('KeyP', ['primary', 'shift']),
271
+ },
272
+ 'edrv.nextEditorRow': {
273
+ 'desktop:macos': bind('ArrowDown', ['primary', 'alt']),
274
+ 'desktop:windows': bind('ArrowDown', ['primary', 'alt']),
275
+ 'web:macos': bind('ArrowDown', ['primary', 'alt']),
276
+ 'web:windows': bind('ArrowDown', ['primary', 'alt']),
277
+ },
278
+ 'edrv.prevEditorRow': {
279
+ 'desktop:macos': bind('ArrowUp', ['primary', 'alt']),
280
+ 'desktop:windows': bind('ArrowUp', ['primary', 'alt']),
281
+ 'web:macos': bind('ArrowUp', ['primary', 'alt']),
282
+ 'web:windows': bind('ArrowUp', ['primary', 'alt']),
283
+ },
284
+ 'edrv.addSelectionRef': {
285
+ 'desktop:macos': bind('KeyU', ['primary']),
286
+ 'desktop:windows': bind('KeyU', ['primary']),
287
+ 'desktop:linux': bind('KeyU', ['primary']),
288
+ },
289
+ 'edrv.closeTab': {
290
+ 'desktop:macos': bind('F4', ['primary']),
291
+ 'desktop:windows': bind('F4', ['primary']),
292
+ 'desktop:linux': bind('F4', ['primary']),
293
+ },
294
+ // 调试 F 键:linux 判 modifier-required(裸 F/全 Shift),仅 desktop:macos/windows
295
+ 'edrv.debugToggleBreakpoint': {
296
+ 'desktop:macos': bind('F9', []),
297
+ 'desktop:windows': bind('F9', []),
298
+ },
299
+ 'edrv.debugStartContinue': {
300
+ 'desktop:macos': bind('F5', []),
301
+ 'desktop:windows': bind('F5', []),
302
+ },
303
+ 'edrv.debugStepOver': {
304
+ 'desktop:macos': bind('F10', []),
305
+ 'desktop:windows': bind('F10', []),
306
+ },
307
+ 'edrv.debugStepInto': {
308
+ 'desktop:macos': bind('F11', []),
309
+ 'desktop:windows': bind('F11', []),
310
+ },
311
+ 'edrv.debugStepOut': {
312
+ 'desktop:macos': bind('F11', ['shift']),
313
+ 'desktop:windows': bind('F11', ['shift']),
314
+ },
315
+ 'edrv.debugStop': {
316
+ 'desktop:macos': bind('F5', ['shift']),
317
+ 'desktop:windows': bind('F5', ['shift']),
318
+ },
319
+ }
320
+
321
+ //#endregion
package/src/shared/rpc.ts CHANGED
@@ -19,7 +19,7 @@ import type { RuleInfo, RuleProject, RuleRefInput, RuleSaveInput } from './rules
19
19
  import type { SnippetEntry, SnippetInfo, SnippetProject, SnippetRefInput, SnippetSaveInput } from './snippets.js'
20
20
  import type { LspCompletionItem, LspCompletionList, LspEnvInstallState, LspExtInfo, LspExtUpdate, LspHover, LspLocation, LspMarketItem, LspPosition, LspSemanticTokens, LspServerStatus, LspSignatureHelp, LspSymbol } from './lsp.js'
21
21
  import type { AiConfigPatch, AiConfigView, AiDirectoryView, AiInlineRequest, AiInlineResult } from './ai.js'
22
- import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdateResult } from './svn.js'
22
+ import type { SvnAction, SvnAiPlan, SvnChangeEntry, SvnConflictArtifact, SvnDiffRevResult, SvnIgnoreItem, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry, SvnUpdatePoll, SvnUpdateResult, SvnUpdateState } from './svn.js'
23
23
  import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, DapConfigSnippet, DapConfigSource, DapDebugConfig, DapFrameView, DapPhase, DapPollResult, DapProcessInfo, DapScopeView, DapVariableView } from './dap.js'
24
24
  import { DAP_ACTIONS } from './dap.js'
25
25
  import type { LoggerLevel } from './logger.js'
@@ -371,6 +371,7 @@ export interface RpcRequestMap {
371
371
  'mcp.projectToggle': { workspacePath: string; serverName: string; enabled: boolean }
372
372
  'mcp.projectRefresh': { workspacePath: string; serverName: string }
373
373
  'vscode.fileOpenSettingsGet': {}
374
+ 'vscode.settingsDocumentPath': {}
374
375
  'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; integrationBaseUrl?: string; expectedRevision?: number }
375
376
  'edrv.integration.status': {}
376
377
  'edrv.integration.register': {}
@@ -396,7 +397,7 @@ export interface RpcRequestMap {
396
397
  'edrv.lsp.workspaceSymbol': { sessionId?: string; query: string }
397
398
  'edrv.lsp.hover': { sessionId?: string; path: string; position: LspPosition }
398
399
  'edrv.lsp.semanticTokens': { sessionId?: string; path: string }
399
- 'edrv.lsp.completion': { sessionId?: string; path: string; position: LspPosition; context?: { triggerKind?: number; triggerCharacter?: string } }
400
+ 'edrv.lsp.completion': { sessionId?: string; path: string; position: LspPosition; context?: { triggerKind?: number; triggerCharacter?: string }; prefix?: string }
400
401
  'edrv.lsp.resolveCompletion': { sessionId?: string; path: string; item: LspCompletionItem }
401
402
  'edrv.lsp.signatureHelp': { sessionId?: string; path: string; position: LspPosition }
402
403
  'edrv.lsp.redetect': { languageId: string }
@@ -447,6 +448,10 @@ export interface RpcRequestMap {
447
448
  'svn.wcRev': { sessionId?: string; path?: string }
448
449
  'svn.cleanup': { sessionId?: string; removeUnversioned?: boolean; removeIgnored?: boolean }
449
450
  'svn.update': { sessionId?: string; path?: string }
451
+ 'svn.updateStart': { sessionId?: string; path?: string }
452
+ 'svn.updatePoll': { sessionId?: string; jobId: string; since: number }
453
+ 'svn.updateCancel': { sessionId?: string; jobId: string }
454
+ 'svn.updateActive': { sessionId?: string }
450
455
  'svn.tortoise': { sessionId?: string; action: SvnAction; path?: string }
451
456
  'svn.patchText': { sessionId?: string; path: string; whitespace?: 'none' | 'b' | 'w'; ignoreEol?: boolean; unified?: number }
452
457
  'svn.diffSum': { sessionId?: string; path?: string; revA: number; revB: number }
@@ -519,6 +524,7 @@ export interface RpcOkMap {
519
524
  'mcp.projectToggle': { project: MpcProject }
520
525
  'mcp.projectRefresh': { project: MpcProject }
521
526
  'vscode.fileOpenSettingsGet': { fileOpenTool: string; integrationBaseUrl: string; revision?: number }
527
+ 'vscode.settingsDocumentPath': { path: string }
522
528
  'vscode.fileOpenSettingsUpdate': { fileOpenTool: string; integrationBaseUrl: string; revision?: number }
523
529
  'edrv.integration.status': ShellIntegrationStatus
524
530
  'edrv.integration.register': ShellIntegrationStatus
@@ -595,6 +601,10 @@ export interface RpcOkMap {
595
601
  'svn.wcRev': { revision: number | null }
596
602
  'svn.cleanup': { summary: string; output: string }
597
603
  'svn.update': SvnUpdateResult
604
+ 'svn.updateStart': SvnUpdateState
605
+ 'svn.updatePoll': SvnUpdatePoll
606
+ 'svn.updateCancel': SvnUpdateState
607
+ 'svn.updateActive': { job: SvnUpdateState | null }
598
608
  'svn.tortoise': { launched: string }
599
609
  'svn.patchText': { text: string; truncated: boolean; binary: boolean }
600
610
  'svn.diffSum': { entries: SvnSumEntry[]; truncated: boolean }
@@ -4,7 +4,10 @@
4
4
  * 作者 ddj 2026年09月03号
5
5
  */
6
6
 
7
- /** 规则作用域:用户规则(~/.dsh/rules)或项目规则(<工作区>/.dsh/rules)。 */
7
+ /** 用户规则来源:当前 DSH_HOME 或系统用户目录的旧版 .dsh。 */
8
+ export type UserRuleOrigin = 'current' | 'legacy'
9
+
10
+ /** 规则作用域:用户规则(DSH_HOME/rules + ~/.dsh/rules)或项目规则(<工作区>/.dsh/rules)。 */
8
11
  export type RuleScope = 'user' | 'project'
9
12
 
10
13
  /** 规则生效类型(由 frontmatter 推导):总是 / 自动(globs 命中时)/ 手动(仅索引)。 */
@@ -13,6 +16,8 @@ export type RuleType = 'always' | 'auto' | 'manual'
13
16
  /** 一条 .mdc 规则的元信息(列表行展示 + 注入语义所需的最小集)。 */
14
17
  export interface RuleInfo {
15
18
  scope: RuleScope
19
+ /** 用户规则的实际来源;项目规则不设置。 */
20
+ origin?: UserRuleOrigin
16
21
  /** 文件名(含 .mdc 后缀,不含路径)。 */
17
22
  file: string
18
23
  /** 绝对路径(host 解析,供展示与按需读取)。 */
@@ -48,6 +53,8 @@ export interface RuleSaveInput {
48
53
  scope: RuleScope
49
54
  /** project 必填:目标工作区绝对路径(须为 DSH 已注册 workspace)。 */
50
55
  workspacePath?: string
56
+ /** 缺省 current;仅用户规则可指定 legacy。 */
57
+ origin?: UserRuleOrigin
51
58
  file: string
52
59
  content: string
53
60
  }
@@ -56,5 +63,6 @@ export interface RuleSaveInput {
56
63
  export interface RuleRefInput {
57
64
  scope: RuleScope
58
65
  workspacePath?: string
66
+ origin?: UserRuleOrigin
59
67
  file: string
60
68
  }
package/src/shared/svn.ts CHANGED
@@ -99,6 +99,44 @@ export interface SvnUpdateResult {
99
99
  output: string
100
100
  }
101
101
 
102
+ /** 原生更新窗口的真实执行阶段。 */
103
+ export type SvnUpdatePhase = 'starting' | 'running' | 'cancelling' | 'completed' | 'failed' | 'cancelled'
104
+
105
+ /** 流式更新行;relPath 仅在目标位于当前工作区内时可操作。 */
106
+ export interface SvnUpdateRow {
107
+ seq: number
108
+ action: SvnUpdateAction | null
109
+ path: string
110
+ relPath: string | null
111
+ label: string
112
+ raw: string
113
+ }
114
+
115
+ /** 分类数量按 svn 输出中的条目行计,不猜文件总数或网络字节。 */
116
+ export type SvnUpdateCounts = Record<SvnUpdateAction, number>
117
+
118
+ /** 更新任务快照(窗口第一次连接和每次增量 poll 共用)。 */
119
+ export interface SvnUpdateState {
120
+ jobId: string
121
+ target: string
122
+ phase: SvnUpdatePhase
123
+ counts: SvnUpdateCounts
124
+ revision: number | null
125
+ elapsedMs: number
126
+ error: string
127
+ rawTail: string
128
+ truncated: boolean
129
+ nextSeq: number
130
+ }
131
+
132
+ /** 增量读取结果:rows 只含 since 之后的一页,offset 表示目前可重放的首条序号。 */
133
+ export interface SvnUpdatePoll extends SvnUpdateState {
134
+ rows: SvnUpdateRow[]
135
+ offset: number
136
+ /** 当前后台总序号;nextSeq 仅是本次前端已经拿到的最后一条。 */
137
+ totalSeq: number
138
+ }
139
+
102
140
  /** revert/add 等批量动作结果(计数 + 摘要 + 原文)。 */
103
141
  export interface SvnActionResult {
104
142
  /** 处理条目数(宽松计数)。 */
package/src/svn.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 作者 ddj 2026年09月16号
8
8
  */
9
9
  import { readdir, stat } from 'node:fs/promises'
10
- import { basename, dirname, join } from 'node:path'
10
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
11
11
  import { tmpdir } from 'node:os'
12
12
  import type { Ctx, Session } from './store.js'
13
13
  import { cwdOf, sessionOf } from './registry.js'
@@ -18,6 +18,8 @@ import { buildAiPrompt, svnAiPlanOf } from './ai/svnTriage.js'
18
18
  import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js'
19
19
  import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
20
20
  import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
21
+ import { SvnUpdateJobs } from './svnUpdateJob.js'
22
+ import type { UpdateProcess } from './svnUpdateJob.js'
21
23
  export { batchResultOf, commitResultOf, updateResultOf } from './svnText.js'
22
24
  export { mapRepoPath, parseLogXml } from './svnLog.js'
23
25
 
@@ -387,6 +389,7 @@ interface SvnRunOutcome { code: number | null; stdout: string; stderr: string }
387
389
  /** subprocess 产句柄最小结构面(done 载荷对齐 revert.ts 的 exitCode/code 双读)。 */
388
390
  interface SpawnedHandle {
389
391
  done: Promise<{ exitCode?: number; code?: number } | undefined>
392
+ terminate?: () => void
390
393
  collected?: {
391
394
  stdout?: { readFrom: (n: number) => { text: string } }
392
395
  stderr?: { readFrom: (n: number) => { text: string } }
@@ -466,18 +469,35 @@ function safeRel(raw: unknown): string | null {
466
469
  return rel
467
470
  }
468
471
 
472
+ /**
473
+ * 会话根可在 SVN 工作副本的子目录;CLI 在 wcRoot 执行时必须换算目标坐标。
474
+ * @author ddj 2026年09月24号
475
+ * @param cwd 当前会话目录
476
+ * @param wcRoot SVN 工作副本根
477
+ * @param rel 会话相对目标
478
+ * @returns SVN 根相对目标;越界返回 null
479
+ */
480
+ export function svnPathOf(cwd: string, wcRoot: string, rel: string): string | null {
481
+ if (safeRel(rel) === null) return null
482
+ const wcRel = relative(wcRoot, resolve(cwd, rel || '.'))
483
+ if (isAbsolute(wcRel) || wcRel === '..' || wcRel.startsWith('../') || wcRel.startsWith('..\\')) return null
484
+ return wcRel.replace(/\\/g, '/') || '.'
485
+ }
486
+
469
487
  /**
470
488
  * 创建 svn.* RPC handlers(缓存随装配生命周期;lsp/ai 同模式并入 buildHandlers)。
471
489
  * @author ddj 2026年09月16号
472
490
  * @param deps ctx + 设置读取面 + 可注入时钟
473
491
  * @returns handlers(Partial<RpcHandlerMap> 形状)
474
492
  */
475
- export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unknown> } {
493
+ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unknown>; dispose: () => void } {
476
494
  const { ctx, settings } = deps
477
495
  const now = deps.now ?? (() => Date.now())
478
496
  const findRoot = deps.findRoot ?? ((cwd: string) => findSvnRoot(cwd))
479
497
  const findTortoise = deps.findTortoise ?? ((dir: string) => findTortoiseProc(dir))
480
498
  const statusCache = new Map<string, { at: number; payload: SvnStatusPayload }>()
499
+ /** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
500
+ const updateRootCache = new Map<string, { at: number; root: string }>()
481
501
  /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
482
502
  const changesCache = new Map<string, { at: number; value: { entries: SvnChangeEntry[]; truncated: boolean } }>()
483
503
  /** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
@@ -485,6 +505,11 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
485
505
  /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
486
506
  const urlCache = new Map<string, { at: number; value: string }>()
487
507
  let cliProbe: { at: number; exe: string; ok: boolean } | null = null
508
+ const updateJobs = new SvnUpdateJobs(
509
+ () => ctx.get('subprocess') as UpdateProcess | null,
510
+ deps.platform ?? process.platform,
511
+ now,
512
+ )
488
513
 
489
514
  const subprocess = (): SpawnFn | null => {
490
515
  const sub = ctx.get('subprocess') as { spawn?: unknown } | null | undefined
@@ -499,6 +524,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
499
524
  let ok = false
500
525
  const spawn = subprocess()
501
526
  if (spawn) {
527
+ const controller = new AbortController()
528
+ const deadline = setTimeout(() => controller.abort(new Error('SVN CLI 探测超时')), CLI_GRACE_MS)
529
+ deadline.unref?.()
502
530
  try {
503
531
  const handle = spawn({
504
532
  argv: [exe, '--version', '--quiet'],
@@ -507,11 +535,20 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
507
535
  cwd: tmpdir(),
508
536
  stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
509
537
  graceMs: CLI_GRACE_MS,
538
+ signal: controller.signal,
539
+ })
540
+ const expired = new Promise<never>((_, reject) => {
541
+ controller.signal.addEventListener('abort', () => {
542
+ try { handle.terminate?.() } catch { /* 返回探测超时即可 */ }
543
+ reject(controller.signal.reason)
544
+ }, { once: true })
510
545
  })
511
- const outcome = await handle.done
546
+ const outcome = await Promise.race([handle.done, expired])
512
547
  ok = (outcome?.exitCode ?? outcome?.code) === 0
513
548
  } catch {
514
549
  ok = false
550
+ } finally {
551
+ clearTimeout(deadline)
515
552
  }
516
553
  }
517
554
  cliProbe = { at, exe, ok }
@@ -543,6 +580,28 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
543
580
  return payload
544
581
  }
545
582
 
583
+ /**
584
+ * 验证更新请求属于当前会话的工作副本;取消/轮询不依赖 CLI 仍可用。
585
+ * @author ddj 2026年09月24号
586
+ * @param sessionId 当前会话 id
587
+ * @param needCli 仅真正启动更新时才探测 SVN 客户端
588
+ * @returns 已校验的目录和状态,或错误
589
+ */
590
+ const updateScope = async (sessionId?: string, needCli = false) => {
591
+ const sc = await requireSvnSession(ctx, sessionId)
592
+ if ('err' in sc) return { err: sc.err }
593
+ if (!needCli) {
594
+ const cached = updateRootCache.get(sc.cwd)
595
+ const root = cached && now() - cached.at < STATUS_TTL_MS ? cached.root : await findRoot(sc.cwd)
596
+ if (root && (!cached || now() - cached.at >= STATUS_TTL_MS)) updateRootCache.set(sc.cwd, { at: now(), root })
597
+ return root ? { cwd: sc.cwd, root, exe: '', cli: false } : { err: '当前工作区不受 SVN 管理' }
598
+ }
599
+ const status = await statusOf(sc.cwd)
600
+ if (!status.managed || !status.wcRoot) return { err: '当前工作区不受 SVN 管理' }
601
+ updateRootCache.set(sc.cwd, { at: now(), root: status.wcRoot })
602
+ return { cwd: sc.cwd, root: status.wcRoot, exe: status.svnPath, cli: status.svnCli }
603
+ }
604
+
546
605
  /**
547
606
  * 运行 svn CLI(管道收集;spawn 级失败回退 inherit,输出降级为空)。
548
607
  * @author ddj 2026年08月26号 / 2026年09月17号
@@ -966,8 +1025,10 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
966
1025
  const status = await statusOf(sc.cwd)
967
1026
  if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
968
1027
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1028
+ const svnTarget = svnPathOf(sc.cwd, status.wcRoot, rel)
1029
+ if (svnTarget === null) return { ok: false as const, error: '日志路径不在 SVN 工作副本内' }
969
1030
  try {
970
- const result = await logOf(status.wcRoot, rel, args.limit ?? LOG_DEFAULT_LIMIT, {
1031
+ const result = await logOf(status.wcRoot, svnTarget === '.' ? '' : svnTarget, args.limit ?? LOG_DEFAULT_LIMIT, {
971
1032
  stopOnCopy: args.stopOnCopy === true,
972
1033
  showMerged: args.showMerged === true,
973
1034
  range: args.startRev && args.endRev
@@ -1026,6 +1087,8 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1026
1087
  const status = await statusOf(sc.cwd)
1027
1088
  if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1028
1089
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1090
+ const svnRel = svnPathOf(sc.cwd, status.wcRoot, rel)
1091
+ if (!svnRel) return { ok: false as const, error: '比较路径不在 SVN 工作副本内' }
1029
1092
  const fs = ctx.get('fs') as {
1030
1093
  resolve?: (p: string, o: object) => Promise<unknown>
1031
1094
  stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
@@ -1039,7 +1102,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1039
1102
  if ((info.size ?? 0) > READ_CAP) return { ok: false as const, error: '文件过大(>8MB),不支持与工作副本比较' }
1040
1103
  const working = await fs.readText(resolved)
1041
1104
  // 左侧 = 该版本内容;cat 失败按「该版本尚无此文件」处理(新增语义,非错误)
1042
- const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', rel], READ_GRACE_MS)
1105
+ const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', svnRel], READ_GRACE_MS)
1043
1106
  if (leftOutcome.code !== 0) {
1044
1107
  const text = (leftOutcome.stderr || leftOutcome.stdout).trim()
1045
1108
  return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text), ...guardSides(null, working) }
@@ -1363,7 +1426,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1363
1426
  if (!status.managed) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1364
1427
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1365
1428
  try {
1366
- const outcome = await runSvn(sc.cwd, ['update', rel || '.'], UPDATE_GRACE_MS)
1429
+ const outcome = await runSvn(sc.cwd, ['update', '--', rel || '.'], UPDATE_GRACE_MS)
1367
1430
  const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code)
1368
1431
  if (outcome.code !== 0) return { ok: false as const, error: '更新失败:' + tailOfText(result.output) }
1369
1432
  return { ok: true as const, ...result }
@@ -1371,6 +1434,38 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1371
1434
  return { ok: false as const, error: '更新失败:' + String(error) }
1372
1435
  }
1373
1436
  },
1437
+ 'svn.updateStart': async (args: { sessionId?: string; path?: string }) => {
1438
+ const scope = await updateScope(args.sessionId, true)
1439
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1440
+ if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1441
+ const rel = safeRel(args.path)
1442
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
1443
+ try {
1444
+ const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | undefined
1445
+ const cwd = fs?.resolve && fs?.processPath
1446
+ ? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd
1447
+ return { ok: true as const, ...updateJobs.start({ cwd, root: scope.root, path: rel, exe: scope.exe }) }
1448
+ } catch (error) {
1449
+ return { ok: false as const, error: '启动 SVN 更新失败:' + String(error) }
1450
+ }
1451
+ },
1452
+ 'svn.updatePoll': async (args: { sessionId?: string; jobId: string; since: number }) => {
1453
+ const scope = await updateScope(args.sessionId)
1454
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1455
+ try { return { ok: true as const, ...updateJobs.poll(scope.root, args.jobId, args.since) } }
1456
+ catch (error) { return { ok: false as const, error: String(error) } }
1457
+ },
1458
+ 'svn.updateCancel': async (args: { sessionId?: string; jobId: string }) => {
1459
+ const scope = await updateScope(args.sessionId)
1460
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1461
+ try { return { ok: true as const, ...updateJobs.cancel(scope.root, args.jobId) } }
1462
+ catch (error) { return { ok: false as const, error: String(error) } }
1463
+ },
1464
+ 'svn.updateActive': async (args: { sessionId?: string }) => {
1465
+ const scope = await updateScope(args.sessionId)
1466
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1467
+ return { ok: true as const, job: updateJobs.active(scope.root) }
1468
+ },
1374
1469
  'svn.tortoise': async (args: { sessionId?: string; action?: SvnAction; path?: string }) => {
1375
1470
  const sc = await requireSvnSession(ctx, args.sessionId)
1376
1471
  if ('err' in sc) return { ok: false as const, error: sc.err }
@@ -1397,5 +1492,5 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1397
1492
  }
1398
1493
  },
1399
1494
  }
1400
- return { handlers }
1495
+ return { handlers, dispose: () => { updateJobs.dispose(); updateRootCache.clear() } }
1401
1496
  }