dsh-vscode-mode 0.15.4 → 0.15.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -0,0 +1,77 @@
1
+ import type { CompatAdapter } from '../shared/compat.js'
2
+ import { SIDEBAR_PLUGIN } from './compat.js'
3
+
4
+ /** Runtime installation facts, separate from the DSH version and user intent. */
5
+ export interface ClientCompatState {
6
+ settingsService: string
7
+ settingsReady: boolean
8
+ openPathReady: boolean
9
+ remoteReady: boolean
10
+ sidebarOpener: boolean
11
+ legacySidebar: boolean
12
+ officialSidebar: boolean
13
+ fileClaim: boolean
14
+ claimWanted: boolean
15
+ selected: string
16
+ }
17
+
18
+ /**
19
+ * Classify the API generation without importing the host's node-only version probe.
20
+ * @private @author ddj 2026年10月13号
21
+ * @param input Running DSH version from the compat RPC.
22
+ * @param patch First supported 0.1.x patch (alpha.1).
23
+ * @returns Whether that API generation is expected; undefined for unknown versions.
24
+ */
25
+ function apiExpected(input: string, patch: number): boolean | undefined {
26
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/.exec(input.trim())
27
+ if (!match) return undefined
28
+ const major = Number(match[1])
29
+ const minor = Number(match[2])
30
+ const current = Number(match[3])
31
+ if (major !== 0) return major > 0
32
+ if (minor !== 1) return minor > 1
33
+ if (current !== patch) return current > patch
34
+ const pre = match[4]
35
+ if (!pre) return true
36
+ const parts = pre.split('.')
37
+ if (/^\d+$/.test(parts[0])) return false
38
+ if (parts[0] !== 'alpha') return parts[0] > 'alpha'
39
+ return parts.length > 1 && (/^\d+$/.test(parts[1]) ? Number(parts[1]) >= 1 : true)
40
+ }
41
+
42
+ /**
43
+ * Build only applicable diagnostics; explicit legacy choices still expose missing capabilities.
44
+ * @public @author ddj 2026年10月13号
45
+ * @param state Actual client installation facts and user selection.
46
+ * @param version Running DSH version; unknown versions retain conservative diagnostics.
47
+ * @returns Visible adapters, without changing routing or installation.
48
+ */
49
+ export function clientSummary(state: ClientCompatState, version = ''): CompatAdapter[] {
50
+ const remoteExpected = apiExpected(version, 3)
51
+ const officialExpected = apiExpected(version, 5)
52
+ const official = state.officialSidebar
53
+ const legacyChosen = state.selected === SIDEBAR_PLUGIN
54
+ const items: CompatAdapter[] = [{
55
+ name: '设置桥', active: state.settingsReady,
56
+ note: state.settingsReady ? '使用 ' + state.settingsService + ' 桥' : '未绑定设置服务(持久化不可用,晚到重试窗口内自愈)',
57
+ }]
58
+ if (remoteExpected !== true && !state.remoteReady && !official) {
59
+ items.push({ name: '文件打开路由(workspaces.openPath)', active: state.openPathReady, note: '旧版对话文件链接路由,失败回退系统打开' })
60
+ }
61
+ if (remoteExpected !== false || state.remoteReady) {
62
+ items.push({ name: '会话文件链接路由(remote.session.openWorkspacePath)', active: state.remoteReady, note: state.remoteReady ? '会话文件链接走插件打开器,失败回退系统打开' : '适用路由未安装(服务缺失或补丁失败)' })
63
+ }
64
+ if (legacyChosen || state.sidebarOpener || (!official && officialExpected !== true)) {
65
+ items.push({ name: '侧边栏打开器(' + SIDEBAR_PLUGIN + ')', active: state.sidebarOpener, note: state.sidebarOpener ? '已注册(优先级 80)' : '未检测到侧边栏打开能力' })
66
+ }
67
+ if (official || officialExpected !== false) {
68
+ items.push({ name: '侧边栏编辑区(官方 Sidebar)', active: official, note: official ? '编辑区=官方右侧 Sidebar Tab(对话+编辑同屏)' : '官方侧边栏服务未探测到' })
69
+ }
70
+ if (!official && (state.legacySidebar || officialExpected !== true)) {
71
+ items.push({ name: '侧边栏编辑区(' + SIDEBAR_PLUGIN + ',归档)', active: state.legacySidebar, note: state.legacySidebar ? '编辑区=旧版侧边栏 Tab' : '旧版侧边栏回退能力未检测到' })
72
+ }
73
+ if (state.fileClaim || (state.claimWanted && (official || officialExpected !== false))) {
74
+ items.push({ name: '文件链接官方认领(dsh-resource://file)', active: state.fileClaim, note: state.fileClaim ? '聊天文件链接转发进单一编辑器页签' : '官方文件链接认领未安装' })
75
+ }
76
+ return items
77
+ }
@@ -15,6 +15,12 @@ import { log } from './log.js'
15
15
  const TOAST_MS = 5000
16
16
  /** 深链移交轮询周期(兼活跃心跳)。 */
17
17
  const HANDOFF_POLL_MS = 3000
