dsh-vscode-mode 0.15.3 → 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.
@@ -0,0 +1,168 @@
1
+ /** Shared in-page window chrome, without a mask or focus trap. @author ddj 2026年10月14号 */
2
+ import React from 'react'
3
+ import { createPortal } from 'react-dom'
4
+ import { clampFloat } from '../state/editorLayout.js'
5
+ import type { FloatRect } from '../state/editorLayout.js'
6
+ import { dropFloat, floatLayer, floatVersion, initialFloat, moveFloat, raiseFloat, watchFloats } from '../state/floatingWindow.js'
7
+
8
+ export interface WindowProps {
9
+ title: string
10
+ dialogClass?: string
11
+ width?: number
12
+ height?: number
13
+ rect?: FloatRect
14
+ owner?: string
15
+ group?: string
16
+ closeOnEsc?: boolean
17
+ onClose?: () => void
18
+ onBounds?: (rect: FloatRect) => void
19
+ onFocus?: () => void
20
+ onKeyDown?: (event: React.KeyboardEvent) => void
21
+ actions?: React.ReactNode
22
+ children?: React.ReactNode
23
+ }
24
+
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
+ let frame: number | null = null
28
+ let next = rect
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) }
32
+ /** @private @author ddj 2026年10月14号 @param event Pointer motion. Ignore unrelated pointers. */
33
+ function move(event: PointerEvent): void {
34
+ if (event.pointerId !== start.pointerId) return
35
+ event.preventDefault()
36
+ moved = true
37
+ next = moveFloat(rect, event.clientX - start.clientX, event.clientY - start.clientY, sizing, window.innerWidth, window.innerHeight)
38
+ if (frame === null) frame = requestAnimationFrame(paint)
39
+ }
40
+ /** @private @author ddj 2026年10月14号 Remove listeners, then commit the settled geometry exactly once. */
41
+ function stop(): void {
42
+ if (frame !== null) { cancelAnimationFrame(frame); frame = null }
43
+ window.removeEventListener('pointermove', move, true)
44
+ window.removeEventListener('pointerup', end, true)
45
+ window.removeEventListener('pointercancel', end, true)
46
+ window.removeEventListener('blur', stop)
47
+ if (moved) { draw(next); apply(next) }
48
+ done()
49
+ }
50
+ /** @private @author ddj 2026年10月14号 @param event Pointer release. Only end originating gesture. */
51
+ function end(event: PointerEvent): void { if (event.pointerId === start.pointerId) stop() }
52
+ window.addEventListener('pointermove', move, true)
53
+ window.addEventListener('pointerup', end, true)
54
+ window.addEventListener('pointercancel', end, true)
55
+ window.addEventListener('blur', stop)
56
+ return stop
57
+ }
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
+
84
+ /** @public @author ddj 2026年10月14号 @param props Window contents and lifecycle callbacks. @returns Movable, resizable nonmodal portal. */
85
+ export function FloatingWindow(props: WindowProps): React.ReactPortal {
86
+ const id = React.useId()
87
+ const root = React.useRef<HTMLDivElement>(null)
88
+ const latest = React.useRef(props)
89
+ latest.current = props
90
+ const [bounds, setBounds] = React.useState(() => initialFloat(props.width ?? 760, props.height ?? 600, window.innerWidth, window.innerHeight))
91
+ const current = React.useRef(props.rect ?? bounds)
92
+ current.current = props.rect ?? bounds
93
+ const cleanup = React.useRef<(() => void) | null>(null)
94
+ const gesture = React.useRef<FloatRect | null>(null)
95
+ React.useSyncExternalStore(watchFloats, floatVersion, floatVersion)
96
+ /** @private @author ddj 2026年10月14号 @param rect Next geometry. Notify controlled owner or retain local geometry. */
97
+ function apply(rect: FloatRect): void {
98
+ if (latest.current.rect) latest.current.onBounds?.(rect)
99
+ else setBounds(rect)
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
+ }
111
+ /** @private @author ddj 2026年10月14号 End any active gesture. */
112
+ function cancel(): void { cleanup.current?.(); cleanup.current = null }
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)) }
115
+ React.useEffect(() => {
116
+ raiseFloat(id)
117
+ const off = subscribeResize(resize)
118
+ resize()
119
+ return () => { off(); cancel(); dropFloat(id) }
120
+ }, [id])
121
+ /** @private @author ddj 2026年10月14号 Activate without stealing text input focus. */
122
+ function focus(): void { raiseFloat(id); latest.current.onFocus?.() }
123
+ /** @private @author ddj 2026年10月14号 @param event Window press. Give noninteractive content a keyboard owner. */
124
+ function activate(event: React.PointerEvent): void {
125
+ focus()
126
+ if (!(event.target as Element).closest('button, input, select, textarea, a, [contenteditable], .monaco-editor')) root.current?.focus({ preventScroll: true })
127
+ }
128
+ /** @private @author ddj 2026年10月14号 @param event Handle press. @param sizing Resize instead of move. */
129
+ function start(event: React.PointerEvent, sizing: boolean): void {
130
+ if (event.button !== 0 || !event.isPrimary) return
131
+ cancel()
132
+ event.preventDefault()
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
143
+ }
144
+ /** @private @author ddj 2026年10月14号 @param event Title press. Preserve title action buttons. */
145
+ function moveStart(event: React.PointerEvent): void { if (!(event.target as Element).closest('button')) start(event, false) }
146
+ /** @private @author ddj 2026年10月14号 @param event Resize press. */
147
+ function sizeStart(event: React.PointerEvent): void { start(event, true) }
148
+ /** @private @author ddj 2026年10月14号 @param event Local key. Exclude React-bubbling child portals. */
149
+ function keyDown(event: React.KeyboardEvent): void {
150
+ if ((event.target as Element).closest('[data-edrv-window]') !== root.current || event.defaultPrevented) return
151
+ if (event.key === 'Escape' && props.closeOnEsc !== false && props.onClose) {
152
+ event.preventDefault(); event.stopPropagation(); props.onClose(); return
153
+ }
154
+ props.onKeyDown?.(event)
155
+ }
156
+ const base = clampFloat(props.rect ?? bounds, window.innerWidth, window.innerHeight)
157
+ const rect = gesture.current ?? base
158
+ return createPortal(React.createElement('div', {
159
+ ref: root, tabIndex: -1, role: 'dialog', 'aria-label': props.title, 'aria-modal': false,
160
+ className: 'edrv-floating', 'data-edrv-view': '1', 'data-edrv-window': id,
161
+ 'data-edrv-float': props.group, 'data-edrv-owner': props.owner,
162
+ style: { ...rect, zIndex: floatLayer(id) }, onPointerDownCapture: activate, onFocusCapture: focus, onKeyDown: keyDown,
163
+ },
164
+ React.createElement('div', { className: 'edrv-floating-title', onPointerDown: moveStart },
165
+ React.createElement('span', null, props.title), props.actions),
166
+ React.createElement('div', { className: 'edrv-floating-content' + (props.dialogClass ? ' ' + props.dialogClass : '') }, props.children),
167
+ React.createElement('div', { className: 'edrv-floating-grip', title: '拖动调整窗口大小', onPointerDown: sizeStart })), document.body)
168
+ }
@@ -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
  )
