dsh-vscode-mode 0.15.4 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/lib/client.js +261 -99
- package/lib/client.js.map +1 -1
- package/lib/index.js +2 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/compatSummary.ts +77 -0
- package/src/client/externalOpen.ts +21 -3
- package/src/client/index.ts +14 -10
- package/src/client/monaco/lsp/lspClient.ts +26 -5
- package/src/client/monaco/lsp/providers.ts +3 -1
- package/src/client/ui/EditorView.ts +2 -2
- package/src/client/ui/FloatingWindow.ts +61 -14
- package/src/client/ui/McpSettings.ts +1 -1
- package/src/client/ui/SvnLogDialog.ts +67 -24
- package/src/svn.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.15.
|
|
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
|
-
*
|
|
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
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
// 击键路径传惰性取值:去抖窗口内的中间态会被覆盖丢弃,事先取全文纯属白付(大文件尤甚)。
|
|
99
|
+
// model 已销毁时 resolveText 返回 null 放弃本次同步,不会拿空内容覆盖 host 侧文档。
|
|
100
|
+
const contentDisposer = model.onDidChangeContent(() => syncDoc(path, () => model.getValue()))
|
|
99
101
|
const disposeDisposer = model.onWillDispose(() => {
|
|
100
102
|
closeDoc(path)
|
|
101
103
|
contentDisposer.dispose()
|
|
@@ -4344,12 +4344,12 @@ export function EditorView(props) {
|
|
|
4344
4344
|
React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
|
|
4345
4345
|
React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
|
|
4346
4346
|
: null
|
|
4347
|
-
const fullView = createPortal(React.createElement(
|
|
4347
|
+
const fullView = createPortal(React.createElement(React.Fragment, null, pathBar, sideHintEl, diskBanner(), editorArea, statusBar), fullHost, 'edrv-full-editor')
|
|
4348
4348
|
const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
|
|
4349
4349
|
React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
|
|
4350
4350
|
renderTree(workbench.tree), fullView,
|
|
4351
4351
|
workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
|
|
4352
|
-
dragPreview ? createPortal(React.createElement(
|
|
4352
|
+
dragPreview ? createPortal(React.createElement(React.Fragment, null,
|
|
4353
4353
|
dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
|
|
4354
4354
|
React.createElement('div', { className: 'edrv-drag-ghost', style: { left: dragPreview.point.x + 12, top: dragPreview.point.y + 12 } }, baseNameOf(dragPreview.path) + ' · ' + dragPreview.label)), document.body) : null)
|
|
4355
4355
|
// 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
|
|
@@ -22,26 +22,29 @@ export interface WindowProps {
|
|
|
22
22
|
children?: React.ReactNode
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** @private @author ddj 2026年10月14号 @param start Originating press. @param rect Initial bounds. @param sizing Resize mode. @param apply Commit bounds. @param done Gesture cleanup notification. @returns Gesture cancellation. */
|
|
26
|
-
function beginGesture(start: React.PointerEvent, rect: FloatRect, sizing: boolean, apply: (rect: FloatRect) => void, done: () => void): () => void {
|
|
25
|
+
/** @private @author ddj 2026年10月14号 @param start Originating press. @param rect Initial bounds. @param sizing Resize mode. @param draw Paint live bounds without a render. @param apply Commit bounds once. @param done Gesture cleanup notification. @returns Gesture cancellation. */
|
|
26
|
+
function beginGesture(start: React.PointerEvent, rect: FloatRect, sizing: boolean, draw: (rect: FloatRect) => void, apply: (rect: FloatRect) => void, done: () => void): () => void {
|
|
27
27
|
let frame: number | null = null
|
|
28
28
|
let next = rect
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
let moved = false
|
|
30
|
+
/** @private @author ddj 2026年10月14号 Paint no more than once per frame. */
|
|
31
|
+
function paint(): void { frame = null; draw(next) }
|
|
31
32
|
/** @private @author ddj 2026年10月14号 @param event Pointer motion. Ignore unrelated pointers. */
|
|
32
33
|
function move(event: PointerEvent): void {
|
|
33
34
|
if (event.pointerId !== start.pointerId) return
|
|
34
35
|
event.preventDefault()
|
|
36
|
+
moved = true
|
|
35
37
|
next = moveFloat(rect, event.clientX - start.clientX, event.clientY - start.clientY, sizing, window.innerWidth, window.innerHeight)
|
|
36
38
|
if (frame === null) frame = requestAnimationFrame(paint)
|
|
37
39
|
}
|
|
38
|
-
/** @private @author ddj 2026年10月14号 Remove listeners
|
|
40
|
+
/** @private @author ddj 2026年10月14号 Remove listeners, then commit the settled geometry exactly once. */
|
|
39
41
|
function stop(): void {
|
|
40
|
-
if (frame !== null) { cancelAnimationFrame(frame);
|
|
42
|
+
if (frame !== null) { cancelAnimationFrame(frame); frame = null }
|
|
41
43
|
window.removeEventListener('pointermove', move, true)
|
|
42
44
|
window.removeEventListener('pointerup', end, true)
|
|
43
45
|
window.removeEventListener('pointercancel', end, true)
|
|
44
46
|
window.removeEventListener('blur', stop)
|
|
47
|
+
if (moved) { draw(next); apply(next) }
|
|
45
48
|
done()
|
|
46
49
|
}
|
|
47
50
|
/** @private @author ddj 2026年10月14号 @param event Pointer release. Only end originating gesture. */
|
|
@@ -53,6 +56,31 @@ function beginGesture(start: React.PointerEvent, rect: FloatRect, sizing: boolea
|
|
|
53
56
|
return stop
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
const resizeSinks = new Set<() => void>()
|
|
60
|
+
let resizeFrame: number | null = null
|
|
61
|
+
|
|
62
|
+
/** @private @author ddj 2026年10月14号 Re-clamp every window at most once per frame. */
|
|
63
|
+
function onViewportResize(): void {
|
|
64
|
+
if (resizeFrame !== null) return
|
|
65
|
+
resizeFrame = requestAnimationFrame(() => {
|
|
66
|
+
resizeFrame = null
|
|
67
|
+
for (const sink of [...resizeSinks]) sink()
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @private @author ddj 2026年10月14号 @param sink Per-window re-clamp callback. @returns Unsubscribe. */
|
|
72
|
+
function subscribeResize(sink: () => void): () => void {
|
|
73
|
+
if (resizeSinks.size === 0) window.addEventListener('resize', onViewportResize)
|
|
74
|
+
resizeSinks.add(sink)
|
|
75
|
+
return () => {
|
|
76
|
+
resizeSinks.delete(sink)
|
|
77
|
+
if (resizeSinks.size === 0) {
|
|
78
|
+
window.removeEventListener('resize', onViewportResize)
|
|
79
|
+
if (resizeFrame !== null) { cancelAnimationFrame(resizeFrame); resizeFrame = null }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/** @public @author ddj 2026年10月14号 @param props Window contents and lifecycle callbacks. @returns Movable, resizable nonmodal portal. */
|
|
57
85
|
export function FloatingWindow(props: WindowProps): React.ReactPortal {
|
|
58
86
|
const id = React.useId()
|
|
@@ -63,21 +91,32 @@ export function FloatingWindow(props: WindowProps): React.ReactPortal {
|
|
|
63
91
|
const current = React.useRef(props.rect ?? bounds)
|
|
64
92
|
current.current = props.rect ?? bounds
|
|
65
93
|
const cleanup = React.useRef<(() => void) | null>(null)
|
|
94
|
+
const gesture = React.useRef<FloatRect | null>(null)
|
|
66
95
|
React.useSyncExternalStore(watchFloats, floatVersion, floatVersion)
|
|
67
96
|
/** @private @author ddj 2026年10月14号 @param rect Next geometry. Notify controlled owner or retain local geometry. */
|
|
68
97
|
function apply(rect: FloatRect): void {
|
|
69
98
|
if (latest.current.rect) latest.current.onBounds?.(rect)
|
|
70
99
|
else setBounds(rect)
|
|
71
100
|
}
|
|
101
|
+
/** @private @author ddj 2026年10月14号 @param rect Live gesture geometry. Write the DOM and hold it until the commit render lands. */
|
|
102
|
+
function draw(rect: FloatRect): void {
|
|
103
|
+
gesture.current = rect
|
|
104
|
+
const node = root.current
|
|
105
|
+
if (!node) return
|
|
106
|
+
node.style.left = rect.left + 'px'
|
|
107
|
+
node.style.top = rect.top + 'px'
|
|
108
|
+
node.style.width = rect.width + 'px'
|
|
109
|
+
node.style.height = rect.height + 'px'
|
|
110
|
+
}
|
|
72
111
|
/** @private @author ddj 2026年10月14号 End any active gesture. */
|
|
73
112
|
function cancel(): void { cleanup.current?.(); cleanup.current = null }
|
|
74
|
-
/** @private @author ddj 2026年10月14号 Re-clamp after viewport resize. */
|
|
75
|
-
function resize(): void { cancel(); apply(clampFloat(current.current, window.innerWidth, window.innerHeight)) }
|
|
113
|
+
/** @private @author ddj 2026年10月14号 Re-clamp after viewport resize, preferring the gesture that just settled. */
|
|
114
|
+
function resize(): void { cancel(); apply(clampFloat(gesture.current ?? current.current, window.innerWidth, window.innerHeight)) }
|
|
76
115
|
React.useEffect(() => {
|
|
77
116
|
raiseFloat(id)
|
|
78
|
-
|
|
117
|
+
const off = subscribeResize(resize)
|
|
79
118
|
resize()
|
|
80
|
-
return () => {
|
|
119
|
+
return () => { off(); cancel(); dropFloat(id) }
|
|
81
120
|
}, [id])
|
|
82
121
|
/** @private @author ddj 2026年10月14号 Activate without stealing text input focus. */
|
|
83
122
|
function focus(): void { raiseFloat(id); latest.current.onFocus?.() }
|
|
@@ -91,10 +130,17 @@ export function FloatingWindow(props: WindowProps): React.ReactPortal {
|
|
|
91
130
|
if (event.button !== 0 || !event.isPrimary) return
|
|
92
131
|
cancel()
|
|
93
132
|
event.preventDefault()
|
|
94
|
-
cleanup.current = beginGesture(event, current.current, sizing, apply, clear)
|
|
133
|
+
cleanup.current = beginGesture(event, gesture.current ?? current.current, sizing, draw, apply, clear)
|
|
134
|
+
}
|
|
135
|
+
// 手势几何的持有范围只到「提交渲染落地」为止:apply() 走的是 pointerup 等离散事件,
|
|
136
|
+
// React 18 会在事件末尾同步 flush,下一次渲染 base 已是新几何,故此处可同步释放。
|
|
137
|
+
// 勘误 2026-10-14(测试约定):原先额外挂一帧再释放,会在手势结束后残留一个待执行帧
|
|
138
|
+
// (tests/floatingWindow.test.ts 断言 frames.size === 0),且无必要。
|
|
139
|
+
/** @private @author ddj 2026年10月14号 Release the settled geometry once React has committed it. */
|
|
140
|
+
function clear(): void {
|
|
141
|
+
cleanup.current = null
|
|
142
|
+
gesture.current = null
|
|
95
143
|
}
|
|
96
|
-
/** @private @author ddj 2026年10月14号 Clear completed gesture reference. */
|
|
97
|
-
function clear(): void { cleanup.current = null }
|
|
98
144
|
/** @private @author ddj 2026年10月14号 @param event Title press. Preserve title action buttons. */
|
|
99
145
|
function moveStart(event: React.PointerEvent): void { if (!(event.target as Element).closest('button')) start(event, false) }
|
|
100
146
|
/** @private @author ddj 2026年10月14号 @param event Resize press. */
|
|
@@ -107,7 +153,8 @@ export function FloatingWindow(props: WindowProps): React.ReactPortal {
|
|
|
107
153
|
}
|
|
108
154
|
props.onKeyDown?.(event)
|
|
109
155
|
}
|
|
110
|
-
const
|
|
156
|
+
const base = clampFloat(props.rect ?? bounds, window.innerWidth, window.innerHeight)
|
|
157
|
+
const rect = gesture.current ?? base
|
|
111
158
|
return createPortal(React.createElement('div', {
|
|
112
159
|
ref: root, tabIndex: -1, role: 'dialog', 'aria-label': props.title, 'aria-modal': false,
|
|
113
160
|
className: 'edrv-floating', 'data-edrv-view': '1', 'data-edrv-window': id,
|
|
@@ -414,7 +414,7 @@ function CompatSection({ getSummary }) {
|
|
|
414
414
|
devForm?.enabled && group('开发形态', React.createElement('div', { className: 'vsm-compat-item ok' }, React.createElement('span', { className: 'vsm-compat-name' }, '开发形态开启(工作区链接安装)'), React.createElement('span', { className: 'vsm-compat-note' }, devForm.path))),
|
|
415
415
|
group('版本适配', renderItems(report?.adapters)),
|
|
416
416
|
group('外部插件与服务', renderItems(report?.external)),
|
|
417
|
-
group('客户端适配', renderItems(getSummary?.() ?? [])),
|
|
417
|
+
group('客户端适配', renderItems(getSummary?.(report?.dshVersion ?? '') ?? [])),
|
|
418
418
|
group('护栏', renderItems(report?.guards)),
|
|
419
419
|
group('警告', warnings.length ? warnings.map((w, index) => React.createElement('div', { key: index, className: 'vsm-mcp-error vsm-mcp-banner' }, w)) : React.createElement('div', { className: 'vsm-compat-item ok' }, React.createElement('span', { className: 'vsm-compat-name' }, '未检测到兼容性问题'), React.createElement('span', { className: 'vsm-compat-note' }, ''))),
|
|
420
420
|
)
|
|
@@ -571,11 +571,42 @@ function PathRow(props) {
|
|
|
571
571
|
buttons)
|
|
572
572
|
}
|
|
573
573
|
|
|
574
|
+
/**
|
|
575
|
+
* 单个数据行:浅比较包装(回调身份由父级 ref 转发保持稳定,故 memo 才可能命中)。
|
|
576
|
+
* @author ddj 2026年10月14号
|
|
577
|
+
*/
|
|
578
|
+
const LogItemRow = React.memo(LogItem)
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 数据行容器(P0-14 上段)。独立 memo:只在条目列表/选中集/工作副本版号变化时重建行元素;
|
|
582
|
+
* 上段高度拖拽与弹窗缩放只改 LogList 的 height,不再逐帧重建整表(原先每帧 logRowsOf + N 个元素)。
|
|
583
|
+
* @author ddj 2026年10月14号
|
|
584
|
+
* @param props.list 过滤排序后的条目(非空)
|
|
585
|
+
* @param props.selectedRevs 可见选中的 revision 集合
|
|
586
|
+
* @param props.wcRev 工作副本版号(null = 不加粗)
|
|
587
|
+
* @param props.onPick 选中回调(event, item)
|
|
588
|
+
* @param props.onMenu 右键回调(event, item)
|
|
589
|
+
* @returns 行元素
|
|
590
|
+
*/
|
|
591
|
+
const LogRows = React.memo(function LogRows(props) {
|
|
592
|
+
const { list, selectedRevs, wcRev, onPick, onMenu } = props
|
|
593
|
+
return React.createElement(React.Fragment, null,
|
|
594
|
+
logRowsOf(list).map((row) => React.createElement(LogItemRow, {
|
|
595
|
+
key: row.item.revision + ':' + row.level,
|
|
596
|
+
item: row.item,
|
|
597
|
+
on: selectedRevs.has(row.item.revision),
|
|
598
|
+
wc: wcRev != null && row.item.revision === wcRev,
|
|
599
|
+
level: row.level,
|
|
600
|
+
onPick,
|
|
601
|
+
onMenu,
|
|
602
|
+
})))
|
|
603
|
+
})
|
|
604
|
+
|
|
574
605
|
/**
|
|
575
606
|
* 上窗格列表(列头行 + 数据行 + 「加载更多」;P0-13 宽度受拖拽控制)。
|
|
576
607
|
* @author ddj 2026年09月16号 / 2026年09月17号
|
|
577
608
|
* @param props.list 过滤排序后的条目(null = 未加载)
|
|
578
|
-
* @param props.
|
|
609
|
+
* @param props.selectedRevs 可见选中的 revision 集合(P1-3 多选;由父级按 revision 维护)
|
|
579
610
|
* @param props.keyword 过滤词(决定空态文案)
|
|
580
611
|
* @param props.busy 加载中
|
|
581
612
|
* @param props.truncated 是否可能还有更多
|
|
@@ -588,22 +619,14 @@ function PathRow(props) {
|
|
|
588
619
|
* @returns 列表元素
|
|
589
620
|
*/
|
|
590
621
|
function LogList(props) {
|
|
591
|
-
const { list,
|
|
622
|
+
const { list, selectedRevs, keyword, busy, truncated, sortKey, sortDir, onSort, onSortReset, onPick, onLoadMore, onItemMenu, height, wcRev } = props
|
|
592
623
|
let inner
|
|
593
624
|
if (list === null) {
|
|
594
625
|
inner = React.createElement('div', { className: 'edrv-tree-loading' }, busy ? '读取日志中…' : '尚未加载')
|
|
595
626
|
} else if (!list.length) {
|
|
596
627
|
inner = React.createElement('div', { className: 'edrv-tree-loading' }, keyword ? '无匹配条目' : '无提交历史')
|
|
597
628
|
} else {
|
|
598
|
-
inner =
|
|
599
|
-
key: row.item.revision + ':' + row.level,
|
|
600
|
-
item: row.item,
|
|
601
|
-
on: selected.some((s) => s.revision === row.item.revision),
|
|
602
|
-
wc: wcRev != null && row.item.revision === wcRev,
|
|
603
|
-
level: row.level,
|
|
604
|
-
onPick,
|
|
605
|
-
onMenu: onItemMenu,
|
|
606
|
-
}))
|
|
629
|
+
inner = React.createElement(LogRows, { list, selectedRevs, wcRev, onPick, onMenu: onItemMenu })
|
|
607
630
|
}
|
|
608
631
|
const more = truncated
|
|
609
632
|
? React.createElement('div', { className: 'edrv-svnlog-more' },
|
|
@@ -921,10 +944,14 @@ export function SvnLogDialog(props) {
|
|
|
921
944
|
() => (filtered === null ? null : orderLogEntries(filtered, sortKey ?? 'revision', sortKey ? sortDir : 'desc')),
|
|
922
945
|
[filtered, sortKey, sortDir],
|
|
923
946
|
)
|
|
924
|
-
// P1-3:可见选中 = 选中数组 ∩ 当前显示列表;current =
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
947
|
+
// P1-3:可见选中 = 选中数组 ∩ 当前显示列表;current = 可见选中首条(详情/菜单主语),空则回落首条。
|
|
948
|
+
// 交集改用 revision 集合:原实现对每条选中再扫一遍显示列表(O(n×m))且每次渲染重算(缩放弹窗时每帧都会走到)。
|
|
949
|
+
const sortedRevs = React.useMemo(() => (sorted ? new Set(sorted.map((x) => x.revision)) : null), [sorted])
|
|
950
|
+
const visibleSelected = React.useMemo(
|
|
951
|
+
() => (sortedRevs ? selected.filter((item) => sortedRevs.has(item.revision)) : selected),
|
|
952
|
+
[sortedRevs, selected],
|
|
953
|
+
)
|
|
954
|
+
const selectedRevs = React.useMemo(() => new Set(visibleSelected.map((item) => item.revision)), [visibleSelected])
|
|
928
955
|
const current = visibleSelected.length
|
|
929
956
|
? visibleSelected[0]
|
|
930
957
|
: (sorted && sorted.length ? sorted[0] : null)
|
|
@@ -1022,30 +1049,37 @@ export function SvnLogDialog(props) {
|
|
|
1022
1049
|
* @param wantList 目标上段(修订列表)高度
|
|
1023
1050
|
* @param wantMsg 目标中段(提交信息)高度
|
|
1024
1051
|
*/
|
|
1052
|
+
/** @private @author ddj 2026年10月14号 @param nextList 目标上段高度 @param nextMsg 目标中段高度 只在值真正变化时提交。 */
|
|
1053
|
+
const applyFit = (nextList, nextMsg) => {
|
|
1054
|
+
if (nextList !== listH) setListH(nextList)
|
|
1055
|
+
if (nextMsg !== msgH) setMsgH(nextMsg)
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1025
1058
|
const fitHeights = (wantList, wantMsg) => {
|
|
1026
1059
|
const total = bodyRef.current?.clientHeight ?? 0
|
|
1027
|
-
if (!total) {
|
|
1060
|
+
if (!total) { applyFit(wantList, wantMsg); return }
|
|
1028
1061
|
const paneOcc = paneRef.current?.offsetHeight ?? -1
|
|
1029
1062
|
// 实测导出非本段占用(勘误 2026-09-24:手算公式漏 gap/margin/padding 曾致保底仅114);测不到回落分隔条
|
|
1030
1063
|
const chrome = paneOcc >= 0 ? Math.max(0, total - listH - msgH - paneOcc) : HSPLIT_TOTAL_PX
|
|
1031
1064
|
const budget = total - PANE_KEEP_MIN - chrome
|
|
1032
1065
|
if (budget < LIST_H_MIN + MSG_H_MIN) {
|
|
1033
|
-
|
|
1034
|
-
setMsgH(Math.min(wantMsg, MSG_H_MIN))
|
|
1066
|
+
applyFit(Math.min(wantList, LIST_H_MIN), Math.min(wantMsg, MSG_H_MIN))
|
|
1035
1067
|
return
|
|
1036
1068
|
}
|
|
1037
|
-
if (wantList + wantMsg <= budget) {
|
|
1069
|
+
if (wantList + wantMsg <= budget) { applyFit(wantList, wantMsg); return }
|
|
1038
1070
|
const scale = budget / (wantList + wantMsg)
|
|
1039
1071
|
let nextList = Math.floor(wantList * scale)
|
|
1040
1072
|
let nextMsg = Math.floor(wantMsg * scale)
|
|
1041
1073
|
if (nextList < LIST_H_MIN) { nextList = LIST_H_MIN; nextMsg = Math.min(nextMsg, budget - nextList) }
|
|
1042
1074
|
else if (nextMsg < MSG_H_MIN) { nextMsg = MSG_H_MIN; nextList = Math.min(nextList, budget - nextMsg) }
|
|
1043
|
-
|
|
1044
|
-
setMsgH(nextMsg)
|
|
1075
|
+
applyFit(nextList, nextMsg)
|
|
1045
1076
|
}
|
|
1046
1077
|
|
|
1047
1078
|
// 初始/弹窗尺寸变化 fit(2026-09-24 用户反馈:固定高中段把下段变更文件区挤到 0):
|
|
1048
|
-
//
|
|
1079
|
+
// 观察的是 body(其高度由弹窗尺寸决定、不含 listH/msgH),故收敛是单步不动点、不会与自身互相激发;
|
|
1080
|
+
// 拖拽期间不介入(拖拽自带 clamp),双击复位经 fitHeights 显式收敛。
|
|
1081
|
+
// 勘误 2026-10-14:ResizeObserver 回调本身已按帧派发,无需再包 rAF —— 推迟到下一帧反而会多出
|
|
1082
|
+
// 一帧「尺寸已变小、height 还是旧值」的中间态,正是要避免的下段被挤扁。改由 applyFit 抑制同值提交。
|
|
1049
1083
|
const fitRef = React.useRef(null)
|
|
1050
1084
|
fitRef.current = () => fitHeights(listH, msgH)
|
|
1051
1085
|
React.useEffect(() => {
|
|
@@ -1127,6 +1161,15 @@ export function SvnLogDialog(props) {
|
|
|
1127
1161
|
})
|
|
1128
1162
|
}
|
|
1129
1163
|
|
|
1164
|
+
// React.memo 生效的前提是行回调身份稳定:pickWithMods/onItemMenu 的闭包依赖 anchorRev/sorted/visibleSelected,
|
|
1165
|
+
// 每次渲染都是新函数,会把 LogRows/LogItemRow 的 memo 全部打穿。用 ref 存最新闭包、useCallback 空依赖转发。
|
|
1166
|
+
/** @author ddj 2026年10月14号 */
|
|
1167
|
+
const rowCbs = React.useRef<{ pick: ((event: unknown, item: unknown) => void) | null; menu: ((event: unknown, item: unknown) => void) | null }>({ pick: null, menu: null })
|
|
1168
|
+
rowCbs.current.pick = pickWithMods
|
|
1169
|
+
rowCbs.current.menu = onItemMenu
|
|
1170
|
+
const pickStable = React.useCallback((event: unknown, item: unknown) => rowCbs.current.pick?.(event, item), [])
|
|
1171
|
+
const menuStable = React.useCallback((event: unknown, item: unknown) => rowCbs.current.menu?.(event, item), [])
|
|
1172
|
+
|
|
1130
1173
|
/** 下窗格路径行右键菜单(P0-8;G8 目录不可开差异,G9 仓库外不可打开)。 */
|
|
1131
1174
|
const onPathMenu = (event, item, revision) => {
|
|
1132
1175
|
event.preventDefault()
|
|
@@ -1149,9 +1192,9 @@ export function SvnLogDialog(props) {
|
|
|
1149
1192
|
// P0-14 上中下三段全宽:上=修订列表(高可拖)/ 中=提交信息(高可拖)/ 下=变更路径(占余)
|
|
1150
1193
|
const body = React.createElement('div', { className: 'edrv-svnlog-body', ref: bodyRef },
|
|
1151
1194
|
React.createElement(LogList, {
|
|
1152
|
-
list: sorted,
|
|
1195
|
+
list: sorted, selectedRevs, keyword: query.trim(), busy, truncated,
|
|
1153
1196
|
sortKey, sortDir, onSort, onSortReset,
|
|
1154
|
-
onPick:
|
|
1197
|
+
onPick: pickStable, onLoadMore, onItemMenu: menuStable,
|
|
1155
1198
|
height: listH, wcRev,
|
|
1156
1199
|
}),
|
|
1157
1200
|
React.createElement(Splitter, {
|
package/src/svn.ts
CHANGED
|
@@ -1119,8 +1119,15 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1119
1119
|
const paths = [...new Set(args.paths.map(safeRel))]
|
|
1120
1120
|
if (paths.some((path) => !path)) return { ok: false as const, error: '路径不合法' }
|
|
1121
1121
|
try {
|
|
1122
|
+
// 分批并发(批 4,计划批准值):冲突面板一次请求上限 64 条,旧串行版在 64 冲突时要排队 64 次 svn 进程
|
|
1123
|
+
// (每次冷启动约 10~40ms,最坏累计 1~2s 阻塞面板)。readConflict 只跑 `svn status --xml`(只读,无写入竞争)。
|
|
1124
|
+
// 与 svn.fileSizes 的分批并发同型(见下方 BATCH = 16);分批 push 保持结果顺序不变。
|
|
1125
|
+
// @author ddj 2026年10月14号
|
|
1122
1126
|
const states = []
|
|
1123
|
-
|
|
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
|
},
|