dsh-vscode-mode 0.15.2 → 0.15.4

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.2",
3
+ "version": "0.15.4",
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": [
@@ -15,6 +15,8 @@ import { workspaceScopeOf } from '../../state/scopeStore.js'
15
15
  import { takeSearchSeed, takeSearchScope } from '../../searchSeed.js'
16
16
 
17
17
  const DEBOUNCE_MS = 250
18
+ /** 文件变更后的重搜去抖:合并一次写盘的 refresh+file-changed 双事件与批量保存。 */
19
+ const REFRESH_DEBOUNCE_MS = 300
18
20
  const INCLUDE_PLACEHOLDER = '例如 *.ts, src/**/include'
19
21
 
20
22
  /**
@@ -56,6 +58,30 @@ export function savedSearchOf(key: string) {
56
58
  }
57
59
  }
58
60
 
61
+ /**
62
+ * 判断文件变化通知是否属于本面板作用域。
63
+ * @author ddj 2026年10月14号
64
+ * @param detail 事件 detail(scope 可缺省)
65
+ * @param scope 本面板作用域
66
+ * @returns 是否触发重搜
67
+ */
68
+ export function noticeInScope(detail: any, scope: string): boolean {
69
+ const changed = detail?.scope
70
+ return !changed || changed === scope
71
+ }
72
+
73
+ /**
74
+ * 判断文件变化通知是否可能改变内容搜索命中。
75
+ * 文件增删改名(structure)会增删命中,一并重搜;SVN 状态刷新(svn)不代表内容变化,跳过以免无谓全库扫描。
76
+ * @author ddj 2026年10月14号
77
+ * @param detail 事件 detail(kind 可缺省)
78
+ * @returns 是否触发重搜
79
+ */
80
+ export function noticeTouchesContent(detail: any): boolean {
81
+ const kind = detail?.kind
82
+ return !kind || kind === 'text' || kind === 'structure'
83
+ }
84
+
59
85
  /**
60
86
  * 命中行文本三段切分(before/命中/after,列为 1-based UTF-16)。
61
87
  * @param text 行文本
@@ -103,6 +129,8 @@ export function SearchPanel(props) {
103
129
  queryRef.current = query
104
130
  const activePathRef = React.useRef(activePath)
105
131
  activePathRef.current = activePath
132
+ // 文件变化后的重搜定时器(与输入防抖分开:两者互不取消对方)
133
+ const refreshTimerRef = React.useRef(null)
106
134
  // 请求参数 ref:防抖/立即重搜闭包统一读最新值,避免陈旧选项
107
135
  const requestRef = React.useRef({ matchCase, wholeWord, regex, include: [], exclude: [] })
108
136
  requestRef.current = {
@@ -152,9 +180,11 @@ export function SearchPanel(props) {
152
180
 
153
181
  /**
154
182
  * 执行内容搜索(请求参数从 ref 读最新值)。
183
+ * @author ddj 2026年10月14号
155
184
  * @param text 搜索词
185
+ * @param force 是否强制绕过 host 短期缓存(文件变更后的重搜必须读最新磁盘内容)
156
186
  */