@@ -14,7 +14,7 @@
14
14
  * 作者 ddj 2026年09月23号
15
15
  */
16
16
  import React from 'react'
17
- import { ModalShell } from './ModalShell.js'
17
+ import { FloatingWindow } from './FloatingWindow.js'
18
18
 
19
19
  /**
20
20
  * 预览勾选初值:分组默认全勾(changelist 可逆),还原/忽略默认不勾(有副作用)。
@@ -282,11 +282,11 @@ export function SvnAiPlanDialog(props) {
282
282
  }, '取消剩余'))))
283
283
  }
284
284
 
285
- return React.createElement(ModalShell, {
285
+ return React.createElement(FloatingWindow, {
286
+ title: 'SVN 智能整理', height: 600,
286
287
  key: 'edrv-svn-plan',
287
288
  dialogClass: 'edrv-svnplan-dialog',
288
- width: 'min(680px, calc(100vw - 48px))',
289
- closeOnMask: !running,
289
+ width: 680,
290
290
  closeOnEsc: !running,
291
291
  onClose,
292
292
  }, header, metaEl, body, footer)
@@ -26,6 +26,8 @@ import { SVN_LOG_ACTION_LABEL } from '../../shared/svn.js'
26
26
  import { orderLogEntries } from '../../svnLog.js'
27
27
  import { SVN_LOG_SHOW_ALL_LIMIT } from '../svnLog.js'
28
28
  import { ModalShell } from './ModalShell.js'
29
+ import { FloatingWindow } from './FloatingWindow.js'
30
+ import { inputOwnsKey } from '../state/floatingWindow.js'
29
31
  import { ContextMenu } from './ContextMenu.js'
30
32
  import type { ContextMenuEntry } from './ContextMenu.js'
31
33
  import { SvnLogStats } from './SvnLogStats.js'
@@ -569,11 +571,42 @@ function PathRow(props) {
569
571
  buttons)
570
572
  }
571
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
+
572
605
  /**
573
606
  * 上窗格列表(列头行 + 数据行 + 「加载更多」;P0-13 宽度受拖拽控制)。
574
607
  * @author ddj 2026年09月16号 / 2026年09月17号
575
608
  * @param props.list 过滤排序后的条目(null = 未加载)
576
- * @param props.selected 选中条目数组(P1-3 多选;至少一条,由父级保证)
609
+ * @param props.selectedRevs 可见选中的 revision 集合(P1-3 多选;由父级按 revision 维护)
577
610
  * @param props.keyword 过滤词(决定空态文案)
578
611
  * @param props.busy 加载中
579
612
  * @param props.truncated 是否可能还有更多
@@ -586,22 +619,14 @@ function PathRow(props) {
586
619
  * @returns 列表元素
587
620
  */
