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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.15.3",
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()
@@ -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 数组开头)',
@@ -4367,12 +4344,12 @@ export function EditorView(props) {
4367
4344
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
4368
4345
  React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
4369
4346
  : null
4370
- 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')
4371
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' } },
4372
4349
  React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
4373
4350
  renderTree(workbench.tree), fullView,
4374
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))),
4375
- dragPreview ? createPortal(React.createElement('div', { 'data-edrv-view': '1', style: { display: 'contents', pointerEvents: 'none' } },
4352
+ dragPreview ? createPortal(React.createElement(React.Fragment, null,
4376
4353
  dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
4377
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)
4378
4355
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
@@ -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
  }