18
+ /**
19
+ * 页面隐藏时的降频倍数(3s × 5 = 15s)。
20
+ * 必须仍短于 host 租约(src/externalHandoff.ts 的 LEASE_MS = 30s):心跳一旦超期,
21
+ * host 判定该页面已死,launcher 会重复开新页——比多几次 RPC 更糟。
22
+ */
23
+ const HIDDEN_POLL_EVERY = 5
18
24
 
19
25
  /** 深链装配选项(全部可注入便于测试;缺省读浏览器全局)。 */
20
26
  export interface ExtOpenOptions {
@@ -52,9 +58,10 @@ export function setupExtOpen(ctx: unknown, options: ExtOpenOptions = {}): () =>
52
58
  }
53
59
 
54
60
  /**
55
- * 深链移交轮询:每 3s 向 host 领取待打开请求(兼活跃心跳,launcher 据此决定是否开新页)。
61
+ * 深链移交轮询:可见时每 3s 向 host 领取待打开请求(兼活跃心跳,launcher 据此决定是否开新页);
62
+ * 页面隐藏时降到 15s 一次——无人看页面时移交不必保持 3s 精度,但心跳不能停(超期会让 host 判页面已死)。
56
63
  * 页面级单例(window 标记防 HMR 重复);领取到即执行打开规则。
57
- * @author ddj 2026年09月08号
64
+ * @author ddj 2026年09月08号 / 2026年10月15号
58
65
  * @param ctx 客户端根上下文
59
66
  */