588
621
  function LogList(props) {
589
- 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
590
623
  let inner
591
624
  if (list === null) {
592
625
  inner = React.createElement('div', { className: 'edrv-tree-loading' }, busy ? '读取日志中…' : '尚未加载')
593
626
  } else if (!list.length) {
594
627
  inner = React.createElement('div', { className: 'edrv-tree-loading' }, keyword ? '无匹配条目' : '无提交历史')
595
628
  } else {
596
- inner = logRowsOf(list).map((row) => React.createElement(LogItem, {
597
- key: row.item.revision + ':' + row.level,
598
- item: row.item,
599
- on: selected.some((s) => s.revision === row.item.revision),
600
- wc: wcRev != null && row.item.revision === wcRev,
601
- level: row.level,
602
- onPick,
603
- onMenu: onItemMenu,
604
- }))
629
+ inner = React.createElement(LogRows, { list, selectedRevs, wcRev, onPick, onMenu: onItemMenu })
605
630
  }
606
631
  const more = truncated
607
632
  ? React.createElement('div', { className: 'edrv-svnlog-more' },
@@ -886,9 +911,6 @@ export function SvnLogDialog(props) {
886
911
  const [sortKey, setSortKey] = React.useState(null)
887
912
  const [sortDir, setSortDir] = React.useState('desc')
888
913
  const [menu, setMenu] = React.useState(null)
889
- const [dlgW, setDlgW] = React.useState(() => Math.min(DIALOG_W_DEFAULT, (typeof window !== 'undefined' ? window.innerWidth : 1280) - 24)) // P0-14:外框宽(拖拽受控)
890
- const [dlgH, setDlgH] = React.useState(() => Math.min(DIALOG_H_DEFAULT, (typeof window !== 'undefined' ? window.innerHeight : 800) - 24)) // P0-14:外框高(拖拽受控)
891
- const [anchor, setAnchor] = React.useState(null) // 八向拖拽锚点(null=flex 居中;非 null=absolute 定位于该视口坐标) @author ddj 2026年09月24号
892
914
  const [listH, setListH] = React.useState(LIST_H_DEFAULT) // P0-14:上段修订列表高(拖拽受控)
893
915
  const [msgH, setMsgH] = React.useState(MSG_H_DEFAULT) // P0-14:中段提交信息高(拖拽受控)
894
916
  const [statsOpen, setStatsOpen] = React.useState(false) // P1-10:统计窗开关
@@ -922,10 +944,14 @@ export function SvnLogDialog(props) {
922
944
  () => (filtered === null ? null : orderLogEntries(filtered, sortKey ?? 'revision', sortKey ? sortDir : 'desc')),
923
945
  [filtered, sortKey, sortDir],
924
946
  )
925
- // P1-3:可见选中 = 选中数组 ∩ 当前显示列表;current = 可见选中首条(详情/菜单主语),空则回落首条
926
- const visibleSelected = sorted
927
- ? selected.filter((item) => sorted.some((x) => x.revision === item.revision))
928
- : 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])
929
955
  const current = visibleSelected.length
930
956
  ? visibleSelected[0]
931
957
  : (sorted && sorted.length ? sorted[0] : null)
@@ -998,65 +1024,6 @@ export function SvnLogDialog(props) {
998
1024
  window.addEventListener('pointerup', up)
999
1025
  }
1000
1026
 
1001
- // 八向(13-log-ui-polish 增量3):pointerdown 取卡片视口 rect 起点并立即 absolute 锚定,
1002
- // 实现「拖哪边哪边动、对边固定」(flex 居中模型无法表达该语义);clamp = 尺寸下限 680/480
1003
- // 与视口 12px 边距;每把手柄都以实际 rect 重锚 → CSS 截断/取整漂移自校正。
1004
- const startResize = (event, mode) => {
1005
- event.preventDefault()
1006
- event.stopPropagation()
1007
- const card = event.currentTarget.parentElement
1008
- const rect = card ? card.getBoundingClientRect() : null
1009
- if (!rect) return
1010
- const r0 = { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
1011
- const startX = event.clientX
1012
- const startY = event.clientY
1013
- const moveL = mode === 'w' || mode === 'nw' || mode === 'sw'
1014
- const moveR = mode === 'e' || mode === 'ne' || mode === 'se'
1015
- const moveT = mode === 'n' || mode === 'nw' || mode === 'ne'
1016
- const moveB = mode === 's' || mode === 'sw' || mode === 'se'
1017
- const EDGE = 12 // 视口边距
1018
- const right0 = r0.left + r0.width
1019
- const bottom0 = r0.top + r0.height
1020
- // 起手锚定:切 absolute 到当前 rect(视觉零跳变)并同步 state=实际渲染尺寸
1021
- setAnchor({ left: r0.left, top: r0.top })
1022
- setDlgW(Math.round(r0.width))
1023
- setDlgH(Math.round(r0.height))
1024
- const move = (e) => {
1025
- const dx = e.clientX - startX
1026
- const dy = e.clientY - startY
1027
- let left = r0.left
1028
- let top = r0.top
1029
- let width = r0.width
1030
- let height = r0.height
1031
- if (moveR) width = Math.min(Math.max(DIALOG_W_MIN, r0.width + dx), window.innerWidth - EDGE - r0.left)
1032
- if (moveL) { left = Math.min(Math.max(EDGE, r0.left + dx), right0 - DIALOG_W_MIN); width = right0 - left }
1033
- if (moveB) height = Math.min(Math.max(DIALOG_H_MIN, r0.height + dy), window.innerHeight - EDGE - r0.top)
1034
- if (moveT) { top = Math.min(Math.max(EDGE, r0.top + dy), bottom0 - DIALOG_H_MIN); height = bottom0 - top }
1035
- const nextLeft = left
1036
- const nextTop = top
1037
- setAnchor((prev) => (prev && prev.left === nextLeft && prev.top === nextTop) ? prev : { left: nextLeft, top: nextTop })
1038
- setDlgW(Math.round(width))
1039
- setDlgH(Math.round(height))
1040
- }
1041
- const up = () => {
1042
- window.removeEventListener('pointermove', move)
1043
- window.removeEventListener('pointerup', up)
1044
- swallowNextClick()
1045
- }
1046
- window.addEventListener('pointermove', move)
1047
- window.addEventListener('pointerup', up)
1048
- }
1049
-
1050
- /**
1051
- * 双击复位:清锚回 flex 居中 + 默认尺寸(视口 clamp 同初始,防小屏溢出)。
1052
- * @author ddj 2026年09月24号
1053
- */
1054
- const resetDlgSize = () => {
1055
- setAnchor(null)
1056
- setDlgW(Math.min(DIALOG_W_DEFAULT, window.innerWidth - 24))
1057
- setDlgH(Math.min(DIALOG_H_DEFAULT, window.innerHeight - 24))
1058
- }
1059
-
1060
1027
  /** 上段修订列表高度拖拽(clamp:下限 200,上限 = body 高 - 信息区/路径区/分隔条保留空间)。 */
1061
1028
  const onListHDrag = (event) => startDrag(event, 'y', listH, (next) => {
1062
1029
  const total = bodyRef.current?.clientHeight ?? 0
@@ -1082,33 +1049,45 @@ export function SvnLogDialog(props) {
1082
1049
  * @param wantList 目标上段(修订列表)高度
1083
1050
  * @param wantMsg 目标中段(提交信息)高度
1084
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
+
1085
1058
  const fitHeights = (wantList, wantMsg) => {
1086
1059
  const total = bodyRef.current?.clientHeight ?? 0
1087
- if (!total) { setListH(wantList); setMsgH(wantMsg); return }
1060
+ if (!total) { applyFit(wantList, wantMsg); return }
1088
1061
  const paneOcc = paneRef.current?.offsetHeight ?? -1
1089
1062
  // 实测导出非本段占用(勘误 2026-09-24:手算公式漏 gap/margin/padding 曾致保底仅114);测不到回落分隔条
1090
1063
  const chrome = paneOcc >= 0 ? Math.max(0, total - listH - msgH - paneOcc) : HSPLIT_TOTAL_PX
1091
1064
  const budget = total - PANE_KEEP_MIN - chrome
1092
1065
  if (budget < LIST_H_MIN + MSG_H_MIN) {
1093
- setListH(Math.min(wantList, LIST_H_MIN))
1094
- setMsgH(Math.min(wantMsg, MSG_H_MIN))
1066
+ applyFit(Math.min(wantList, LIST_H_MIN), Math.min(wantMsg, MSG_H_MIN))
1095
1067
  return
1096
1068
  }
1097
- if (wantList + wantMsg <= budget) { setListH(wantList); setMsgH(wantMsg); return }
1069
+ if (wantList + wantMsg <= budget) { applyFit(wantList, wantMsg); return }
1098
1070
  const scale = budget / (wantList + wantMsg)
1099
1071
  let nextList = Math.floor(wantList * scale)
1100
1072
  let nextMsg = Math.floor(wantMsg * scale)
1101
1073
  if (nextList < LIST_H_MIN) { nextList = LIST_H_MIN; nextMsg = Math.min(nextMsg, budget - nextList) }
1102
1074
  else if (nextMsg < MSG_H_MIN) { nextMsg = MSG_H_MIN; nextList = Math.min(nextList, budget - nextMsg) }
1103
- setListH(nextList)
1104
- setMsgH(nextMsg)
1075
+ applyFit(nextList, nextMsg)
1105
1076
  }
1106
1077
 
1107
1078
  // 初始/弹窗尺寸变化 fit(2026-09-24 用户反馈:固定高中段把下段变更文件区挤到 0):
1108
- // 挂载与 dlgH 变化时收敛一次;拖拽期间不介入(拖拽自带 clamp),双击复位经 fitHeights 显式收敛。
1079
+ // 观察的是 body(其高度由弹窗尺寸决定、不含 listH/msgH),故收敛是单步不动点、不会与自身互相激发;
1080
+ // 拖拽期间不介入(拖拽自带 clamp),双击复位经 fitHeights 显式收敛。
1081
+ // 勘误 2026-10-14:ResizeObserver 回调本身已按帧派发,无需再包 rAF —— 推迟到下一帧反而会多出
1082
+ // 一帧「尺寸已变小、height 还是旧值」的中间态,正是要避免的下段被挤扁。改由 applyFit 抑制同值提交。
1083
+ const fitRef = React.useRef(null)
1084
+ fitRef.current = () => fitHeights(listH, msgH)
1109
1085
  React.useEffect(() => {
1110
- fitHeights(listH, msgH)
1111
- }, [dlgH])
1086
+ const observer = new ResizeObserver(() => fitRef.current?.())
1087
+ if (bodyRef.current) observer.observe(bodyRef.current)
1088
+ fitRef.current?.()
1089
+ return () => observer.disconnect()
1090
+ }, [])
1112
1091
 
1113
1092
  // 勘误2(2026-09-24):挂载 fit 量到的是数据到达前布局(终态差 ~4px → 保底 166)。
1114
1093
  // 初次数据到达以终态布局补 fit 一次收敛至 ≥170;单次门闩,之后刷新/加载更多/拖拽不介入。
@@ -1118,24 +1097,20 @@ export function SvnLogDialog(props) {
1118
1097
  fitHeights(listH, msgH)
1119
1098
  }, [list])
1120
1099
 
1121
- // 键盘:F5 刷新(防浏览器整页刷新);↑↓ 在显示列表内移动选中(输入框内不劫持)
1122
- React.useEffect(() => {
1123
- const onKey = (event) => {
1124
- if (event.key === 'F5') { event.preventDefault(); onRefresh(); return }
1125
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
1126
- const tag = String(event.target?.tagName ?? '').toLowerCase()
1127
- if (tag === 'input' || tag === 'select' || tag === 'textarea') return
1128
- if (!sorted || !sorted.length) return
1129
- event.preventDefault()
1130
- const at = sorted.findIndex((item) => item.revision === selected[0]?.revision)
1131
- const next = at < 0 ? 0 : Math.min(sorted.length - 1, Math.max(0, at + (event.key === 'ArrowDown' ? 1 : -1)))
1132
- const hit = sorted[next]
1133
- setSelected([hit]) // ↑↓ 折叠为单选(官方同款),并重置 Shift 基准
1134
- setAnchorRev(hit.revision)
1135
- }
1136
- window.addEventListener('keydown', onKey, true)
1137
- return () => window.removeEventListener('keydown', onKey, true)
1138
- }, [sorted, onRefresh, selected])
1100
+ /** @private @author ddj 2026年10月14号 @param event 日志浮窗内按键。其他窗口及输入控件保留自己的键盘行为。 */
1101
+ function onLogKey(event) {
1102
+ if (rangeOpen || menu || inputOwnsKey(event.target)) return
1103
+ if (event.key === 'F5') { event.preventDefault(); event.stopPropagation(); onRefresh(); return }
1104
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
1105
+ if (!sorted || !sorted.length) return
1106
+ event.preventDefault()
1107
+ event.stopPropagation()
1108
+ const at = sorted.findIndex((item) => item.revision === selected[0]?.revision)
1109
+ const next = at < 0 ? 0 : Math.min(sorted.length - 1, Math.max(0, at + (event.key === 'ArrowDown' ? 1 : -1)))
1110
+ const hit = sorted[next]
1111
+ setSelected([hit])
1112
+ setAnchorRev(hit.revision)
1113
+ }
1139
1114
 
1140
1115
  /** 复制当前选中修订(P0-9;剪贴板不可用静默忽略)。 */
1141
1116
  const copyRevision = (entry) => {
@@ -1186,6 +1161,15 @@ export function SvnLogDialog(props) {
1186
1161
  })
1187
1162
  }
1188
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
+
1189
1173
  /** 下窗格路径行右键菜单(P0-8;G8 目录不可开差异,G9 仓库外不可打开)。 */
1190
1174
  const onPathMenu = (event, item, revision) => {
1191
1175
  event.preventDefault()
@@ -1208,9 +1192,9 @@ export function SvnLogDialog(props) {
1208
1192
  // P0-14 上中下三段全宽:上=修订列表(高可拖)/ 中=提交信息(高可拖)/ 下=变更路径(占余)
1209
1193
  const body = React.createElement('div', { className: 'edrv-svnlog-body', ref: bodyRef },
1210
1194
  React.createElement(LogList, {
1211
- list: sorted, selected: visibleSelected, keyword: query.trim(), busy, truncated,
1195
+ list: sorted, selectedRevs, keyword: query.trim(), busy, truncated,
1212
1196
  sortKey, sortDir, onSort, onSortReset,
1213
- onPick: pickWithMods, onLoadMore, onItemMenu,
1197
+ onPick: pickStable, onLoadMore, onItemMenu: menuStable,
1214
1198
  height: listH, wcRev,
1215
1199
  }),
1216
1200
  React.createElement(Splitter, {
@@ -1224,16 +1208,10 @@ export function SvnLogDialog(props) {
1224
1208
  React.createElement('div', { className: 'edrv-svnlog-pane edrv-svnlog-pathspane', ref: paneRef },
1225
1209
  React.createElement(LogPathsInner, { selected: visibleSelected, target, onlyAffected, onOpenFile, onOpenDiff, onPathMenu })))
1226
1210
 
1227
- return React.createElement(ModalShell, {
1228
- dialogClass: 'edrv-svnlog-dialog',
1229
- width: Math.round(dlgW) + 'px',
1230
- // 八向锚定(增量3):anchor 非空时 absolute 定位于视口坐标(脱离 flex 居中,视觉零跳变);
1231
- // border-box 令 rect==state(clamp/拖拽零换算);maxHeight:none 消除 88vh 造成 state≠视觉的漂移。
1232
- cardStyle: Object.assign(
1233
- { height: Math.round(dlgH) + 'px', boxSizing: 'border-box', maxHeight: 'none' },
1234
- anchor ? { position: 'absolute', left: Math.round(anchor.left) + 'px', top: Math.round(anchor.top) + 'px' } : null,
1235
- ),
1236
- onClose,
1211
+ return React.createElement(FloatingWindow, {
1212
+ title: 'SVN 日志', dialogClass: 'edrv-svnlog-dialog',
1213
+ width: DIALOG_W_DEFAULT, height: DIALOG_H_DEFAULT,
1214
+ onKeyDown: onLogKey, onClose,
1237
1215
  },
1238
1216
  React.createElement(LogHead, { target, onClose }),
1239
1217
  React.createElement(LogFilterBar, {
@@ -1249,15 +1227,6 @@ export function SvnLogDialog(props) {
1249
1227
  onExportCsv: () => onExport('csv'), onExportHtml: () => onExport('html'),
1250
1228
  rangeActive: rangeActive === true, onOpenRange: () => setRangeOpen(true), onRangeReset: () => onRangeReset?.(),
1251
1229
  }),
1252
- // 八向手柄(增量3):四边先渲染、四角后渲染(角盖过相邻边 2px overlap);双击复位统一走 resetDlgSize
1253
- React.createElement(Grip, { mode: 'n', onDragStart: startResize, onReset: resetDlgSize }),
1254
- React.createElement(Grip, { mode: 's', onDragStart: startResize, onReset: resetDlgSize }),
1255
- React.createElement(Grip, { mode: 'w', onDragStart: startResize, onReset: resetDlgSize }),
1256
- React.createElement(Grip, { mode: 'e', onDragStart: startResize, onReset: resetDlgSize }),
1257
- React.createElement(Grip, { mode: 'nw', onDragStart: startResize, onReset: resetDlgSize }),
1258
- React.createElement(Grip, { mode: 'ne', onDragStart: startResize, onReset: resetDlgSize }),
1259
- React.createElement(Grip, { mode: 'sw', onDragStart: startResize, onReset: resetDlgSize }),
1260
- React.createElement(Grip, { mode: 'se', onDragStart: startResize, onReset: resetDlgSize }),
1261
1230
  (menu ? React.createElement(ContextMenu, { x: menu.x, y: menu.y, entries: menu.entries, onClose: () => setMenu(null) }) : null),
1262
1231
  (statsOpen ? React.createElement(SvnLogStats, { key: 'edrv-svnlog-stats', entries: sorted, onClose: () => setStatsOpen(false) }) : null),
1263
1232
  (rangeOpen ? React.createElement(ModalShell, {
@@ -10,7 +10,7 @@
10
10
  * 作者 ddj 2026年09月17号
11
11
  */
12
12
  import React from 'react'
13
- import { ModalShell } from './ModalShell.js'
13
+ import { FloatingWindow } from './FloatingWindow.js'
14
14
 
15
15
  /** stacked 时间线展开的作者上限(其余并入「其他」)。 */
16
16
  const STACK_TOP = 5
@@ -205,11 +205,10 @@ export function SvnLogStats(props) {
205
205
  const [othersPct, setOthersPct] = React.useState(0)
206
206
  const [stacked, setStacked] = React.useState(false)
207
207
  const stats = React.useMemo(() => svnLogStatsOf(entries, { caseInsensitive: ci }), [entries, ci])
208
- return React.createElement(ModalShell, {
208
+ return React.createElement(FloatingWindow, {
209
+ title: 'SVN 日志统计', height: 600,
209
210
  dialogClass: 'edrv-svnlog-stats',
210
- width: 'min(680px, calc(100vw - 40px))',
211
- zIndex: 192,
212
- closeOnEsc: false,
211
+ width: 680,
213
212
  onClose,
214
213
  },
215
214
  React.createElement('div', { className: 'edrv-svnlog-stats-head' },
@@ -9,7 +9,7 @@
9
9
  * 作者 ddj 2026年09月20号
10
10
  */
11
11
  import React from 'react'
12
- import { ModalShell } from './ModalShell.js'
12
+ import { FloatingWindow } from './FloatingWindow.js'
13
13
  import { downloadText } from './svnExport.js'
14
14
 
15
15
  /**
@@ -40,10 +40,11 @@ export function SvnPatchDialog(props) {
40
40
  onNote?.(done ? '已保存 ' + fileName : '保存失败:浏览器下载能力不可用')
41
41
  }
42
42
 
43
- return React.createElement(ModalShell, {
43
+ return React.createElement(FloatingWindow, {
44
+ title: 'SVN 补丁预览', height: 560,
44
45
  key: 'edrv-svn-patch',
45
46
  dialogClass: 'edrv-svn-patch-dialog',
46
- width: 'min(760px, calc(100vw - 48px))',
47
+ width: 760,
47
48
  onClose,
48
49
  },
49
50
  React.createElement('div', { className: 'edrv-svn-patch-head' },
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import React from 'react'
11
11
  import { SVN_SUM_LETTER } from '../../shared/svn.js'
12
- import { ModalShell } from './ModalShell.js'
12
+ import { FloatingWindow } from './FloatingWindow.js'
13
13
  import { svnDiffSum } from '../svnStatus.js'
14
14
 
15
15
  /**
@@ -53,10 +53,11 @@ export function SvnSumDialog(props) {
53
53
  (entry.kind === 'dir' ? React.createElement('span', { className: 'edrv-svnlog-path-kind' }, '目录') : null)))
54
54
  }
55
55
 
56
- return React.createElement(ModalShell, {
56
+ return React.createElement(FloatingWindow, {
57
+ title: 'SVN 目录对比', height: 560,
57
58
  key: 'edrv-svn-sum',
58
59
  dialogClass: 'edrv-svn-sum-dialog',
59
- width: 'min(680px, calc(100vw - 48px))',
60
+ width: 680,
60
61
  onClose,
61
62
  },
62
63
  React.createElement('div', { className: 'edrv-svn-patch-head' },
@@ -9,7 +9,7 @@ import { rpc } from '../rpc.js'
9
9
  import { absoluteOf } from '../tabActions.js'
10
10
  import { ContextMenu } from './ContextMenu.js'
11
11
  import type { ContextMenuEntry } from './ContextMenu.js'
12
- import { ModalShell } from './ModalShell.js'
12
+ import { FloatingWindow } from './FloatingWindow.js'
13
13
  import { nextConflict, nextUpdateSort, rowConflict, sortUpdateRows, updateActs, updateCloseMode, updateDone, updateLocked, updateParent, updateRange, updateReady, updateType } from './svnUpdateModel.js'
14
14
  import type { UpdateColumn, UpdateSort } from './svnUpdateModel.js'
15
15
 
@@ -564,10 +564,9 @@ export function SvnUpdateDialog(props: UpdateProps): React.ReactElement {
564
564
  const phase = pendingRows && processFinished ? '正在加载更新明细…'
565
565
  : state?.phase === 'completed' ? '更新完成' : state?.phase === 'failed' ? '更新失败'
566
566
  : state?.phase === 'cancelled' ? '更新已停止' : state?.phase === 'cancelling' ? '正在停止…' : '正在更新…'
567
- return React.createElement(ModalShell, {
568
- dialogClass: 'edrv-update-dialog', width: 'min(1320px, calc(100vw - 48px))',
569
- cardStyle: { height: 'min(680px, calc(100vh - 48px))', boxSizing: 'border-box', maxHeight: 'none' },
570
- closeOnEsc: closeMode.passive, closeOnMask: closeMode.passive, onClose: dismiss,
567
+ return React.createElement(FloatingWindow, {
568
+ title: 'SVN 更新', dialogClass: 'edrv-update-dialog', width: 1320, height: 680,
569
+ closeOnEsc: closeMode.passive, onClose: dismiss,
571
570
  },
572
571
  updateHead(props, state, phase, closable, dismiss),
573
572
  updateTable(props, displayRows, view, selection, finished, sort, onSort, evidence.states),
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
  },