157
- const runSearch = (text) => {
187
+ const runSearch = (text, force) => {
158
188
  const s = ++seqRef.current
159
189
  const clean = String(text ?? '').trim()
160
190
  if (clean.length < 2) { setMatches(null); setStatus('idle'); return }
@@ -174,6 +204,8 @@ export function SearchPanel(props) {
174
204
  sessionId, query: clean,
175
205
  matchCase: req.matchCase, wholeWord: req.wholeWord, regex: req.regex,
176
206
  include: req.include, exclude: req.exclude,
207
+ // 仅重搜路径带上 force:普通搜索的请求载荷与修复前保持完全一致。
208
+ ...(force === true ? { force: true } : {}),
177
209
  }).then((res) => {
178
210
  if (s !== seqRef.current) return
179
211
  if (res && res.ok) {
@@ -194,6 +226,10 @@ export function SearchPanel(props) {
194
226
  })
195
227
  }
196
228
 
229
+ // 事件监听闭包经 ref 取最新搜索实现与最新查询词(事件注册只做一次)
230
+ const runSearchRef = React.useRef(runSearch)
231
+ runSearchRef.current = runSearch
232
+
197
233
  /**
198
234
  * 输入变化:防抖触发搜索。
199
235
  * @param value 新输入
@@ -303,6 +339,27 @@ export function SearchPanel(props) {
303
339
  return () => window.removeEventListener('edrv:search-focus', onFocus)
304
340
  }, [])
305
341
 
342
+ // 文件内容变化(保存/外部写盘/文件增删改名)→ 去抖强制重搜,避免面板残留已失效的旧命中。
343
+ // 两种事件都监听:编辑器内保存只发 edrv:refresh,外部写盘只发 edrv:file-changed。
344
+ React.useEffect(() => {
345
+ /** @private @author ddj 2026年10月14号 @description 合并同一次写盘的多条通知后强制重搜 */
346
+ const onChanged = (event) => {
347
+ const detail = event?.detail ?? {}
348
+ if (!noticeInScope(detail, scope)) return
349
+ if (!noticeTouchesContent(detail)) return
350
+ if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
351
+ refreshTimerRef.current = setTimeout(() => runSearchRef.current(queryRef.current, true), REFRESH_DEBOUNCE_MS)
352
+ }
353
+ window.addEventListener('edrv:file-changed', onChanged)
354
+ window.addEventListener('edrv:refresh', onChanged)
355
+ return () => {
356
+ window.removeEventListener('edrv:file-changed', onChanged)
357
+ window.removeEventListener('edrv:refresh', onChanged)
358
+ if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null }
359
+ }
360
+ // eslint-disable-next-line react-hooks/exhaustive-deps
361
+ }, [])
362
+
306
363
  // 一次意图对应一次提交后选区;无需定时回调,卸载后不会抢焦点。