60
67
  function startPresencePoll(ctx: unknown): () => void {
@@ -65,6 +72,17 @@ function startPresencePoll(ctx: unknown): () => void {
65
72
  let disposed = false
66
73
  let polling = false
67
74
  let executing = false
75
+ let ticks = 0
76
+ /**
77
+ * 页面隐藏时按倍数跳轮;可见时每次都跑并清零计数。
78
+ * @author ddj 2026年10月15号
79
+ * @returns 是否跳过本轮
80
+ */
81
+ function hiddenSkip(): boolean {
82
+ if (typeof document === 'undefined' || document.visibilityState !== 'hidden') { ticks = 0; return false }
83
+ ticks += 1
84
+ return ticks % HIDDEN_POLL_EVERY !== 0
85
+ }
68
86
  /** @private @author ddj 2026年09月28号 Open all requested paths before acknowledging this exact lease. */
69
87
  async function execute(open: { paths: string[]; line?: number; column?: number; token: string; leaseId: string }): Promise<void> {
70
88
  executing = true
@@ -82,7 +100,7 @@ function startPresencePoll(ctx: unknown): () => void {
82
100
  if (!disposed && result.ok && result.open && !executing) void execute(result.open).catch(() => {})
83
101
  } finally { polling = false }
84
102
  }
85
- const timer = window.setInterval(() => { void tick().catch(() => {}) }, HANDOFF_POLL_MS)
103
+ const timer = window.setInterval(() => { if (hiddenSkip()) return; void tick().catch(() => {}) }, HANDOFF_POLL_MS)
86
104
  /** @private @author ddj 2026年09月28号 Stop heartbeats and fail outstanding editor waits on plugin disposal. */
87
105
  function dispose(): void {
88
106
  disposed = true
@@ -32,6 +32,7 @@ import { openEditorView } from './events.js'
32
32
  import { setupExtOpen } from './externalOpen.js'
33
33
  import { SettingsContext } from './settingsContext.js'
34
34
  import { SIDEBAR_PLUGIN, registerSlotSafely, settingsBridge } from './compat.js'
35
+ import { clientSummary } from './compatSummary.js'
35
36
  import { detectSidebarService, hasSideRoute, installSideEditor, routeSideEditor, setEnsureSideEditor, SIDEBAR_INSTALL_CMD } from './sidebarBridge.js'
36
37
  import { detectOfficial, installOfficial, registerOfficialFileClaim, OFFICIAL_TAB_KIND, OFFICIAL_TAB_TITLE, isEditorTabActive, markEditorActive, restoreEditorTab, buildFileAddress, readSavedTab, setEditorTabScope } from './officialSidebar.js'
37
38
  import { setNativeCsv, setNativeOpenHandler, resetNativeOpen } from './nativeOpenStore.js'
@@ -264,16 +265,19 @@ export async function apply(ctx: any): Promise<void> {
264
265
  /** 「添加到对话」动作集:编辑区/Tab 右键菜单注入文件引用与代码块(conversation 服务缺失时各动作安全降级)。 */
265
266
  const addToConversation = createAddToConversation(ctx)
266
267
 
267
- /** 客户端侧兼容摘要(与 host 报告合并展示;惰性求值保证 HMR 后仍新鲜)。 */
268
- const compatSummary = (): CompatAdapter[] => [
269
- { name: '设置桥', active: bridge.scope() !== undefined, note: bridge.scope() !== undefined ? '使用 ' + bridge.service() + ' 桥' : '未绑定设置服务(fileOpenTool 持久化不可用,晚到重试窗口内自愈)' },
270
- { name: '文件打开路由(workspaces.openPath)', active: Boolean(workspaces?.openPath), note: 'vscode 打开器优先,失败回退系统打开(0.1.2 及更早 DSH 的对话文件链接主路径)' },
271
- { name: '会话文件链接路由(remote.session.openWorkspacePath)', active: remoteOpenInstalled, note: '0.1.3+ 对话文件引用/产物打开优先走插件打开器,失败回退系统打开' },
272
- { name: '侧边栏打开器(' + SIDEBAR_PLUGIN + ')', active: registry.get(SIDEBAR_PLUGIN) !== undefined, note: registry.get(SIDEBAR_PLUGIN) !== undefined ? '已注册(优先级 80)' : '未检测到侧边栏打开能力' },
273
- { name: '侧边栏编辑区(官方 Sidebar)', active: officialService !== undefined, note: officialService !== undefined ? '编辑区=官方右侧 Sidebar Tab(对话+编辑同屏,DSH 0.1.5+)' : '官方侧边栏服务未探测到(DSH < 0.1.5-alpha.1 时属预期)' },
274
- { name: '侧边栏编辑区(' + SIDEBAR_PLUGIN + ',归档)', active: sideService !== undefined, note: sideService !== undefined ? '编辑区=侧边栏 Tab(旧版 DSH 回退形态)' : '未检测到;DSH ≥ 0.1.5 优先官方侧边栏,旧版可安装(' + SIDEBAR_INSTALL_CMD + ')' },
275
- { name: '文件链接官方认领(dsh-resource://file)', active: claimDisposer !== null, note: claimDisposer !== null ? '聊天文件链接转发进单一编辑器页签(文件分页归编辑器自带页签栏)' : '链接走官方查看器或旧版路由(未认领)' },
276
- ]
268
+ /**
269
+ * Build applicable client diagnostics from live installation state.
270
+ * @private @author ddj 2026年10月13号
271
+ * @param version Running DSH version supplied by the compat RPC.
272
+ * @returns Applicable adapters; routing behavior is unchanged.
273
+ */
274
+ const compatSummary = (version = ''): CompatAdapter[] => clientSummary({
275
+ settingsService: bridge.service(), settingsReady: bridge.scope() !== undefined,
276
+ openPathReady: Boolean(workspaces?.openPath), remoteReady: remoteOpenInstalled,
277
+ sidebarOpener: registry.get(SIDEBAR_PLUGIN) !== undefined,
278
+ legacySidebar: sideService !== undefined, officialSidebar: officialService !== undefined,
279
+ fileClaim: claimDisposer !== null, claimWanted: shouldClaimFiles(selected), selected,
280
+ }, version)
277
281
 
278
282
  ctx.provide('fileOpeners', registry)
279
283
  ctx.effect(() => registry.register(vscodeOpener()), 'vscode-mode: file opener')
@@ -108,25 +108,46 @@ function nextDocVer(path) {
108
108
  /**
109
109
  * 立即同步某文档到 host(首次打开/查询前保证服务器已认识该文档)。
110
110
  * sessionId 缺失时仍发起请求:host 对唯一活跃会话有兜底解析,弃发会白白丢失文档归属。
111
- * @author ddj 2026年08月27号 / 2026年09月11号
111
+ * @author ddj 2026年08月27号 / 2026年09月11号 / 2026年10月15号
112
112
  * @param path 工作区相对路径
113
- * @param text 文档全文
113
+ * @param text 文档全文;传函数表示**发送前才求值**(击键路径用它,避免为被去抖丢弃的中间态取全文)
114
114
  * @param immediate true 立即发送,false 去抖
115
115
  */
116
116
  export async function syncDoc(path, text, immediate = false) {
117
- if (!path || typeof text !== 'string') return
117
+ if (!path || (typeof text !== 'string' && typeof text !== 'function')) return
118
118
  if (immediate) {
119
119
  clearPending(path)
120
- await rpc('edrv.lsp.sync', { sessionId, path, text, version: nextDocVer(path) }).catch(() => {})
120
+ const value = resolveText(text)
121
+ if (value === null) return
122
+ await rpc('edrv.lsp.sync', { sessionId, path, text: value, version: nextDocVer(path) }).catch(() => {})
121
123
  return
122
124
  }
123
125
  clearPending(path)
124
126
  syncTimers.set(path, setTimeout(() => {
125
127
  syncTimers.delete(path)
126
- rpc('edrv.lsp.sync', { sessionId, path, text, version: nextDocVer(path) }).catch(() => {})
128
+ const value = resolveText(text)
129
+ if (value === null) return
130
+ rpc('edrv.lsp.sync', { sessionId, path, text: value, version: nextDocVer(path) }).catch(() => {})
127
131
  }, SYNC_DEBOUNCE_MS))
128
132
  }
129
133
 
134
+ /**
135
+ * 取本次要同步的文本:字符串原样返回,函数形态在发送前求值。
136
+ * 取值抛错(model 已销毁)时返回 null 表示**放弃本次同步**——不能退回空串,
137
+ * 那会把 host 侧文档内容清掉,比漏一次同步严重得多(关闭路径由 closeDoc 负责)。
138
+ * @author ddj 2026年10月15号
139
+ * @param text 字符串或惰性取值函数
140
+ * @returns 待发送文本;无法取值时为 null
141
+ */
142
+ function resolveText(text) {
143
+ if (typeof text === 'string') return text
144
+ try {
145
+ return String(text())
146
+ } catch (error) {
147
+ return null
148
+ }
149
+ }
150
+
130
151
  function clearPending(path) {
131
152
  const timer = syncTimers.get(path)
132
153
  if (timer) { clearTimeout(timer); syncTimers.delete(path) }
@@ -95,7 +95,9 @@ export function registerLspProviders(monaco) {
95
95
  if (!model || model.uri.scheme !== 'edrv') return
96
96
  const path = pathOfModel(model)
97
97
  if (!path) return
98
- const contentDisposer = model.onDidChangeContent(() => syncDoc(path, model.getValue()))
98
+ // 击键路径传惰性取值:去抖窗口内的中间态会被覆盖丢弃,事先取全文纯属白付(大文件尤甚)。
99
+ // model 已销毁时 resolveText 返回 null 放弃本次同步,不会拿空内容覆盖 host 侧文档。
100
+ const contentDisposer = model.onDidChangeContent(() => syncDoc(path, () => model.getValue()))
99
101
  const disposeDisposer = model.onWillDispose(() => {
100
102
  closeDoc(path)
101
103
  contentDisposer.dispose()
@@ -4344,12 +4344,12 @@ export function EditorView(props) {
4344
4344
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
4345
4345
  React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
4346
4346
  : null
4347
- const fullView = createPortal(React.createElement('div', { 'data-edrv-view': '1', style: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, minHeight: 0, overflow: 'hidden' } }, pathBar, sideHintEl, diskBanner(), editorArea, statusBar), fullHost, 'edrv-full-editor')
4347
+ const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea, statusBar), fullHost, 'edrv-full-editor')
4348
4348
  const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
4349
4349
  React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
4350
4350
  renderTree(workbench.tree), fullView,
4351
4351
  workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
4352
- dragPreview ? createPortal(React.createElement('div', { 'data-edrv-view': '1', style: { display: 'contents', pointerEvents: 'none' } },
4352
+ dragPreview ? createPortal(React.createElement(React.Fragment, null,
4353
4353
  dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
4354
4354
  React.createElement('div', { className: 'edrv-drag-ghost', style: { left: dragPreview.point.x + 12, top: dragPreview.point.y + 12 } }, baseNameOf(dragPreview.path) + ' · ' + dragPreview.label)), document.body) : null)
4355
4355
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
@@ -22,26 +22,29 @@ export interface WindowProps {
22
22
  children?: React.ReactNode
23
23
  }
24
24
 
25
- /** @private @author ddj 2026年10月14号 @param start Originating press. @param rect Initial bounds. @param sizing Resize mode. @param apply Commit bounds. @param done Gesture cleanup notification. @returns Gesture cancellation. */
26
- function beginGesture(start: React.PointerEvent, rect: FloatRect, sizing: boolean, apply: (rect: FloatRect) => void, done: () => void): () => void {
25
+ /** @private @author ddj 2026年10月14号 @param start Originating press. @param rect Initial bounds. @param sizing Resize mode. @param draw Paint live bounds without a render. @param apply Commit bounds once. @param done Gesture cleanup notification. @returns Gesture cancellation. */
26
+ function beginGesture(start: React.PointerEvent, rect: FloatRect, sizing: boolean, draw: (rect: FloatRect) => void, apply: (rect: FloatRect) => void, done: () => void): () => void {
27
27
  let frame: number | null = null
28
28
  let next = rect
29
- /** @private @author ddj 2026年10月14号 Apply no more than once per frame. */
30
- function paint(): void { frame = null; apply(next) }
29
+ let moved = false
30
+ /** @private @author ddj 2026年10月14号 Paint no more than once per frame. */
31
+ function paint(): void { frame = null; draw(next) }
31
32
  /** @private @author ddj 2026年10月14号 @param event Pointer motion. Ignore unrelated pointers. */
32
33
  function move(event: PointerEvent): void {
33
34
  if (event.pointerId !== start.pointerId) return
34
35
  event.preventDefault()
36
+ moved = true
35
37
  next = moveFloat(rect, event.clientX - start.clientX, event.clientY - start.clientY, sizing, window.innerWidth, window.innerHeight)
36
38
  if (frame === null) frame = requestAnimationFrame(paint)
37
39
  }
38
- /** @private @author ddj 2026年10月14号 Remove listeners and commit pending geometry. */
40
+ /** @private @author ddj 2026年10月14号 Remove listeners, then commit the settled geometry exactly once. */
39
41
  function stop(): void {
40
- if (frame !== null) { cancelAnimationFrame(frame); paint() }
42
+ if (frame !== null) { cancelAnimationFrame(frame); frame = null }
41
43
  window.removeEventListener('pointermove', move, true)
42
44
  window.removeEventListener('pointerup', end, true)
43
45
  window.removeEventListener('pointercancel', end, true)
44
46
  window.removeEventListener('blur', stop)
47
+ if (moved) { draw(next); apply(next) }
45
48
  done()
46
49
  }
47
50
  /** @private @author ddj 2026年10月14号 @param event Pointer release. Only end originating gesture. */
@@ -53,6 +56,31 @@ function beginGesture(start: React.PointerEvent, rect: FloatRect, sizing: boolea
53
56
  return stop
54
57
  }
55
58
 
59
+ const resizeSinks = new Set<() => void>()
60
+ let resizeFrame: number | null = null
61
+
62
+ /** @private @author ddj 2026年10月14号 Re-clamp every window at most once per frame. */
63
+ function onViewportResize(): void {
64
+ if (resizeFrame !== null) return
65
+ resizeFrame = requestAnimationFrame(() => {
66
+ resizeFrame = null
67
+ for (const sink of [...resizeSinks]) sink()
68
+ })
69
+ }
70
+
71
+ /** @private @author ddj 2026年10月14号 @param sink Per-window re-clamp callback. @returns Unsubscribe. */
72
+ function subscribeResize(sink: () => void): () => void {
73
+ if (resizeSinks.size === 0) window.addEventListener('resize', onViewportResize)
74
+ resizeSinks.add(sink)
75
+ return () => {
76
+ resizeSinks.delete(sink)
77
+ if (resizeSinks.size === 0) {
78
+ window.removeEventListener('resize', onViewportResize)
79
+ if (resizeFrame !== null) { cancelAnimationFrame(resizeFrame); resizeFrame = null }
80
+ }
81
+ }
82
+ }
83
+
56
84
  /** @public @author ddj 2026年10月14号 @param props Window contents and lifecycle callbacks. @returns Movable, resizable nonmodal portal. */
57
85
  export function FloatingWindow(props: WindowProps): React.ReactPortal {
58
86
  const id = React.useId()
@@ -63,21 +91,32 @@ export function FloatingWindow(props: WindowProps): React.ReactPortal {
63
91
  const current = React.useRef(props.rect ?? bounds)
64
92
  current.current = props.rect ?? bounds
65
93
  const cleanup = React.useRef<(() => void) | null>(null)
94
+ const gesture = React.useRef<FloatRect | null>(null)
66
95
  React.useSyncExternalStore(watchFloats, floatVersion, floatVersion)
67
96
  /** @private @author ddj 2026年10月14号 @param rect Next geometry. Notify controlled owner or retain local geometry. */
68
97
  function apply(rect: FloatRect): void {
69
98
  if (latest.current.rect) latest.current.onBounds?.(rect)
70
99
  else setBounds(rect)
71
100
  }
101
+ /** @private @author ddj 2026年10月14号 @param rect Live gesture geometry. Write the DOM and hold it until the commit render lands. */
102
+ function draw(rect: FloatRect): void {
103
+ gesture.current = rect
104
+ const node = root.current
105
+ if (!node) return
106
+ node.style.left = rect.left + 'px'
107
+ node.style.top = rect.top + 'px'
108
+ node.style.width = rect.width + 'px'
109
+ node.style.height = rect.height + 'px'
110
+ }
72
111
  /** @private @author ddj 2026年10月14号 End any active gesture. */
73
112
  function cancel(): void { cleanup.current?.(); cleanup.current = null }
74
- /** @private @author ddj 2026年10月14号 Re-clamp after viewport resize. */
75
- function resize(): void { cancel(); apply(clampFloat(current.current, window.innerWidth, window.innerHeight)) }
113
+ /** @private @author ddj 2026年10月14号 Re-clamp after viewport resize, preferring the gesture that just settled. */
114
+ function resize(): void { cancel(); apply(clampFloat(gesture.current ?? current.current, window.innerWidth, window.innerHeight)) }
76
115
  React.useEffect(() => {
77
116
  raiseFloat(id)
78
- window.addEventListener('resize', resize)
117
+ const off = subscribeResize(resize)
79
118
  resize()
80
- return () => { cancel(); window.removeEventListener('resize', resize); dropFloat(id) }
119
+ return () => { off(); cancel(); dropFloat(id) }
81
120
  }, [id])
82
121
  /** @private @author ddj 2026年10月14号 Activate without stealing text input focus. */
83
122
  function focus(): void { raiseFloat(id); latest.current.onFocus?.() }
@@ -91,10 +130,17 @@ export function FloatingWindow(props: WindowProps): React.ReactPortal {
91
130
  if (event.button !== 0 || !event.isPrimary) return
92
131
  cancel()
93
132
  event.preventDefault()
94
- cleanup.current = beginGesture(event, current.current, sizing, apply, clear)
133
+ cleanup.current = beginGesture(event, gesture.current ?? current.current, sizing, draw, apply, clear)
134
+ }
135
+ // 手势几何的持有范围只到「提交渲染落地」为止:apply() 走的是 pointerup 等离散事件,
136
+ // React 18 会在事件末尾同步 flush,下一次渲染 base 已是新几何,故此处可同步释放。
137
+ // 勘误 2026-10-14(测试约定):原先额外挂一帧再释放,会在手势结束后残留一个待执行帧
138
+ // (tests/floatingWindow.test.ts 断言 frames.size === 0),且无必要。
139
+ /** @private @author ddj 2026年10月14号 Release the settled geometry once React has committed it. */
140
+ function clear(): void {
141
+ cleanup.current = null
142
+ gesture.current = null
95
143
  }
96
- /** @private @author ddj 2026年10月14号 Clear completed gesture reference. */
97
- function clear(): void { cleanup.current = null }
98
144
  /** @private @author ddj 2026年10月14号 @param event Title press. Preserve title action buttons. */
99
145
  function moveStart(event: React.PointerEvent): void { if (!(event.target as Element).closest('button')) start(event, false) }
100
146
  /** @private @author ddj 2026年10月14号 @param event Resize press. */
@@ -107,7 +153,8 @@ export function FloatingWindow(props: WindowProps): React.ReactPortal {
107
153
  }
108
154
  props.onKeyDown?.(event)
109
155
  }
110
- const rect = clampFloat(props.rect ?? bounds, window.innerWidth, window.innerHeight)
156
+ const base = clampFloat(props.rect ?? bounds, window.innerWidth, window.innerHeight)
157
+ const rect = gesture.current ?? base
111
158
  return createPortal(React.createElement('div', {
112
159
  ref: root, tabIndex: -1, role: 'dialog', 'aria-label': props.title, 'aria-modal': false,
113
160
  className: 'edrv-floating', 'data-edrv-view': '1', 'data-edrv-window': id,
@@ -414,7 +414,7 @@ function CompatSection({ getSummary }) {
414
414
  devForm?.enabled && group('开发形态', React.createElement('div', { className: 'vsm-compat-item ok' }, React.createElement('span', { className: 'vsm-compat-name' }, '开发形态开启(工作区链接安装)'), React.createElement('span', { className: 'vsm-compat-note' }, devForm.path))),
415
415
  group('版本适配', renderItems(report?.adapters)),
416
416
  group('外部插件与服务', renderItems(report?.external)),
417
- group('客户端适配', renderItems(getSummary?.() ?? [])),
417
+ group('客户端适配', renderItems(getSummary?.(report?.dshVersion ?? '') ?? [])),
418
418
  group('护栏', renderItems(report?.guards)),
419
419
  group('警告', warnings.length ? warnings.map((w, index) => React.createElement('div', { key: index, className: 'vsm-mcp-error vsm-mcp-banner' }, w)) : React.createElement('div', { className: 'vsm-compat-item ok' }, React.createElement('span', { className: 'vsm-compat-name' }, '未检测到兼容性问题'), React.createElement('span', { className: 'vsm-compat-note' }, ''))),
420
420
  )
@@ -571,11 +571,42 @@ function PathRow(props) {
571
571
  buttons)
572
572
  }
573
573
 
574
+ /**
575
+ * 单个数据行:浅比较包装(回调身份由父级 ref 转发保持稳定,故 memo 才可能命中)。
576
+ * @author ddj 2026年10月14号
577
+ */
578
+ const LogItemRow = React.memo(LogItem)
579
+
580
+ /**
581
+ * 数据行容器(P0-14 上段)。独立 memo:只在条目列表/选中集/工作副本版号变化时重建行元素;
582
+ * 上段高度拖拽与弹窗缩放只改 LogList 的 height,不再逐帧重建整表(原先每帧 logRowsOf + N 个元素)。
583
+ * @author ddj 2026年10月14号
584
+ * @param props.list 过滤排序后的条目(非空)
585
+ * @param props.selectedRevs 可见选中的 revision 集合
586
+ * @param props.wcRev 工作副本版号(null = 不加粗)
587
+ * @param props.onPick 选中回调(event, item)
588
+ * @param props.onMenu 右键回调(event, item)
589
+ * @returns 行元素
590
+ */
591
+ const LogRows = React.memo(function LogRows(props) {
592
+ const { list, selectedRevs, wcRev, onPick, onMenu } = props
593
+ return React.createElement(React.Fragment, null,
594
+ logRowsOf(list).map((row) => React.createElement(LogItemRow, {
595
+ key: row.item.revision + ':' + row.level,
596
+ item: row.item,
597
+ on: selectedRevs.has(row.item.revision),
598
+ wc: wcRev != null && row.item.revision === wcRev,
599
+ level: row.level,
600
+ onPick,
601
+ onMenu,
602
+ })))
603
+ })
604
+
574
605
  /**
575
606
  * 上窗格列表(列头行 + 数据行 + 「加载更多」;P0-13 宽度受拖拽控制)。
576
607
  * @author ddj 2026年09月16号 / 2026年09月17号
577
608
  * @param props.list 过滤排序后的条目(null = 未加载)
578
- * @param props.selected 选中条目数组(P1-3 多选;至少一条,由父级保证)
609
+ * @param props.selectedRevs 可见选中的 revision 集合(P1-3 多选;由父级按 revision 维护)
579
610
  * @param props.keyword 过滤词(决定空态文案)
580
611
  * @param props.busy 加载中
581
612
  * @param props.truncated 是否可能还有更多
@@ -588,22 +619,14 @@ function PathRow(props) {
588
619
  * @returns 列表元素
589
620
  */
590
621
  function LogList(props) {
591
- const { list, selected, keyword, busy, truncated, sortKey, sortDir, onSort, onSortReset, onPick, onLoadMore, onItemMenu, height, wcRev } = props
622
+ const { list, selectedRevs, keyword, busy, truncated, sortKey, sortDir, onSort, onSortReset, onPick, onLoadMore, onItemMenu, height, wcRev } = props
592
623
  let inner
593
624
  if (list === null) {
594
625
  inner = React.createElement('div', { className: 'edrv-tree-loading' }, busy ? '读取日志中…' : '尚未加载')
595
626
  } else if (!list.length) {
596
627
  inner = React.createElement('div', { className: 'edrv-tree-loading' }, keyword ? '无匹配条目' : '无提交历史')
597
628
  } else {
598
- inner = logRowsOf(list).map((row) => React.createElement(LogItem, {
599
- key: row.item.revision + ':' + row.level,
600
- item: row.item,
601
- on: selected.some((s) => s.revision === row.item.revision),
602
- wc: wcRev != null && row.item.revision === wcRev,
603
- level: row.level,
604
- onPick,
605
- onMenu: onItemMenu,
606
- }))
629
+ inner = React.createElement(LogRows, { list, selectedRevs, wcRev, onPick, onMenu: onItemMenu })
607
630
  }
608
631
  const more = truncated
609
632
  ? React.createElement('div', { className: 'edrv-svnlog-more' },
@@ -921,10 +944,14 @@ export function SvnLogDialog(props) {
921
944
  () => (filtered === null ? null : orderLogEntries(filtered, sortKey ?? 'revision', sortKey ? sortDir : 'desc')),
922
945
  [filtered, sortKey, sortDir],
923
946
  )
924
- // P1-3:可见选中 = 选中数组 ∩ 当前显示列表;current = 可见选中首条(详情/菜单主语),空则回落首条
925
- const visibleSelected = sorted
926
- ? selected.filter((item) => sorted.some((x) => x.revision === item.revision))
927
- : selected
947
+ // P1-3:可见选中 = 选中数组 ∩ 当前显示列表;current = 可见选中首条(详情/菜单主语),空则回落首条。
948
+ // 交集改用 revision 集合:原实现对每条选中再扫一遍显示列表(O(n×m))且每次渲染重算(缩放弹窗时每帧都会走到)。
949
+ const sortedRevs = React.useMemo(() => (sorted ? new Set(sorted.map((x) => x.revision)) : null), [sorted])
950
+ const visibleSelected = React.useMemo(
951
+ () => (sortedRevs ? selected.filter((item) => sortedRevs.has(item.revision)) : selected),
952
+ [sortedRevs, selected],
953
+ )
954
+ const selectedRevs = React.useMemo(() => new Set(visibleSelected.map((item) => item.revision)), [visibleSelected])
928
955
  const current = visibleSelected.length
929
956
  ? visibleSelected[0]
930
957
  : (sorted && sorted.length ? sorted[0] : null)
@@ -1022,30 +1049,37 @@ export function SvnLogDialog(props) {
1022
1049
  * @param wantList 目标上段(修订列表)高度
1023
1050
  * @param wantMsg 目标中段(提交信息)高度
1024
1051
  */
1052
+ /** @private @author ddj 2026年10月14号 @param nextList 目标上段高度 @param nextMsg 目标中段高度 只在值真正变化时提交。 */
1053
+ const applyFit = (nextList, nextMsg) => {
1054
+ if (nextList !== listH) setListH(nextList)
1055
+ if (nextMsg !== msgH) setMsgH(nextMsg)
1056
+ }
1057
+
1025
1058
  const fitHeights = (wantList, wantMsg) => {
1026
1059
  const total = bodyRef.current?.clientHeight ?? 0
1027
- if (!total) { setListH(wantList); setMsgH(wantMsg); return }
1060
+ if (!total) { applyFit(wantList, wantMsg); return }
1028
1061
  const paneOcc = paneRef.current?.offsetHeight ?? -1
1029
1062
  // 实测导出非本段占用(勘误 2026-09-24:手算公式漏 gap/margin/padding 曾致保底仅114);测不到回落分隔条
1030
1063
  const chrome = paneOcc >= 0 ? Math.max(0, total - listH - msgH - paneOcc) : HSPLIT_TOTAL_PX
1031
1064
  const budget = total - PANE_KEEP_MIN - chrome
1032
1065
  if (budget < LIST_H_MIN + MSG_H_MIN) {
1033
- setListH(Math.min(wantList, LIST_H_MIN))
1034
- setMsgH(Math.min(wantMsg, MSG_H_MIN))
1066
+ applyFit(Math.min(wantList, LIST_H_MIN), Math.min(wantMsg, MSG_H_MIN))
1035
1067
  return
1036
1068
  }
1037
- if (wantList + wantMsg <= budget) { setListH(wantList); setMsgH(wantMsg); return }
1069
+ if (wantList + wantMsg <= budget) { applyFit(wantList, wantMsg); return }
1038
1070
  const scale = budget / (wantList + wantMsg)
1039
1071
  let nextList = Math.floor(wantList * scale)
1040
1072
  let nextMsg = Math.floor(wantMsg * scale)
1041
1073
  if (nextList < LIST_H_MIN) { nextList = LIST_H_MIN; nextMsg = Math.min(nextMsg, budget - nextList) }
1042
1074
  else if (nextMsg < MSG_H_MIN) { nextMsg = MSG_H_MIN; nextList = Math.min(nextList, budget - nextMsg) }
1043
- setListH(nextList)
1044
- setMsgH(nextMsg)
1075
+ applyFit(nextList, nextMsg)
1045
1076
  }
1046
1077
 
1047
1078
  // 初始/弹窗尺寸变化 fit(2026-09-24 用户反馈:固定高中段把下段变更文件区挤到 0):
1048
- // 挂载与 dlgH 变化时收敛一次;拖拽期间不介入(拖拽自带 clamp),双击复位经 fitHeights 显式收敛。
1079
+ // 观察的是 body(其高度由弹窗尺寸决定、不含 listH/msgH),故收敛是单步不动点、不会与自身互相激发;
1080
+ // 拖拽期间不介入(拖拽自带 clamp),双击复位经 fitHeights 显式收敛。
1081
+ // 勘误 2026-10-14:ResizeObserver 回调本身已按帧派发,无需再包 rAF —— 推迟到下一帧反而会多出
1082
+ // 一帧「尺寸已变小、height 还是旧值」的中间态,正是要避免的下段被挤扁。改由 applyFit 抑制同值提交。
1049
1083
  const fitRef = React.useRef(null)
1050
1084
  fitRef.current = () => fitHeights(listH, msgH)
1051
1085
  React.useEffect(() => {
@@ -1127,6 +1161,15 @@ export function SvnLogDialog(props) {
1127
1161
  })
1128
1162
  }
1129
1163
 
1164
+ // React.memo 生效的前提是行回调身份稳定:pickWithMods/onItemMenu 的闭包依赖 anchorRev/sorted/visibleSelected,
1165
+ // 每次渲染都是新函数,会把 LogRows/LogItemRow 的 memo 全部打穿。用 ref 存最新闭包、useCallback 空依赖转发。
1166
+ /** @author ddj 2026年10月14号 */
1167
+ const rowCbs = React.useRef<{ pick: ((event: unknown, item: unknown) => void) | null; menu: ((event: unknown, item: unknown) => void) | null }>({ pick: null, menu: null })
1168
+ rowCbs.current.pick = pickWithMods
1169
+ rowCbs.current.menu = onItemMenu
1170
+ const pickStable = React.useCallback((event: unknown, item: unknown) => rowCbs.current.pick?.(event, item), [])
1171
+ const menuStable = React.useCallback((event: unknown, item: unknown) => rowCbs.current.menu?.(event, item), [])
1172
+
1130
1173
  /** 下窗格路径行右键菜单(P0-8;G8 目录不可开差异,G9 仓库外不可打开)。 */
1131
1174
  const onPathMenu = (event, item, revision) => {
1132
1175
  event.preventDefault()
@@ -1149,9 +1192,9 @@ export function SvnLogDialog(props) {
1149
1192
  // P0-14 上中下三段全宽:上=修订列表(高可拖)/ 中=提交信息(高可拖)/ 下=变更路径(占余)
1150
1193
  const body = React.createElement('div', { className: 'edrv-svnlog-body', ref: bodyRef },
1151
1194
  React.createElement(LogList, {
1152
- list: sorted, selected: visibleSelected, keyword: query.trim(), busy, truncated,
1195
+ list: sorted, selectedRevs, keyword: query.trim(), busy, truncated,
1153
1196
  sortKey, sortDir, onSort, onSortReset,
1154
- onPick: pickWithMods, onLoadMore, onItemMenu,
1197
+ onPick: pickStable, onLoadMore, onItemMenu: menuStable,
1155
1198
  height: listH, wcRev,
1156
1199
  }),
1157
1200
  React.createElement(Splitter, {
package/src/svn.ts CHANGED
@@ -1119,8 +1119,15 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1119
1119
  const paths = [...new Set(args.paths.map(safeRel))]
1120
1120
  if (paths.some((path) => !path)) return { ok: false as const, error: '路径不合法' }
1121
1121
  try {
1122
+ // 分批并发(批 4,计划批准值):冲突面板一次请求上限 64 条,旧串行版在 64 冲突时要排队 64 次 svn 进程
1123
+ // (每次冷启动约 10~40ms,最坏累计 1~2s 阻塞面板)。readConflict 只跑 `svn status --xml`(只读,无写入竞争)。
1124
+ // 与 svn.fileSizes 的分批并发同型(见下方 BATCH = 16);分批 push 保持结果顺序不变。
1125
+ // @author ddj 2026年10月14号
1122
1126
  const states = []
1123
- for (const path of paths) states.push(await readConflict(scope.cwd, scope.root, path!))
1127
+ const BATCH = 4
1128
+ for (let start = 0; start < paths.length; start += BATCH) {
1129
+ states.push(...await Promise.all(paths.slice(start, start + BATCH).map((path) => readConflict(scope.cwd, scope.root, path!))))
1130
+ }
1124
1131
  return { ok: true as const, states }
1125
1132
  } catch (error) { return { ok: false as const, error: String(error) } }
1126
1133
  },