dsh-vscode-mode 0.14.12 → 0.15.0
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 +7 -3
- package/lib/client.js +2905 -1471
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/paths.ts +2 -0
- package/src/client/sidebar/panels/FileExplorer.ts +24 -76
- package/src/client/sidebar/panels/useExplorerReveal.ts +109 -0
- package/src/client/state/documentStore.ts +148 -0
- package/src/client/state/editorLayout.ts +179 -0
- package/src/client/state/modelCache.ts +16 -7
- package/src/client/styles/editor.css +29 -3
- package/src/client/tabDrag.ts +137 -0
- package/src/client/tabMenu.ts +15 -3
- package/src/client/ui/EditorPane.ts +130 -0
- package/src/client/ui/EditorView.ts +358 -97
- package/src/client/ui/FloatingEditor.ts +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
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": [
|
package/src/client/paths.ts
CHANGED
|
@@ -16,6 +16,8 @@ export const CACHE_KEY = {
|
|
|
16
16
|
viewstate: 'edrv.cache.viewstate.v1.',
|
|
17
17
|
/** 编辑器页签/活动文件(EditorView v3:页签带 pinned 固定标记)。 */
|
|
18
18
|
editor: 'edrv.editor.v3.',
|
|
19
|
+
/** Multi-group split tree and floating window geometry. */
|
|
20
|
+
editorLayout: 'edrv.editorLayout.v1.',
|
|
19
21
|
/** 旧编辑器页签键(v2:页签为纯路径数组)——仅用于一次性回读迁移,不再写入。 */
|
|
20
22
|
editorLegacy: 'edrv.editor.v2.',
|
|
21
23
|
/** 侧边栏状态(EditorView;按布局 side/central 追加段)。 */
|
|
@@ -24,6 +24,7 @@ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
24
24
|
import { emitRefresh } from '../../events.js'
|
|
25
25
|
import { svnOverlayEl, viewOverlayOf } from '../../ui/SvnOverlay.js'
|
|
26
26
|
import { useExplorerSvn } from './useExplorerSvn.js'
|
|
27
|
+
import { useExplorerReveal, treeRevealPath } from './useExplorerReveal.js'
|
|
27
28
|
import type { SidebarCtx } from '../types.js'
|
|
28
29
|
|
|
29
30
|
const DIR_CAP = 4000
|
|
@@ -31,10 +32,6 @@ const SAVE_DEBOUNCE_MS = 300
|
|
|
31
32
|
const FOLLOW_INTERVAL_MS = 10_000
|
|
32
33
|
const PREFETCH_MAX = 4
|
|
33
34
|
const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm', '.pnpm-store'])
|
|
34
|
-
/** 「在资源管理器视图中显示」高亮时长与定位重试上限(目录懒加载需等待行渲染)。 */
|
|
35
|
-
const REVEAL_HIGHLIGHT_MS = 2000
|
|
36
|
-
const REVEAL_RETRY_MAX = 6
|
|
37
|
-
const REVEAL_RETRY_MS = 120
|
|
38
35
|
/** 外部文件变化重列去抖:一轮外部批量写入(如 agent 连写多文件)合并为一次重列。 */
|
|
39
36
|
const FILE_CHANGE_DEBOUNCE_MS = 400
|
|
40
37
|
|
|
@@ -97,16 +94,6 @@ function rowView(props) {
|
|
|
97
94
|
}
|
|
98
95
|
const ExplorerRow = React.memo(rowView)
|
|
99
96
|
|
|
100
|
-
/**
|
|
101
|
-
* 转义 CSS 属性选择器的值(路径含引号/反斜杠时避免选择器语法错误)。
|
|
102
|
-
* @author ddj 2026年09月11号
|
|
103
|
-
* @param value 原始值
|
|
104
|
-
* @returns 可安全嵌入 `[attr="…"]` 的字符串
|
|
105
|
-
*/
|
|
106
|
-
function cssEscape(value) {
|
|
107
|
-
return String(value ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
108
|
-
}
|
|
109
|
-
|
|
110
97
|
/**
|
|
111
98
|
* 目录树面板主体(SWR:有缓存先渲染,无缓存才显示加载态;加载总在后台)。
|
|
112
99
|
* @param props.ctx 面板共享上下文(sessionId/openFile/activePath/pendingByPath/fileMenuItems/notify)
|
|
@@ -127,7 +114,6 @@ export function FileExplorer(props) {
|
|
|
127
114
|
const [loading, setLoading] = React.useState({})
|
|
128
115
|
const [errors, setErrors] = React.useState({}) // rel → 错误文案(仅无任何数据时展示)
|
|
129
116
|
const [menu, setMenu] = React.useState(null) // 右键菜单 { x, y, target }
|
|
130
|
-
const [revealPath, setRevealPath] = React.useState(null) // 高亮的相对路径(「在资源管理器视图中显示」)
|
|
131
117
|
// 各状态 ref 镜像:定时器/监听用最新闭包
|
|
132
118
|
const tokensRef = React.useRef({})
|
|
133
119
|
const expandedRef = React.useRef({})
|
|
@@ -139,9 +125,6 @@ export function FileExplorer(props) {
|
|
|
139
125
|
const saveTimerRef = React.useRef(null)
|
|
140
126
|
const loadDirRef = React.useRef(null)
|
|
141
127
|
const refreshRef = React.useRef(null)
|
|
142
|
-
const revealTimerRef = React.useRef(null) // 高亮清除计时器
|
|
143
|
-
const revealTryRef = React.useRef(0) // 当前定位的重试计数(行渲染需等目录加载)
|
|
144
|
-
const revealInTreeRef = React.useRef(null) // 定位动作最新闭包(窗口监听读取)
|
|
145
128
|
const treeRef = React.useRef(null) // 目录树容器(定位时按 data-edrv-path 查行)
|
|
146
129
|
const reloadDirRef = React.useRef(null) // loadDir 最新闭包(文件变化监听读取,防陈旧闭包)
|
|
147
130
|
const changedTimerRef = React.useRef(null) // 文件变化合并去抖计时器
|
|
@@ -172,8 +155,10 @@ export function FileExplorer(props) {
|
|
|
172
155
|
const force = opts?.force === true
|
|
173
156
|
const doPrefetch = opts?.prefetch === true
|
|
174
157
|
if (!force && loadingRef.current[rel]) return
|
|
175
|
-
const
|
|
176
|
-
|
|
158
|
+
const tokens = tokensRef.current
|
|
159
|
+
const token = (tokens[rel] || 0) + 1
|
|
160
|
+
tokens[rel] = token
|
|
161
|
+
loadingRef.current = Object.assign({}, loadingRef.current, { [rel]: true })
|
|
177
162
|
setLoading((prev) => Object.assign({}, prev, { [rel]: true }))
|
|
178
163
|
setErrors((prev) => {
|
|
179
164
|
const next = Object.assign({}, prev)
|
|
@@ -183,7 +168,7 @@ export function FileExplorer(props) {
|
|
|
183
168
|
const args = { sessionId, path: rel }
|
|
184
169
|
if (force) args.force = true
|
|
185
170
|
rpc('edrv.listDir', args).then((res) => {
|
|
186
|
-
if (tokensRef.current[rel] !== token) return
|
|
171
|
+
if (scopeRef.current !== scope || tokensRef.current !== tokens || tokens[rel] !== token) return
|
|
187
172
|
if (res && res.ok && Array.isArray(res.entries)) {
|
|
188
173
|
setDirs((prev) => Object.assign({}, prev, { [rel]: res.entries }))
|
|
189
174
|
if (res.root) setRoot((prev) => prev || res.root)
|
|
@@ -196,12 +181,12 @@ export function FileExplorer(props) {
|
|
|
196
181
|
}
|
|
197
182
|
}
|
|
198
183
|
}).catch((e) => {
|
|
199
|
-
if (tokensRef.current[rel] !== token) return
|
|
184
|
+
if (scopeRef.current !== scope || tokensRef.current !== tokens || tokens[rel] !== token) return
|
|
200
185
|
if (!entriesOf(rel)) {
|
|
201
186
|
setErrors((prev) => Object.assign({}, prev, { [rel]: '读取目录异常:' + String(e) }))
|
|
202
187
|
}
|
|
203
188
|
}).finally(() => {
|
|
204
|
-
if (tokensRef.current[rel] !== token) return
|
|
189
|
+
if (scopeRef.current !== scope || tokensRef.current !== tokens || tokens[rel] !== token) return
|
|
205
190
|
setLoading((prev) => {
|
|
206
191
|
const next = Object.assign({}, prev)
|
|
207
192
|
delete next[rel]
|
|
@@ -220,55 +205,28 @@ export function FileExplorer(props) {
|
|
|
220
205
|
void loadDir(rel, { prefetch: true })
|
|
221
206
|
}
|
|
222
207
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
225
|
-
* @author ddj 2026年09月11号
|
|
226
|
-
* @param path 目标相对路径
|
|
227
|
-
*/
|
|
228
|
-
const tryReveal = (path) => {
|
|
229
|
-
const host = treeRef.current
|
|
230
|
-
const row = host?.querySelector?.('[data-edrv-path="' + cssEscape(path) + '"]')
|
|
231
|
-
if (!row) {
|
|
232
|
-
revealTryRef.current += 1
|
|
233
|
-
if (revealTryRef.current < REVEAL_RETRY_MAX) {
|
|
234
|
-
window.setTimeout(() => tryReveal(path), REVEAL_RETRY_MS)
|
|
235
|
-
}
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
row.scrollIntoView?.({ block: 'center' })
|
|
239
|
-
setRevealPath(path)
|
|
240
|
-
if (revealTimerRef.current) clearTimeout(revealTimerRef.current)
|
|
241
|
-
revealTimerRef.current = window.setTimeout(() => {
|
|
242
|
-
revealTimerRef.current = null
|
|
243
|
-
setRevealPath(null)
|
|
244
|
-
}, REVEAL_HIGHLIGHT_MS)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* 在资源管理器视图中显示:展开全部祖先目录并定位高亮目标文件。
|
|
249
|
-
* 目录条目懒加载,故先展开祖先并触发加载,再由 tryReveal 轮询等行渲染。
|
|
250
|
-
* @author ddj 2026年09月11号
|
|
251
|
-
* @param path 目标相对路径
|
|
252
|
-
*/
|
|
253
|
-
const revealInTree = (path) => {
|
|
254
|
-
if (!path) return
|
|
208
|
+
/** @private @author ddj 2026年10月14号 @param path Safe relative file path. Expands only the ancestor chain and reuses available directory entries. */
|
|
209
|
+
function prepareReveal(path) {
|
|
255
210
|
const ancestors = ancestorDirsOf(path)
|
|
256
|
-
|
|
257
|
-
|
|
211
|
+
setExpanded(expandParents)
|
|
212
|
+
for (const dir of ['', ...ancestors]) {
|
|
213
|
+
if (entriesOf(dir)) continue
|
|
214
|
+
void loadDir(dir, { prefetch: false })
|
|
215
|
+
}
|
|
216
|
+
/** @private @author ddj 2026年10月14号 @param prev Current expansion map. @returns Expanded ancestor map. */
|
|
217
|
+
function expandParents(prev) {
|
|
258
218
|
const next = Object.assign({}, prev)
|
|
259
219
|
for (const dir of ancestors) next[dir] = true
|
|
260
220
|
return next
|
|
261
|
-
}
|
|
262
|
-
for (const dir of ancestors) void loadDir(dir, { prefetch: false })
|
|
263
|
-
tryReveal(path)
|
|
221
|
+
}
|
|
264
222
|
}
|
|
265
|
-
revealInTreeRef.current = revealInTree
|
|
266
223
|
|
|
267
224
|
const refresh = (event) => {
|
|
268
225
|
if (event?.detail?.scope && event.detail.scope !== scope) return
|
|
269
226
|
if (event?.detail?.paths?.length) { for (const path of event.detail.paths) onFileChanged(path); return }
|
|
270
227
|
const keep = Object.keys(expandedRef.current).filter((k) => expandedRef.current[k] === true)
|
|
271
228
|
tokensRef.current = {}
|
|
229
|
+
loadingRef.current = {}
|
|
272
230
|
setMenu(null)
|
|
273
231
|
setErrors({})
|
|
274
232
|
// SWR:保留旧条目不清空,后台强制重列(force 跳过索引)
|
|
@@ -334,13 +292,13 @@ export function FileExplorer(props) {
|
|
|
334
292
|
|
|
335
293
|
React.useEffect(() => {
|
|
336
294
|
tokensRef.current = {}
|
|
295
|
+
loadingRef.current = {}
|
|
337
296
|
setDirs({})
|
|
338
297
|
setExpanded({})
|
|
339
298
|
setLoading({})
|
|
340
299
|
setErrors({})
|
|
341
300
|
setMenu(null)
|
|
342
301
|
setRoot(null)
|
|
343
|
-
setRevealPath(null)
|
|
344
302
|
// 恢复上次展开状态(对齐 VSCode:持久化展开路径,条目缓存即时渲染、后台刷新)
|
|
345
303
|
const cached = scope ? explorerLoad(scope) : null
|
|
346
304
|
const restored = cached?.expanded ?? []
|
|
@@ -379,19 +337,9 @@ export function FileExplorer(props) {
|
|
|
379
337
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
380
338
|
}, [])
|
|
381
339
|
|
|
382
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const path = event?.detail?.path
|
|
386
|
-
if (typeof path === 'string' && path) revealInTreeRef.current?.(path)
|
|
387
|
-
}
|
|
388
|
-
window.addEventListener('edrv:reveal-path', onReveal)
|
|
389
|
-
return () => {
|
|
390
|
-
window.removeEventListener('edrv:reveal-path', onReveal)
|
|
391
|
-
if (revealTimerRef.current) { clearTimeout(revealTimerRef.current); revealTimerRef.current = null }
|
|
392
|
-
}
|
|
393
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
394
|
-
}, [])
|
|
340
|
+
// 在恢复工作区展开状态之后跟随活动文件;隐藏期间目标由编辑器保留,重新挂载时兑现。
|
|
341
|
+
const { revealPath } = useExplorerReveal({ scope, activePath, treeRef, prepare: prepareReveal })
|
|
342
|
+
const activeTreePath = treeRevealPath(activePath)
|
|
395
343
|
|
|
396
344
|
// 10s 轻量跟随:已展开目录后台刷新(命中 host 索引,近零成本),树跟随 agent 写入
|
|
397
345
|
React.useEffect(() => {
|
|
@@ -446,7 +394,7 @@ export function FileExplorer(props) {
|
|
|
446
394
|
if (!row.e) return React.createElement('div', { key: row.key, className: 'edrv-tree-loading', style: { paddingLeft: 6 + row.depth * 14 } }, row.text)
|
|
447
395
|
const { e, depth, isDir, isOpen } = row
|
|
448
396
|
return React.createElement(ExplorerRow, { key: e.path, path: e.path, name: e.name, type: e.type, depth, isDir, isOpen,
|
|
449
|
-
pending: isDir ? 0 : pendingByPath[e.path] ?? 0, active: !isDir && e.path ===
|
|
397
|
+
pending: isDir ? 0 : pendingByPath[e.path] ?? 0, active: !isDir && e.path === activeTreePath,
|
|
450
398
|
context: Boolean(menu && menuEntries.length && menu.target.path === e.path), reveal: revealPath === e.path,
|
|
451
399
|
mark: svn.local?.get(e.path), remote: svn.remote?.get(e.path) ?? 0, onSelect, onContext })
|
|
452
400
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { isTreeRevealable } from '../../tabActions.js'
|
|
3
|
+
|
|
4
|
+
/** @private @author ddj 2026年10月14号 @param path Editor path. @returns Safe normalized tree path, or null. */
|
|
5
|
+
export function treeRevealPath(path: string | null): string | null {
|
|
6
|
+
if (!path || !isTreeRevealable(path)) return null
|
|
7
|
+
return path.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @private @author ddj 2026年10月14号 @param host Tree scroll container. @param path Exact row path. @returns Whether a visible row was located. Only scrolls the tree. */
|
|
11
|
+
export function revealTreeRow(host: HTMLElement | null, path: string): boolean {
|
|
12
|
+
if (!host || host.clientHeight <= 0) return false
|
|
13
|
+
const escaped = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
14
|
+
const row = host.querySelector<HTMLElement>('[data-edrv-path="' + escaped + '"]')
|
|
15
|
+
if (!row) return false
|
|
16
|
+
const viewport = host.getBoundingClientRect()
|
|
17
|
+
const bounds = row.getBoundingClientRect()
|
|
18
|
+
const top = viewport.top + host.clientTop
|
|
19
|
+
if (bounds.top < top || bounds.bottom > top + host.clientHeight) {
|
|
20
|
+
host.scrollTop += bounds.top - top - (host.clientHeight - bounds.height) / 2
|
|
21
|
+
}
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RevealOptions {
|
|
26
|
+
scope: string
|
|
27
|
+
activePath: string | null
|
|
28
|
+
treeRef: React.RefObject<HTMLElement>
|
|
29
|
+
prepare: (path: string) => void
|
|
30
|
+
}
|
|
31
|
+
interface RevealTarget {
|
|
32
|
+
scope: string
|
|
33
|
+
path: string
|
|
34
|
+
active: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @private @author ddj 2026年10月14号 @param options Current workspace, active editor and tree preparation. @returns Highlight and manual request callback. Waits for committed rows without polling; unmount cancels highlight cleanup. */
|
|
38
|
+
export function useExplorerReveal(options: RevealOptions) {
|
|
39
|
+
const { scope, activePath, treeRef } = options
|
|
40
|
+
const [target, setTarget] = React.useState<RevealTarget | null>(null)
|
|
41
|
+
const [revealPath, setRevealPath] = React.useState<string | null>(null)
|
|
42
|
+
const pending = React.useRef<RevealTarget | null>(null)
|
|
43
|
+
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
44
|
+
const latest = React.useRef(options)
|
|
45
|
+
latest.current = options
|
|
46
|
+
|
|
47
|
+
/** @private @author ddj 2026年10月14号 Clears only this hook's highlight timer. */
|
|
48
|
+
function clearHighlight(): void {
|
|
49
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
50
|
+
timer.current = null
|
|
51
|
+
}
|
|
52
|
+
/** @private @author ddj 2026年10月14号 @param path Requested tree path. Expands ancestors and replaces any previous pending target. */
|
|
53
|
+
function requestReveal(path: string | null): void {
|
|
54
|
+
clearHighlight()
|
|
55
|
+
setRevealPath(null)
|
|
56
|
+
const normalized = treeRevealPath(path)
|
|
57
|
+
const current = latest.current
|
|
58
|
+
const next = normalized ? { scope: current.scope, path: normalized, active: current.activePath } : null
|
|
59
|
+
pending.current = next
|
|
60
|
+
setTarget(next)
|
|
61
|
+
if (next) current.prepare(next.path)
|
|
62
|
+
}
|
|
63
|
+
/** @private @author ddj 2026年10月14号 Attempts the latest target after row commit or container resize. Consumes only a successful, current request. */
|
|
64
|
+
function tryReveal(): void {
|
|
65
|
+
const next = pending.current
|
|
66
|
+
const current = latest.current
|
|
67
|
+
if (!next || next.scope !== current.scope || next.active !== current.activePath) return
|
|
68
|
+
if (!revealTreeRow(current.treeRef.current, next.path)) return
|
|
69
|
+
pending.current = null
|
|
70
|
+
setRevealPath(next.path)
|
|
71
|
+
clearHighlight()
|
|
72
|
+
timer.current = setTimeout(endHighlight, 2000)
|
|
73
|
+
}
|
|
74
|
+
/** @private @author ddj 2026年10月14号 Ends transient reveal styling without changing the active editor row. */
|
|
75
|
+
function endHighlight(): void {
|
|
76
|
+
timer.current = null
|
|
77
|
+
setRevealPath(null)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @private @author ddj 2026年10月14号 Establishes the latest editor target after workspace tree restoration. */
|
|
81
|
+
function followActive(): void { requestReveal(activePath) }
|
|
82
|
+
React.useEffect(followActive, [scope, activePath])
|
|
83
|
+
React.useLayoutEffect(tryReveal)
|
|
84
|
+
|
|
85
|
+
/** @private @author ddj 2026年10月14号 @returns Cleanup for manual events, resize observation and highlight timer. */
|
|
86
|
+
function listenReveal(): () => void {
|
|
87
|
+
/** @private @author ddj 2026年10月14号 @param event Existing explicit reveal request. */
|
|
88
|
+
function onReveal(event: Event): void {
|
|
89
|
+
const detail = (event as CustomEvent<{ path?: string; scope?: string }>).detail
|
|
90
|
+
if (detail?.scope && detail.scope !== latest.current.scope) return
|
|
91
|
+
if (typeof detail?.path === 'string') requestReveal(detail.path)
|
|
92
|
+
}
|
|
93
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(tryReveal) : null
|
|
94
|
+
if (treeRef.current) observer?.observe(treeRef.current)
|
|
95
|
+
window.addEventListener('edrv:reveal-path', onReveal)
|
|
96
|
+
/** @private @author ddj 2026年10月14号 Cancels callbacks owned by this mounted tree. */
|
|
97
|
+
function cleanup(): void {
|
|
98
|
+
observer?.disconnect()
|
|
99
|
+
window.removeEventListener('edrv:reveal-path', onReveal)
|
|
100
|
+
pending.current = null
|
|
101
|
+
clearHighlight()
|
|
102
|
+
}
|
|
103
|
+
return cleanup
|
|
104
|
+
}
|
|
105
|
+
React.useEffect(listenReveal, [treeRef])
|
|
106
|
+
// Keep the request state as a render trigger when an already-open tree is revealed manually.
|
|
107
|
+
void target
|
|
108
|
+
return { revealPath, requestReveal }
|
|
109
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** One save owner per document, independent of group placement. @author ddj 2026年10月14号 */
|
|
2
|
+
export interface TextModel { getValue(): string; isDisposed?(): boolean; getVersionId?(): number }
|
|
3
|
+
export interface SaveResult { ok: boolean; rev?: string; conflict?: boolean; error?: string }
|
|
4
|
+
export interface DocumentState {
|
|
5
|
+
model: TextModel | null
|
|
6
|
+
rev?: string
|
|
7
|
+
dirty: boolean
|
|
8
|
+
conflict: boolean
|
|
9
|
+
pending: ReturnType<typeof setTimeout> | null
|
|
10
|
+
saving: Promise<SaveResult> | null
|
|
11
|
+
again: boolean
|
|
12
|
+
generation: number
|
|
13
|
+
}
|
|
14
|
+
export interface SaveRequest { path: string; content: string; rev?: string }
|
|
15
|
+
export interface DocumentDeps {
|
|
16
|
+
save(request: SaveRequest): Promise<SaveResult>
|
|
17
|
+
changed?(path: string, state: DocumentState): void
|
|
18
|
+
saved?(path: string, content: string, model: TextModel, result: SaveResult): void
|
|
19
|
+
failed?(path: string, result: SaveResult): void
|
|
20
|
+
blocked?(path: string): boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @public @author ddj 2026年10月14号 @param deps IO/version guard and view notifications. @returns Shared lifecycle without model disposal. */
|
|
24
|
+
export function createDocStore(deps: DocumentDeps) {
|
|
25
|
+
const documents = new Map<string, DocumentState>()
|
|
26
|
+
let disposed = false
|
|
27
|
+
|
|
28
|
+
/** @public @author ddj 2026年10月14号 @param path Canonical file path. @returns Document state, lazily created. */
|
|
29
|
+
function state(path: string): DocumentState {
|
|
30
|
+
let item = documents.get(path)
|
|
31
|
+
if (!item) {
|
|
32
|
+
item = { model: null, dirty: false, conflict: false, pending: null, saving: null, again: false, generation: 0 }
|
|
33
|
+
documents.set(path, item)
|
|
34
|
+
}
|
|
35
|
+
return item
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @private @author ddj 2026年10月14号 @param path Canonical path. @param item Current state. Publish change to all views. */
|
|
39
|
+
function notify(path: string, item: DocumentState): void { deps.changed?.(path, item) }
|
|
40
|
+
|
|
41
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param model Existing model. @param rev Optional disk revision. Bind without erasing edits. */
|
|
42
|
+
function bind(path: string, model: TextModel, rev?: string): void {
|
|
43
|
+
const item = state(path)
|
|
44
|
+
if (item.model && item.model !== model) item.generation += 1
|
|
45
|
+
item.model = model
|
|
46
|
+
if (rev) item.rev = rev
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param rev Disk token. @param conflict Optional conflict marker. Update disk baseline only. */
|
|
50
|
+
function baseline(path: string, rev?: string, conflict?: boolean): void {
|
|
51
|
+
const item = state(path)
|
|
52
|
+
if (rev) item.rev = rev
|
|
53
|
+
if (conflict !== undefined) item.conflict = conflict
|
|
54
|
+
notify(path, item)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @private @author ddj 2026年10月14号 @param item Document state. Cancel delayed save, not in-flight IO. */
|
|
58
|
+
function cancel(item: DocumentState): void {
|
|
59
|
+
if (item.pending !== null) clearTimeout(item.pending)
|
|
60
|
+
item.pending = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @private @author ddj 2026年10月14号 @param path Saved path. @param item Owner state. @param model Captured model. @param content Saved snapshot. @param version Captured version. @param generation Owner generation. @param result IO result. Accept only current content. */
|
|
64
|
+
function accept(path: string, item: DocumentState, model: TextModel, content: string, version: number | undefined, generation: number, result: SaveResult): void {
|
|
65
|
+
if (documents.get(path) !== item || item.generation !== generation || item.model !== model) return
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
item.conflict = result.conflict === true || item.conflict
|
|
68
|
+
deps.failed?.(path, result)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
if (result.rev) item.rev = result.rev
|
|
72
|
+
if (!model.isDisposed?.() && model.getValue() === content && (version === undefined || version === model.getVersionId?.())) item.dirty = false
|
|
73
|
+
deps.saved?.(path, content, model, result)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param manual Explicit user save may invoke version guard despite conflict. @returns Deduplicated save completion; failures retain dirty state. */
|
|
77
|
+
function save(path: string, manual = false): Promise<SaveResult> {
|
|
78
|
+
const item = state(path)
|
|
79
|
+
cancel(item)
|
|
80
|
+
if (disposed || !item.model || item.model.isDisposed?.()) return Promise.resolve({ ok: false, error: '文件模型不可用' })
|
|
81
|
+
if (!manual && (item.conflict || deps.blocked?.(path))) return Promise.resolve({ ok: false, conflict: true })
|
|
82
|
+
if (item.saving) { item.again = item.dirty; return item.saving }
|
|
83
|
+
const model = item.model, content = model.getValue(), version = model.getVersionId?.(), generation = item.generation
|
|
84
|
+
const request = { path, content, rev: item.rev }
|
|
85
|
+
/** @private @author ddj 2026年10月14号 @param result IO result. @returns Original result after guarded acceptance. */
|
|
86
|
+
function success(result: SaveResult): SaveResult { accept(path, item, model, content, version, generation, result); return result }
|
|
87
|
+
/** @private @author ddj 2026年10月14号 @param error IO failure. @returns Failure without discarding content. */
|
|
88
|
+
function failure(error: unknown): SaveResult {
|
|
89
|
+
const result = { ok: false, error: String(error) }
|
|
90
|
+
if (documents.get(path) === item) deps.failed?.(path, result)
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
/** @private @author ddj 2026年10月14号 Complete one request before scheduling another changed snapshot. */
|
|
94
|
+
function settled(): void {
|
|
95
|
+
item.saving = null
|
|
96
|
+
notify(path, item)
|
|
97
|
+
if (documents.get(path) !== item || disposed || !item.again) return
|
|
98
|
+
item.again = false
|
|
99
|
+
if (item.dirty && !item.conflict) edit(path)
|
|
100
|
+
}
|
|
101
|
+
item.saving = Promise.resolve().then(() => deps.save(request)).then(success, failure).finally(settled)
|
|
102
|
+
notify(path, item)
|
|
103
|
+
return item.saving
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @public @author ddj 2026年10月14号 @param path File path. @param delay Debounce milliseconds. Mark dirty and schedule a document-owned save. */
|
|
107
|
+
function edit(path: string, delay = 700): void {
|
|
108
|
+
if (disposed) return
|
|
109
|
+
const item = state(path)
|
|
110
|
+
item.dirty = true
|
|
111
|
+
cancel(item)
|
|
112
|
+
if (!item.conflict && !deps.blocked?.(path)) {
|
|
113
|
+
/** @private @author ddj 2026年10月14号 Save the same document even after group/focus changes. */
|
|
114
|
+
function delayed(): void { item.pending = null; void save(path) }
|
|
115
|
+
item.pending = setTimeout(delayed, delay)
|
|
116
|
+
}
|
|
117
|
+
notify(path, item)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @public @author ddj 2026年10月14号 @param path Optional document. @returns Pending saves submitted; moving groups need not call this. */
|
|
121
|
+
function flush(path?: string): Promise<SaveResult[]> {
|
|
122
|
+
const work: Promise<SaveResult>[] = []
|
|
123
|
+
for (const [key, item] of documents) {
|
|
124
|
+
if (path && path !== key) continue
|
|
125
|
+
if (item.pending !== null || (item.dirty && !item.saving)) work.push(save(key))
|
|
126
|
+
else if (item.saving) work.push(item.saving)
|
|
127
|
+
}
|
|
128
|
+
return Promise.all(work)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @public @author ddj 2026年10月14号 @param path File removed from tracking. @returns False while unsafe; never destroys a model. */
|
|
132
|
+
function release(path: string): boolean {
|
|
133
|
+
const item = documents.get(path)
|
|
134
|
+
if (!item) return true
|
|
135
|
+
if (item.dirty || item.saving) return false
|
|
136
|
+
cancel(item)
|
|
137
|
+
documents.delete(path)
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** @public @author ddj 2026年10月14号 @returns Whether dirty or in-flight content prevents reload. */
|
|
142
|
+
function unsafe(): boolean { return [...documents.values()].some((item) => item.dirty || item.pending !== null || !!item.saving) }
|
|
143
|
+
|
|
144
|
+
/** @public @author ddj 2026年10月14号 Cancel timers on teardown; retained models and in-flight save guards survive. */
|
|
145
|
+
function dispose(): void { disposed = true; for (const item of documents.values()) cancel(item) }
|
|
146
|
+
return { state, bind, baseline, edit, save, flush, release, unsafe, dispose }
|
|
147
|
+
}
|
|
148
|
+
export type DocumentStore = ReturnType<typeof createDocStore>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** Workbench group layout without file-content ownership. @author ddj 2026年10月14号 */
|
|
2
|
+
import { normalizeTabs, pickActive, tabPathOf, type TabLike } from '../tabActions.js'
|
|
3
|
+
|
|
4
|
+
export const GROUP_LIMIT = 8
|
|
5
|
+
export const MAIN_GROUP = 'main'
|
|
6
|
+
export type Edge = 'left' | 'right' | 'top' | 'bottom'
|
|
7
|
+
export interface Group { id: string; tabs: TabLike[]; active: string | null }
|
|
8
|
+
export type LayoutNode = { group: string } | { axis: 'horizontal' | 'vertical'; ratio: number; first: LayoutNode; second: LayoutNode }
|
|
9
|
+
export interface FloatRect { left: number; top: number; width: number; height: number }
|
|
10
|
+
export interface FloatGroup extends FloatRect { group: string }
|
|
11
|
+
export interface EditorLayout { v: 1; tree: LayoutNode; groups: Group[]; floats: FloatGroup[]; focused: string; serial: number }
|
|
12
|
+
export type DropTarget = { kind: 'group'; group: string; index?: number } | { kind: 'split'; group: string; edge: Edge } | { kind: 'float'; rect: FloatRect }
|
|
13
|
+
|
|
14
|
+
// --region Layout transactions
|
|
15
|
+
/** @public @author ddj 2026年10月14号 @param tabs Legacy tabs. @param active Legacy selection. @returns Single-group layout. */
|
|
16
|
+
export function createLayout(tabs: TabLike[] = [], active: string | null = null): EditorLayout {
|
|
17
|
+
return { v: 1, tree: { group: MAIN_GROUP }, groups: [{ id: MAIN_GROUP, tabs, active: pickActive(tabs, active) }], floats: [], focused: MAIN_GROUP, serial: 0 }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param path Normalized path. @returns Unique owning group. */
|
|
21
|
+
export function ownerOf(layout: EditorLayout, path: string): Group | undefined {
|
|
22
|
+
return layout.groups.find((group) => group.tabs.some((tab) => tab.path === path))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param group Group id. @param path Optional tab selection. @returns Focused layout. */
|
|
26
|
+
export function focusGroup(layout: EditorLayout, group: string, path?: string): EditorLayout {
|
|
27
|
+
const found = layout.groups.find((item) => item.id === group)
|
|
28
|
+
if (!found) return layout
|
|
29
|
+
const active = path && found.tabs.some((tab) => tab.path === path) ? path : found.active
|
|
30
|
+
if (layout.focused === group && active === found.active) return layout
|
|
31
|
+
return { ...layout, focused: group, groups: layout.groups.map((item) => item.id === group ? { ...item, active } : item) }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @private @author ddj 2026年10月14号 @param node Split subtree. @param group Leaf to replace. @param next Replacement. @returns Rewritten tree. */
|
|
35
|
+
function replaceLeaf(node: LayoutNode, group: string, next: LayoutNode): LayoutNode {
|
|
36
|
+
if ('group' in node) return node.group === group ? next : node
|
|
37
|
+
return { ...node, first: replaceLeaf(node.first, group, next), second: replaceLeaf(node.second, group, next) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @private @author ddj 2026年10月14号 @param node Split subtree. @param alive Surviving ids. @returns Collapsed tree or null. */
|
|
41
|
+
function pruneTree(node: LayoutNode, alive: Set<string>): LayoutNode | null {
|
|
42
|
+
if ('group' in node) return alive.has(node.group) ? node : null
|
|
43
|
+
const first = pruneTree(node.first, alive)
|
|
44
|
+
const second = pruneTree(node.second, alive)
|
|
45
|
+
return first && second ? { ...node, first, second } : first ?? second
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @private @author ddj 2026年10月14号 @param layout Changed layout. @returns Empty auxiliary groups removed; primary group retained. */
|
|
49
|
+
function compact(layout: EditorLayout): EditorLayout {
|
|
50
|
+
const groups = layout.groups.filter((group) => group.id === MAIN_GROUP || group.tabs.length)
|
|
51
|
+
const alive = new Set(groups.map((group) => group.id))
|
|
52
|
+
return { ...layout, groups, tree: pruneTree(layout.tree, alive) ?? { group: MAIN_GROUP }, floats: layout.floats.filter((item) => alive.has(item.group)), focused: alive.has(layout.focused) ? layout.focused : MAIN_GROUP }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @private @author ddj 2026年10月14号 @param tabs Target tabs without moved tab. @param tab Moved record. @param index Requested insertion slot. @returns Pinned-partition-safe insertion. */
|
|
56
|
+
function insertAt(tabs: TabLike[], tab: TabLike, index = tabs.length): TabLike[] {
|
|
57
|
+
const pins = tabs.filter((item) => item.pinned).length
|
|
58
|
+
const at = tab.pinned ? Math.max(0, Math.min(pins, index)) : Math.max(pins, Math.min(tabs.length, index))
|
|
59
|
+
return [...tabs.slice(0, at), tab, ...tabs.slice(at)]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param path Existing normalized file. @param target Accepted destination. @returns Atomic move; invalid target leaves original unchanged. */
|
|
63
|
+
export function moveTab(layout: EditorLayout, path: string, target: DropTarget): EditorLayout {
|
|
64
|
+
const source = ownerOf(layout, path)
|
|
65
|
+
const tab = source?.tabs.find((item) => item.path === path)
|
|
66
|
+
if (!source || !tab) return layout
|
|
67
|
+
if (target.kind !== 'group' && layout.groups.length >= GROUP_LIMIT) return layout
|
|
68
|
+
const destination = target.kind !== 'float' ? layout.groups.find((item) => item.id === target.group) : null
|
|
69
|
+
if (target.kind !== 'float' && !destination) return layout
|
|
70
|
+
if (target.kind === 'split' && layout.floats.some((item) => item.group === target.group)) return layout
|
|
71
|
+
const id = target.kind === 'group' ? target.group : 'group-' + (layout.serial + 1)
|
|
72
|
+
let index = target.kind === 'group' ? target.index : undefined
|
|
73
|
+
const oldIndex = source.tabs.findIndex((item) => item.path === path)
|
|
74
|
+
if (source.id === id && index !== undefined && oldIndex < index) index -= 1
|
|
75
|
+
let groups = layout.groups.map((group) => {
|
|
76
|
+
const tabs = group.tabs.filter((item) => item.path !== path)
|
|
77
|
+
if (group.id === id) return { ...group, tabs: insertAt(tabs, tab, index), active: path }
|
|
78
|
+
return { ...group, tabs, active: pickActive(tabs, group.active) }
|
|
79
|
+
})
|
|
80
|
+
let tree = layout.tree
|
|
81
|
+
let floats = layout.floats
|
|
82
|
+
if (target.kind !== 'group') groups = [...groups, { id, tabs: [tab], active: path }]
|
|
83
|
+
if (target.kind === 'float') floats = [...floats, { ...target.rect, group: id }]
|
|
84
|
+
if (target.kind === 'split') {
|
|
85
|
+
const before = target.edge === 'left' || target.edge === 'top'
|
|
86
|
+
const old: LayoutNode = { group: target.group }
|
|
87
|
+
const added: LayoutNode = { group: id }
|
|
88
|
+
tree = replaceLeaf(tree, target.group, { axis: target.edge === 'left' || target.edge === 'right' ? 'horizontal' : 'vertical', ratio: 0.5, first: before ? added : old, second: before ? old : added })
|
|
89
|
+
}
|
|
90
|
+
return compact({ ...layout, groups, tree, floats, focused: id, serial: target.kind === 'group' ? layout.serial : layout.serial + 1 })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param group Auxiliary group. @returns All tabs returned to main without closing documents. */
|
|
94
|
+
export function returnGroup(layout: EditorLayout, group: string): EditorLayout {
|
|
95
|
+
const found = layout.groups.find((item) => item.id === group)
|
|
96
|
+
if (!found || group === MAIN_GROUP) return layout
|
|
97
|
+
let next = layout
|
|
98
|
+
for (const tab of found.tabs) next = moveTab(next, tab.path, { kind: 'group', group: MAIN_GROUP })
|
|
99
|
+
return next
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @public @author ddj 2026年10月14号 @param layout Workbench. @param tabs Global file records. @param active Optional newly selected file. @returns Layout reconciled with opens/closes/renames. */
|
|
103
|
+
export function syncTabs(layout: EditorLayout, tabs: TabLike[], active?: string | null): EditorLayout {
|
|
104
|
+
const byPath = new Map(tabs.map((tab) => [tab.path, tab]))
|
|
105
|
+
const groups = layout.groups.map((group) => {
|
|
106
|
+
const kept = group.tabs.flatMap((tab) => byPath.has(tab.path) ? [byPath.get(tab.path)!] : [])
|
|
107
|
+
return { ...group, tabs: kept, active: pickActive(kept, group.active) }
|
|
108
|
+
})
|
|
109
|
+
const seen = new Set(groups.flatMap((group) => group.tabs.map((tab) => tab.path)))
|
|
110
|
+
const focused = groups.find((group) => group.id === layout.focused) ?? groups[0]!
|
|
111
|
+
for (const tab of tabs) if (!seen.has(tab.path)) focused.tabs = insertAt(focused.tabs, tab)
|
|
112
|
+
let next = compact({ ...layout, groups })
|
|
113
|
+
if (active) {
|
|
114
|
+
const owner = ownerOf(next, active)
|
|
115
|
+
if (owner) next = focusGroup(next, owner.id, active)
|
|
116
|
+
}
|
|
117
|
+
return next
|
|
118
|
+
}
|
|
119
|
+
// --endregion
|
|
120
|
+
|
|
121
|
+
// --region Persistence and geometry
|
|
122
|
+
/** @public @author ddj 2026年10月14号 @param rect Floating bounds. @param width Viewport width. @param height Viewport height. @returns Visible, finite rectangle. */
|
|
123
|
+
export function clampFloat(rect: FloatRect, width: number, height: number): FloatRect {
|
|
124
|
+
const w = Math.max(1, width), h = Math.max(1, height)
|
|
125
|
+
const sizeW = Math.min(w, Math.max(Math.min(360, w), Number.isFinite(rect.width) ? rect.width : 600))
|
|
126
|
+
const sizeH = Math.min(h, Math.max(Math.min(240, h), Number.isFinite(rect.height) ? rect.height : 400))
|
|
127
|
+
return { width: sizeW, height: sizeH, left: Math.max(0, Math.min(w - sizeW, Number.isFinite(rect.left) ? rect.left : 0)), top: Math.max(0, Math.min(h - sizeH, Number.isFinite(rect.top) ? rect.top : 0)) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @private @author ddj 2026年10月14号 @param raw Unknown tree. @param ids Available nonfloating groups. @param seen Leaf uniqueness guard. @param depth Recursion budget. @returns Valid tree or null. */
|
|
131
|
+
function parseTree(raw: unknown, ids: Set<string>, seen: Set<string>, depth = 0): LayoutNode | null {
|
|
132
|
+
if (!raw || typeof raw !== 'object' || depth > GROUP_LIMIT) return null
|
|
133
|
+
const node = raw as Record<string, unknown>
|
|
134
|
+
if (typeof node.group === 'string') {
|
|
135
|
+
if (!ids.has(node.group) || seen.has(node.group)) return null
|
|
136
|
+
seen.add(node.group)
|
|
137
|
+
return { group: node.group }
|
|
138
|
+
}
|
|
139
|
+
if (node.axis !== 'horizontal' && node.axis !== 'vertical') return null
|
|
140
|
+
if (typeof node.ratio !== 'number' || !Number.isFinite(node.ratio)) return null
|
|
141
|
+
const first = parseTree(node.first, ids, seen, depth + 1)
|
|
142
|
+
const second = parseTree(node.second, ids, seen, depth + 1)
|
|
143
|
+
return first && second ? { axis: node.axis, ratio: Math.max(0.1, Math.min(0.9, node.ratio)), first, second } : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @public @author ddj 2026年10月14号 @param text Saved layout JSON. @param cwd Workspace path normalization. @returns Valid bounded layout or null for legacy fallback. */
|
|
147
|
+
export function parseLayout(text: string | null, cwd?: string | null): EditorLayout | null {
|
|
148
|
+
if (!text) return null
|
|
149
|
+
try {
|
|
150
|
+
const raw = JSON.parse(text)
|
|
151
|
+
if (raw?.v !== 1 || !Array.isArray(raw.groups) || !Array.isArray(raw.floats)) return null
|
|
152
|
+
if (!raw.groups.length || raw.groups.length > GROUP_LIMIT) return null
|
|
153
|
+
const seen = new Set<string>(), paths = new Set<string>()
|
|
154
|
+
const groups: Group[] = []
|
|
155
|
+
for (const item of raw.groups) {
|
|
156
|
+
if (typeof item?.id !== 'string' || !item.id || seen.has(item.id)) return null
|
|
157
|
+
seen.add(item.id)
|
|
158
|
+
const tabs = normalizeTabs(item.tabs, cwd)
|
|
159
|
+
if (tabs.some((tab) => paths.has(tab.path))) return null
|
|
160
|
+
for (const tab of tabs) paths.add(tab.path)
|
|
161
|
+
groups.push({ id: item.id, tabs, active: pickActive(tabs, typeof item.active === 'string' ? tabPathOf(item.active, cwd) : null) })
|
|
162
|
+
}
|
|
163
|
+
if (!seen.has(MAIN_GROUP)) return null
|
|
164
|
+
const floats: FloatGroup[] = [], floating = new Set<string>()
|
|
165
|
+
for (const item of raw.floats) {
|
|
166
|
+
if (!seen.has(item?.group) || item.group === MAIN_GROUP || floating.has(item.group)) return null
|
|
167
|
+
if (![item.left, item.top, item.width, item.height].every(Number.isFinite)) return null
|
|
168
|
+
floating.add(item.group)
|
|
169
|
+
floats.push({ group: item.group, ...clampFloat(item, 100000, 100000) })
|
|
170
|
+
}
|
|
171
|
+
const leaves = new Set<string>(), ids = new Set([...seen].filter((id) => !floating.has(id)))
|
|
172
|
+
const tree = parseTree(raw.tree, ids, leaves)
|
|
173
|
+
if (!tree || leaves.size !== ids.size) return null
|
|
174
|
+
const serial = Number.isSafeInteger(raw.serial) && raw.serial >= 0 ? raw.serial : 0
|
|
175
|
+
if (groups.some((group) => group.id !== MAIN_GROUP && (!/^group-\d+$/.test(group.id) || Number(group.id.slice(6)) > serial))) return null
|
|
176
|
+
return compact({ v: 1, tree, groups, floats, focused: seen.has(raw.focused) ? raw.focused : MAIN_GROUP, serial })
|
|
177
|
+
} catch { return null }
|
|
178
|
+
}
|
|
179
|
+
// --endregion
|