307
364
  React.useLayoutEffect(() => {
308
365
  if (!focusTick) return
@@ -0,0 +1,50 @@
1
+ /** Shared nonmodal geometry and bounded stacking. @author ddj 2026年10月14号 */
2
+ import { clampFloat } from './editorLayout.js'
3
+ import type { FloatRect } from './editorLayout.js'
4
+
5
+ const windows: string[] = []
6
+ const listeners = new Set<() => void>()
7
+ let version = 0
8
+
9
+ /** @private @author ddj 2026年10月14号 Notify every mounted window after an order change. */
10
+ function notify(): void { version += 1; for (const listener of listeners) listener() }
11
+ /** @public @author ddj 2026年10月14号 @param listener Order observer. @returns Unsubscribe callback. */
12
+ export function watchFloats(listener: () => void): () => void {
13
+ listeners.add(listener)
14
+ return () => { listeners.delete(listener) }
15
+ }
16
+ /** @public @author ddj 2026年10月14号 @returns Stable external-store revision. */
17
+ export function floatVersion(): number { return version }
18
+ /** @public @author ddj 2026年10月14号 @param id Window identity. Bring forward without exceeding modal layers. */
19
+ export function raiseFloat(id: string): void {
20
+ const index = windows.indexOf(id)
21
+ if (index === windows.length - 1 && index >= 0) return
22
+ if (index >= 0) windows.splice(index, 1)
23
+ windows.push(id)
24
+ notify()
25
+ }
26
+ /** @public @author ddj 2026年10月14号 @param id Unmounted window identity. */
27
+ export function dropFloat(id: string): void {
28
+ const index = windows.indexOf(id)
29
+ if (index < 0) return
30
+ windows.splice(index, 1)
31
+ notify()
32
+ }
33
+ /** @public @author ddj 2026年10月14号 @param id Window identity. @returns Layer below menus/modals (190+). */
34
+ export function floatLayer(id: string): number {
35
+ return 60 + Math.floor(120 * Math.max(0, windows.indexOf(id)) / Math.max(1, windows.length))
36
+ }
37
+ /** @public @author ddj 2026年10月14号 @param width Preferred width. @param height Preferred height. @param vw Viewport width. @param vh Viewport height. @returns Centered, reachable bounds. */
38
+ export function initialFloat(width: number, height: number, vw: number, vh: number): FloatRect {
39
+ return clampFloat({ width, height, left: (vw - width) / 2, top: (vh - height) / 2 }, vw, vh)
40
+ }
41
+ /** @public @author ddj 2026年10月14号 @param rect Gesture origin. @param dx Horizontal delta. @param dy Vertical delta. @param sizing Resize instead of move. @param vw Viewport width. @param vh Viewport height. @returns Clamped next bounds. */
42
+ export function moveFloat(rect: FloatRect, dx: number, dy: number, sizing: boolean, vw: number, vh: number): FloatRect {
43
+ const next = sizing ? { ...rect, width: rect.width + dx, height: rect.height + dy }
44
+ : { ...rect, left: rect.left + dx, top: rect.top + dy }
45
+ return clampFloat(next, vw, vh)
46
+ }
47
+ /** @public @author ddj 2026年10月14号 @param target Key target. @returns Whether native text/control navigation owns the key. */
48
+ export function inputOwnsKey(target: EventTarget | null): boolean {
49
+ return target instanceof Element && !!target.closest('input, textarea, select, [contenteditable="true"], .monaco-editor')
50
+ }
@@ -994,7 +994,20 @@
994
994
  [data-edrv-view] .edrv-split-handle:hover, [data-edrv-view] .edrv-split-handle:focus-visible { background: var(--dsw-alias-state-info-primary, #4d6bfe); }
995
995
  [data-edrv-view].edrv-floating { position: fixed; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2, #cdd6da); background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #1f2933); box-shadow: 0 8px 28px rgba(0,0,0,.22); }
996
996
  [data-edrv-view] .edrv-floating-title { display: flex; align-items: center; gap: 8px; flex: 0 0 32px; padding: 0 8px; background: var(--dsw-alias-bg-layer-1, #f7f9fa); cursor: move; touch-action: none; user-select: none; }
997
- [data-edrv-view] .edrv-floating-title > span { flex: 1; }
997
+ [data-edrv-view] .edrv-floating-title > span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
998
+ [data-edrv-view] .edrv-floating-content { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; overflow: auto; box-sizing: border-box; }
999
+ [data-edrv-view] .edrv-floating-content.edrv-svnlog-dialog { padding: 12px; overflow: auto; }
1000
+ [data-edrv-view] .edrv-floating-content.edrv-svn-patch-dialog,
1001
+ [data-edrv-view] .edrv-floating-content.edrv-svn-sum-dialog,
1002
+ [data-edrv-view] .edrv-floating-content.edrv-svnplan-dialog { padding: 12px; max-width: none; gap: 8px; }
1003
+ [data-edrv-view] .edrv-floating-content.edrv-svnlog-stats { max-height: none; }
1004
+ [data-edrv-view] .edrv-floating-content .edrv-svn-patch-pre,
1005
+ [data-edrv-view] .edrv-floating-content .edrv-svn-sum-list { flex: 1; min-height: 0; max-height: none; }
1006
+ [data-edrv-view] .edrv-floating-content .edrv-svnlog-filter,
1007
+ [data-edrv-view] .edrv-floating-content .edrv-svn-sum-form { flex-wrap: wrap; }
1008
+ [data-edrv-view] .edrv-floating-content > .edrv-svnlog-head,
1009
+ [data-edrv-view] .edrv-floating-content > .edrv-svnlog-filter,
1010
+ [data-edrv-view] .edrv-floating-content > .edrv-svnlog-foot { flex-shrink: 0; }
998
1011
  [data-edrv-view] .edrv-floating-title > button { border: 0; background: transparent; color: inherit; cursor: pointer; }
999
1012
  [data-edrv-view] .edrv-floating-grip { position: absolute; width: 16px; height: 16px; bottom: 0; right: 0; cursor: nwse-resize; touch-action: none; border-right: 3px solid var(--dsw-alias-border-l2, #cdd6da); border-bottom: 3px solid var(--dsw-alias-border-l2, #cdd6da); }
1000
1013
  .edrv-drag-overlay { position: fixed; z-index: 150; pointer-events: none; box-sizing: border-box; border: 0; background: color-mix(in srgb, var(--dsw-alias-state-info-primary, #4d6bfe) 25%, transparent); }
@@ -11,6 +11,7 @@ import React from 'react'
11
11
  import { createPortal } from 'react-dom'
12
12
  import { EditorPane, EditorSplit, PassiveEditor } from './EditorPane.js'
13
13
  import { FloatingEditor } from './FloatingEditor.js'
14
+ import { FloatingWindow } from './FloatingWindow.js'
14
15
  import { createLayout, parseLayout, syncTabs, ownerOf, focusGroup, moveTab, returnGroup, GROUP_LIMIT, MAIN_GROUP } from '../state/editorLayout.js'
15
16
  import { createTabDrag, hitTarget } from '../tabDrag.js'
16
17
  import { createDocStore } from '../state/documentStore.js'
@@ -3505,8 +3506,7 @@ export function EditorView(props) {
3505
3506
  //(focusDiff=true 跳过:差异审查对象必为本插件编辑的文本文件,审查优先);
3506
3507
  // handler 缺失/失败(旧版无 openResource / seat 未挂)→ 回退现状页签打开,不阻断
3507
3508
  if (!focusDiff && inNativePath(path) && tryNativeOpen(path)) return
3508
- // 打开文件即离开基线差异审阅态(差异视图是「当前文件」的临时视图)
3509
- setSvnDiff(null)
3509
+ // 对比浮窗独立保留,允许同时打开并编辑其他文件。
3510
3510
  recordNav()
3511
3511
  // G9:统一归一为页签规范形态(差异栏/启动器/树/命令栏等入口一致)
3512
3512
  const normalized = addTabNorm(path, true)
@@ -3555,7 +3555,6 @@ export function EditorView(props) {
3555
3555
  if (!document) return
3556
3556
  flushSave()
3557
3557
  saveViewState(active)
3558
- setSvnDiff(null)
3559
3558
  setDiskActive(null)
3560
3559
  setPlanTabs((prev) => [...prev.filter((item) => item.address !== document.address), document].slice(-32))
3561
3560
  setPlanActive(document.address)
@@ -3578,7 +3577,6 @@ export function EditorView(props) {
3578
3577
  flushSave()
3579
3578
  saveViewState(active)
3580
3579
  recordNav()
3581
- setSvnDiff(null)
3582
3580
  const positioned = request.line != null || request.column != null
3583
3581
  if (positioned && mdPreviewRef.current.has(path)) toggleMdPreview(path)
3584
3582
  setError(null)
@@ -3611,7 +3609,7 @@ export function EditorView(props) {
3611
3609
  const image = imgElRef.current
3612
3610
  return {
3613
3611
  sessionId, cwd, active, sequence: loadSeqRef.current, load: receiptLoad,
3614
- mounted: Boolean(viewRootRef.current), blocked: Boolean(svnDiff),
3612
+ mounted: Boolean(viewRootRef.current), blocked: false,
3615
3613
  kind: isImageActive ? 'image' : isPdfActive ? 'pdf' : mdPreviewing ? 'markdown' : 'text',
3616
3614
  contentPath, contentReady, modelMatches, positionMatches,
3617
3615
  imageLoaded: Boolean(imageSrc && !imgBroken && image?.getAttribute('src') === imageSrc && image.complete && image.naturalWidth > 0),
@@ -3624,7 +3622,7 @@ export function EditorView(props) {
3624
3622
  function receiptReady(request) {
3625
3623
  if (request.plan) return request.sessionId === sessionId && planActive === request.plan.address
3626
3624
  && planTabs.some((item) => item.address === request.plan.address && item.markdown === request.plan.markdown)
3627
- && planHostRef.current?.getAttribute('data-plan-address') === request.plan.address && !svnDiff
3625
+ && planHostRef.current?.getAttribute('data-plan-address') === request.plan.address
3628
3626
  return receiptReadyFor(request, receiptView(request))
3629
3627
  }
3630
3628
  /** @private @author ddj 2026年09月28号 @param request Claimed target. @returns Only its current load/render error. */
@@ -4013,27 +4011,6 @@ export function EditorView(props) {
4013
4011
  : React.createElement('pre', { className: 'edrv-mdview-pre' }, text)
4014
4012
  body = React.createElement('div', { className: 'edrv-mdview', ref: planHostRef, 'data-plan-address': activePlan.address },
4015
4013
  React.createElement('div', { className: 'edrv-mdview-stage' }, React.createElement('div', { className: 'edrv-mdview-doc' }, render)))
4016
- } else if (svnDiff) {
4017
- // 差异视图优先占位(覆盖编辑器主体):只读审阅态,关闭后回到原编辑器
4018
- body = React.createElement(SvnDiffPanel, {
4019
- key: 'edrv-svndiff:' + svnDiff.scheme + ':' + svnDiff.path,
4020
- monaco,
4021
- path: svnDiff.path,
4022
- base: svnDiff.base,
4023
- working: svnDiff.working,
4024
- reason: svnDiff.reason,
4025
- message: svnDiff.message,
4026
- leftLabel: svnDiff.leftLabel,
4027
- rightLabel: svnDiff.rightLabel,
4028
- onBack: svnDiff.logTarget !== undefined ? backToSvnLog : undefined,
4029
- onClose: () => {
4030
- setSvnDiff(null)
4031
- if (svnUpdateReturn?.from === 'diff') {
4032
- setSvnUpdateDialog(svnUpdateReturn.dialog)
4033
- setSvnUpdateReturn(null)
4034
- }
4035
- },
4036
- })
4037
4014
  } else if (!monaco && !monacoErr) {
4038
4015
  body = loadingBody(loadStage.message, loadStage.progress)
4039
4016
  } else if (monacoErr) {
@@ -4329,7 +4306,7 @@ export function EditorView(props) {
4329
4306
  }
4330
4307
 
4331
4308
  // 「添加配置」按钮(VSCode 同款形态,编辑区右下角):仅插件专属 launch.json 打开且非差异审阅态显示
4332
- const addCfgBtn = (active && !svnDiff && LAUNCH_JSON_RE.test(String(active).replace(/\\/g, '/')))
4309
+ const addCfgBtn = (active && LAUNCH_JSON_RE.test(String(active).replace(/\\/g, '/')))
4333
4310
  ? React.createElement('button', {
4334
4311
  className: 'edrv-addcfg',
4335
4312
  title: '添加调试配置(插入到 configurations 数组开头)',
@@ -4517,6 +4494,23 @@ export function EditorView(props) {
4517
4494
  },
4518
4495
  })
4519
4496
  : null
4497
+ /** @private @author ddj 2026年10月14号 关闭对比浮窗,按原 job 恢复来源更新窗口。 */
4498
+ function closeSvnDiff() {
4499
+ setSvnDiff(null)
4500
+ if (svnUpdateReturn?.from === 'diff') {
4501
+ setSvnUpdateDialog(svnUpdateReturn.dialog)
4502
+ setSvnUpdateReturn(null)
4503
+ }
4504
+ }
4505
+ const svnDiffEl = svnDiff ? React.createElement(FloatingWindow, {
4506
+ key: 'edrv-svndiff-window:' + scope,
4507
+ title: '文件对比 · ' + baseNameOf(svnDiff.path), width: 1100, height: 680,
4508
+ owner: String(scope), onClose: closeSvnDiff,
4509
+ }, React.createElement(SvnDiffPanel, {
4510
+ ...svnDiff, key: 'edrv-svndiff:' + svnDiff.scheme + ':' + svnDiff.path, monaco,
4511
+ onBack: svnDiff.logTarget !== undefined ? backToSvnLog : undefined,
4512
+ onClose: closeSvnDiff,
4513
+ })) : null
4520
4514
  // W2-1 补丁对话框 / W2-2 目录对比对话框:portal 到 body,与日志弹窗同一挂载方式
4521
4515
  const patchDialogEl = patchDialog
4522
4516
  ? React.createElement(SvnPatchDialog, {
@@ -4613,6 +4607,7 @@ export function EditorView(props) {
4613
4607
  paletteEl,
4614
4608
  svnLogEl,
4615
4609
  svnUpdateEl,
4610
+ svnDiffEl,
4616
4611
  patchDialogEl,
4617
4612
  sumDialogEl,
4618
4613
  dlogEl,
@@ -4625,6 +4620,7 @@ export function EditorView(props) {
4625
4620
  paletteEl,
4626
4621
  svnLogEl,
4627
4622
  svnUpdateEl,
4623
+ svnDiffEl,
4628
4624
  patchDialogEl,
4629
4625
  sumDialogEl,
4630
4626
  dlogEl,
@@ -1,7 +1,7 @@
1
- /** In-page window chrome; closing returns tabs, never closes documents. @author ddj 2026年10月14号 */
1
+ /** In-page editor window; closing returns tabs, never closes documents. @author ddj 2026年10月14号 */
2
2
  import React from 'react'
3
- import { createPortal } from 'react-dom'
4
- import { clampFloat, type FloatRect } from '../state/editorLayout.js'
3
+ import type { FloatRect } from '../state/editorLayout.js'
4
+ import { FloatingWindow } from './FloatingWindow.js'
5
5
 
6
6
  export interface FloatingProps {
7
7
  group: string
@@ -15,73 +15,19 @@ export interface FloatingProps {
15
15
  children?: React.ReactNode
16
16
  }
17
17
 
18
- /** @public @author ddj 2026年10月14号 @param props Window geometry and group callbacks. @returns Theme-aware portal with move/resize handles. */
19
- export function FloatingEditor(props: FloatingProps): React.ReactPortal {
20
- const latest = React.useRef(props)
21
- latest.current = props
22
- const cleanup = React.useRef<(() => void) | null>(null)
23
- /** @private @author ddj 2026年10月14号 Cancel a gesture on unmount without altering tabs. */
24
- function cancel(): void { cleanup.current?.(); cleanup.current = null }
25
- React.useEffect(() => cancel, [])
26
- React.useEffect(() => {
27
- /** @private @author ddj 2026年10月14号 Keep title/chrome visible after viewport changes. */
28
- function resize(): void {
29
- const current = latest.current
30
- current.onBounds(current.group, clampFloat(current.rect, window.innerWidth, window.innerHeight))
31
- }
32
- window.addEventListener('resize', resize)
33
- resize()
34
- return () => window.removeEventListener('resize', resize)
35
- }, [])
36
-
37
- /** @private @author ddj 2026年10月14号 @param event Handle press. @param sizing Resize rather than move. */
38
- function start(event: React.PointerEvent, sizing: boolean): void {
39
- if (event.button !== 0 || !event.isPrimary) return
40
- cancel()
41
- event.preventDefault()
42
- const initial = latest.current.rect
43
- const x = event.clientX, y = event.clientY, pointer = event.pointerId
44
- let frame: number | null = null
45
- let next = initial
46
- latest.current.onFocus(props.group)
47
- /** @private @author ddj 2026年10月14号 Apply at most one geometry change per animation frame. */
48
- function paint(): void { frame = null; latest.current.onBounds(props.group, next) }
49
- /** @private @author ddj 2026年10月14号 @param moveEvent Pointer movement for the originating pointer. */
50
- function move(moveEvent: PointerEvent): void {
51
- if (moveEvent.pointerId !== pointer) return
52
- moveEvent.preventDefault()
53
- const dx = moveEvent.clientX - x, dy = moveEvent.clientY - y
54
- next = clampFloat(sizing ? { ...initial, width: initial.width + dx, height: initial.height + dy } : { ...initial, left: initial.left + dx, top: initial.top + dy }, window.innerWidth, window.innerHeight)
55
- if (frame === null) frame = requestAnimationFrame(paint)
56
- }
57
- /** @private @author ddj 2026年10月14号 Remove gesture listeners and pending frames. */
58
- function stop(): void {
59
- if (frame !== null) { cancelAnimationFrame(frame); paint() }
60
- window.removeEventListener('pointermove', move, true)
61
- window.removeEventListener('pointerup', stop, true)
62
- window.removeEventListener('pointercancel', stop, true)
63
- window.removeEventListener('blur', stop)
64
- cleanup.current = null
65
- }
66
- cleanup.current = stop
67
- window.addEventListener('pointermove', move, true)
68
- window.addEventListener('pointerup', stop, true)
69
- window.addEventListener('pointercancel', stop, true)
70
- window.addEventListener('blur', stop)
71
- }
72
- /** @private @author ddj 2026年10月14号 @param event Title press. Move window without stealing button presses. */
73
- function moveStart(event: React.PointerEvent): void { if (!(event.target as Element).closest('button')) start(event, false) }
74
- /** @private @author ddj 2026年10月14号 @param event Resize grip press. */
75
- function sizeStart(event: React.PointerEvent): void { start(event, true) }
18
+ /** @public @author ddj 2026年10月14号 @param props Window geometry and group callbacks. @returns Shared window chrome preserving tab ownership. */
19
+ export function FloatingEditor(props: FloatingProps): React.ReactElement {
20
+ /** @private @author ddj 2026年10月14号 @param rect Updated window bounds. */
21
+ function bounds(rect: FloatRect): void { props.onBounds(props.group, rect) }
76
22
  /** @private @author ddj 2026年10月14号 Return all tabs to the main group. */
77
- function returnTabs(): void { latest.current.onReturn(props.group) }
23
+ function returnTabs(): void { props.onReturn(props.group) }
78
24
  /** @private @author ddj 2026年10月14号 Bring the group to the foreground. */
79
- function focus(): void { latest.current.onFocus(props.group) }
80
- const rect = clampFloat(props.rect, window.innerWidth, window.innerHeight)
81
- return createPortal(React.createElement('div', { className: 'edrv-floating' + (props.focused ? ' edrv-floating-focused' : ''), 'data-edrv-view': '1', 'data-edrv-float': props.group, 'data-edrv-owner': props.owner, style: { ...rect, zIndex: (props.focused ? 80 : 60) + props.layer }, onPointerDownCapture: focus },
82
- React.createElement('div', { className: 'edrv-floating-title', onPointerDown: moveStart },
83
- React.createElement('span', null, '浮动编辑组'), React.createElement('button', { type: 'button', title: '将全部文件移回主编辑区', onClick: returnTabs }, '移回主编辑区'),
84
- React.createElement('button', { type: 'button', title: '关闭浮窗并将文件移回主编辑区', 'aria-label': '关闭浮窗并移回文件', onClick: returnTabs }, '×')),
85
- props.children,
86
- React.createElement('div', { className: 'edrv-floating-grip', title: '拖动调整窗口大小', onPointerDown: sizeStart })), document.body)
25
+ function focus(): void { props.onFocus(props.group) }
26
+ const actions = React.createElement(React.Fragment, null,
27
+ React.createElement('button', { type: 'button', title: '将全部文件移回主编辑区', onClick: returnTabs }, '移回主编辑区'),
28
+ React.createElement('button', { type: 'button', title: '关闭浮窗并将文件移回主编辑区', 'aria-label': '关闭浮窗并移回文件', onClick: returnTabs }, '×'))
29
+ return React.createElement(FloatingWindow, {
30
+ title: '浮动编辑组', owner: props.owner, group: props.group, rect: props.rect,
31
+ closeOnEsc: false, onBounds: bounds, onFocus: focus, actions,
32
+ }, props.children)
87
33
  }
@@ -0,0 +1,121 @@
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 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 {
27
+ let frame: number | null = null
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) }
31
+ /** @private @author ddj 2026年10月14号 @param event Pointer motion. Ignore unrelated pointers. */
32
+ function move(event: PointerEvent): void {
33
+ if (event.pointerId !== start.pointerId) return
34
+ event.preventDefault()
35
+ next = moveFloat(rect, event.clientX - start.clientX, event.clientY - start.clientY, sizing, window.innerWidth, window.innerHeight)
36
+ if (frame === null) frame = requestAnimationFrame(paint)
37
+ }
38
+ /** @private @author ddj 2026年10月14号 Remove listeners and commit pending geometry. */
39
+ function stop(): void {
40
+ if (frame !== null) { cancelAnimationFrame(frame); paint() }
41
+ window.removeEventListener('pointermove', move, true)
42
+ window.removeEventListener('pointerup', end, true)
43
+ window.removeEventListener('pointercancel', end, true)
44
+ window.removeEventListener('blur', stop)
45
+ done()
46
+ }
47
+ /** @private @author ddj 2026年10月14号 @param event Pointer release. Only end originating gesture. */
48
+ function end(event: PointerEvent): void { if (event.pointerId === start.pointerId) stop() }
49
+ window.addEventListener('pointermove', move, true)
50
+ window.addEventListener('pointerup', end, true)
51
+ window.addEventListener('pointercancel', end, true)
52
+ window.addEventListener('blur', stop)
53
+ return stop
54
+ }
55
+
56
+ /** @public @author ddj 2026年10月14号 @param props Window contents and lifecycle callbacks. @returns Movable, resizable nonmodal portal. */
57
+ export function FloatingWindow(props: WindowProps): React.ReactPortal {
58
+ const id = React.useId()
59
+ const root = React.useRef<HTMLDivElement>(null)
60
+ const latest = React.useRef(props)
61
+ latest.current = props
62
+ const [bounds, setBounds] = React.useState(() => initialFloat(props.width ?? 760, props.height ?? 600, window.innerWidth, window.innerHeight))
63
+ const current = React.useRef(props.rect ?? bounds)
64
+ current.current = props.rect ?? bounds
65
+ const cleanup = React.useRef<(() => void) | null>(null)
66
+ React.useSyncExternalStore(watchFloats, floatVersion, floatVersion)
67
+ /** @private @author ddj 2026年10月14号 @param rect Next geometry. Notify controlled owner or retain local geometry. */
68
+ function apply(rect: FloatRect): void {
69
+ if (latest.current.rect) latest.current.onBounds?.(rect)
70
+ else setBounds(rect)
71
+ }
72
+ /** @private @author ddj 2026年10月14号 End any active gesture. */
73
+ 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)) }
76
+ React.useEffect(() => {
77
+ raiseFloat(id)
78
+ window.addEventListener('resize', resize)
79
+ resize()
80
+ return () => { cancel(); window.removeEventListener('resize', resize); dropFloat(id) }
81
+ }, [id])
82
+ /** @private @author ddj 2026年10月14号 Activate without stealing text input focus. */
83
+ function focus(): void { raiseFloat(id); latest.current.onFocus?.() }
84
+ /** @private @author ddj 2026年10月14号 @param event Window press. Give noninteractive content a keyboard owner. */
85
+ function activate(event: React.PointerEvent): void {
86
+ focus()
87
+ if (!(event.target as Element).closest('button, input, select, textarea, a, [contenteditable], .monaco-editor')) root.current?.focus({ preventScroll: true })
88
+ }
89
+ /** @private @author ddj 2026年10月14号 @param event Handle press. @param sizing Resize instead of move. */
90
+ function start(event: React.PointerEvent, sizing: boolean): void {
91
+ if (event.button !== 0 || !event.isPrimary) return
92
+ cancel()
93
+ event.preventDefault()
94
+ cleanup.current = beginGesture(event, current.current, sizing, apply, clear)
95
+ }
96
+ /** @private @author ddj 2026年10月14号 Clear completed gesture reference. */
97
+ function clear(): void { cleanup.current = null }
98
+ /** @private @author ddj 2026年10月14号 @param event Title press. Preserve title action buttons. */
99
+ function moveStart(event: React.PointerEvent): void { if (!(event.target as Element).closest('button')) start(event, false) }
100
+ /** @private @author ddj 2026年10月14号 @param event Resize press. */
101
+ function sizeStart(event: React.PointerEvent): void { start(event, true) }
102
+ /** @private @author ddj 2026年10月14号 @param event Local key. Exclude React-bubbling child portals. */
103
+ function keyDown(event: React.KeyboardEvent): void {
104
+ if ((event.target as Element).closest('[data-edrv-window]') !== root.current || event.defaultPrevented) return
105
+ if (event.key === 'Escape' && props.closeOnEsc !== false && props.onClose) {
106
+ event.preventDefault(); event.stopPropagation(); props.onClose(); return
107
+ }
108
+ props.onKeyDown?.(event)
109
+ }
110
+ const rect = clampFloat(props.rect ?? bounds, window.innerWidth, window.innerHeight)
111
+ return createPortal(React.createElement('div', {
112
+ ref: root, tabIndex: -1, role: 'dialog', 'aria-label': props.title, 'aria-modal': false,
113
+ className: 'edrv-floating', 'data-edrv-view': '1', 'data-edrv-window': id,
114
+ 'data-edrv-float': props.group, 'data-edrv-owner': props.owner,
115
+ style: { ...rect, zIndex: floatLayer(id) }, onPointerDownCapture: activate, onFocusCapture: focus, onKeyDown: keyDown,
116
+ },
117
+ React.createElement('div', { className: 'edrv-floating-title', onPointerDown: moveStart },
118
+ React.createElement('span', null, props.title), props.actions),
119
+ React.createElement('div', { className: 'edrv-floating-content' + (props.dialogClass ? ' ' + props.dialogClass : '') }, props.children),
120
+ React.createElement('div', { className: 'edrv-floating-grip', title: '拖动调整窗口大小', onPointerDown: sizeStart })), document.body)
121
+ }
@@ -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)