287 lines
10 KiB
Markdown
287 lines
10 KiB
Markdown
|
|
# NewEditor Window Workspace Single-Source Refactor Plan
|
|||
|
|
|
|||
|
|
Date: 2026-04-22
|
|||
|
|
Status: In Progress
|
|||
|
|
|
|||
|
|
## Progress
|
|||
|
|
|
|||
|
|
Completed on 2026-04-22:
|
|||
|
|
|
|||
|
|
1. `EditorWindowWorkspaceCoordinator::BuildWorkspaceMutationController()` no longer reads mutation input from `EditorWindowWorkspaceStore` mirror
|
|||
|
|
2. mutation input is now built from live `EditorWindow` controllers at call time
|
|||
|
|
3. Debug build of `XCUIEditorApp` passed
|
|||
|
|
4. manual verification passed for:
|
|||
|
|
- detach
|
|||
|
|
- re-dock
|
|||
|
|
- open / close baseline behavior
|
|||
|
|
- detached title baseline behavior
|
|||
|
|
|
|||
|
|
Next:
|
|||
|
|
|
|||
|
|
1. split detached title refresh from frame-time projection
|
|||
|
|
2. then remove the remaining per-frame mirror commit path
|
|||
|
|
## 1. Objective
|
|||
|
|
|
|||
|
|
这份计划只处理 `new_editor` 当前最严重的一处结构性冗余:
|
|||
|
|
|
|||
|
|
1. 每个 `EditorWindow` 已经各自持有一份 live `UIEditorWorkspaceController`
|
|||
|
|
2. `EditorWindowWorkspaceStore` 又额外持有一份完整的 `UIEditorWindowWorkspaceSet`
|
|||
|
|
3. 宿主层每帧把前者整份投影回后者
|
|||
|
|
4. 跨窗口操作再从后者反向重建 controller 灌回窗口
|
|||
|
|
|
|||
|
|
本计划的目标不是“看起来更抽象”,而是把窗口 workspace 的单一事实源收回到 live window,自根源消除双写状态同步环,同时不破坏以下现有行为:
|
|||
|
|
|
|||
|
|
1. 分离窗口打开与关闭
|
|||
|
|
2. Tab 跨窗口拖拽
|
|||
|
|
3. 面板 detach / re-dock
|
|||
|
|
4. detached tool window 标题与尺寸策略
|
|||
|
|
5. 现有 `UIEditorWindowWorkspaceController` 上的跨窗口变换语义
|
|||
|
|
|
|||
|
|
## 2. Confirmed Problem
|
|||
|
|
|
|||
|
|
当前重复链路已经形成完整闭环:
|
|||
|
|
|
|||
|
|
1. live state:
|
|||
|
|
- `EditorWindowRuntimeController::m_workspaceController`
|
|||
|
|
2. mirrored state:
|
|||
|
|
- `EditorWindowWorkspaceStore::m_windowSet`
|
|||
|
|
3. frame-time projection:
|
|||
|
|
- `EditorWindowHostRuntime::RenderAllWindows(...)`
|
|||
|
|
- `EditorWindowWorkspaceCoordinator::CommitWindowProjection(...)`
|
|||
|
|
4. mutation-time reverse application:
|
|||
|
|
- `EditorWindowWorkspaceCoordinator::BuildWorkspaceMutationController()`
|
|||
|
|
- `EditorWindowWorkspaceCoordinator::SynchronizeWindowsFromWindowSet(...)`
|
|||
|
|
|
|||
|
|
这不是普通 cache,而是双向同步的第二套真相源。
|
|||
|
|
|
|||
|
|
## 3. Root Cause
|
|||
|
|
|
|||
|
|
根因不在某一个函数,而在宿主层把“跨窗口协调”实现成了“跨窗口状态镜像”。
|
|||
|
|
|
|||
|
|
当前设计默认:
|
|||
|
|
|
|||
|
|
1. window 内的 live controller 负责真实交互与更新
|
|||
|
|
2. store 内的 window set 负责跨窗口 mutation 的输入
|
|||
|
|
3. 二者通过每帧投影保持近似一致
|
|||
|
|
|
|||
|
|
这导致:
|
|||
|
|
|
|||
|
|
1. 同一语义的 workspace/session 状态有两份 owner
|
|||
|
|
2. 每帧发生整份复制和整套验证
|
|||
|
|
3. mutation 逻辑被迫依赖 mirror,而不是直接面向 live windows
|
|||
|
|
4. 标题刷新、窗口集合描述、关闭路径,也被顺带绑在 mirror 上
|
|||
|
|
|
|||
|
|
## 4. Why This Is The Most Serious Redundancy
|
|||
|
|
|
|||
|
|
这处问题同时命中四个严重特征:
|
|||
|
|
|
|||
|
|
1. 同一职责重复实现
|
|||
|
|
- live window controller 和 mirrored window set 都在表达同一份窗口 workspace 状态
|
|||
|
|
2. 同一帧重复计算
|
|||
|
|
- 每帧 `CommitWindowProjection(...)` 都会复制 `m_windowSet`、覆写某个窗口状态、再验证整套 window set
|
|||
|
|
3. 结构扩散
|
|||
|
|
- 打开窗口、关闭窗口、跨窗口拖拽、标题刷新都被迫围绕 mirror 组织
|
|||
|
|
4. 没有独立 owner
|
|||
|
|
- `EditorWindowWorkspaceStore` 并没有独立业务语义,只是在宿主层替 live state 保存一份副本
|
|||
|
|
|
|||
|
|
## 5. Red Lines
|
|||
|
|
|
|||
|
|
这次重构明确禁止以下做法:
|
|||
|
|
|
|||
|
|
1. 只删除 `CommitWindowProjection(...)`,但继续让 mutation 从旧 store 读数据
|
|||
|
|
2. 先删 store,再临时把跨窗口逻辑散落回 `EditorWindowWorkspaceCoordinator`
|
|||
|
|
3. 改写 `UIEditorWindowWorkspaceController` 的跨窗口变换语义
|
|||
|
|
4. 修改 dock / transfer / extract 的业务规则,只为了配合结构调整
|
|||
|
|
5. 以“后面再补验证”为理由跳过 detached window / global tab drag 的回归验证
|
|||
|
|
|
|||
|
|
其中第 1 条最危险。它一定会造成功能回归,因为当前 detached 窗口标题刷新和 mutation 输入都还绑在 projection 路径上。
|
|||
|
|
|
|||
|
|
## 6. Target End State
|
|||
|
|
|
|||
|
|
目标终态必须满足:
|
|||
|
|
|
|||
|
|
1. live `EditorWindow` 持有的 `UIEditorWorkspaceController` 成为唯一可变真相源
|
|||
|
|
2. 跨窗口 mutation 需要的 `UIEditorWindowWorkspaceSet` 改为按需从 live windows 现采样构建
|
|||
|
|
3. `EditorWindowWorkspaceStore` 不再保存整份 `workspace/session`
|
|||
|
|
4. detached 窗口标题刷新从 projection 逻辑中独立出来
|
|||
|
|
5. primary / active / existing window 集合信息只保留真正被消费的最小元数据
|
|||
|
|
|
|||
|
|
允许两种落地形式:
|
|||
|
|
|
|||
|
|
1. 保留 `EditorWindowWorkspaceStore`,但只保留窗口元数据和注册信息,不再保存 workspace/session
|
|||
|
|
2. 完全删除 `EditorWindowWorkspaceStore`,由 coordinator 直接从 host runtime 构建 snapshot
|
|||
|
|
|
|||
|
|
## 7. Refactor Strategy
|
|||
|
|
|
|||
|
|
### Phase A. Freeze semantics and add live snapshot builder
|
|||
|
|
|
|||
|
|
先新增从 live windows 构建 `UIEditorWindowWorkspaceSet` 的单一路径,例如:
|
|||
|
|
|
|||
|
|
1. `BuildLiveWindowWorkspaceSnapshot()`
|
|||
|
|
2. 或 `BuildWorkspaceMutationControllerFromLiveWindows()`
|
|||
|
|
|
|||
|
|
要求:
|
|||
|
|
|
|||
|
|
1. snapshot 必须完整覆盖所有活跃窗口
|
|||
|
|
2. 每个窗口的 workspace/session 直接来自 live controller
|
|||
|
|
3. primary window id 必须来自 live host state
|
|||
|
|
4. active window id 只保留当前真实需要的最小语义
|
|||
|
|
|
|||
|
|
这一步先增加,不删旧路径。
|
|||
|
|
|
|||
|
|
### Phase B. Make mutation read from live snapshot instead of store mirror
|
|||
|
|
|
|||
|
|
把以下入口改为依赖 live snapshot:
|
|||
|
|
|
|||
|
|
1. `BuildWorkspaceMutationController()`
|
|||
|
|
2. `TryStartGlobalTabDrag(...)`
|
|||
|
|
3. `TryProcessDetachRequest(...)`
|
|||
|
|
4. `TryProcessOpenDetachedPanelRequest(...)`
|
|||
|
|
5. cross-window drop commit path
|
|||
|
|
|
|||
|
|
要求:
|
|||
|
|
|
|||
|
|
1. mutation 前只从 live windows 取状态
|
|||
|
|
2. mutation 后仍复用 `SynchronizeWindowsFromWindowSet(...)` 回灌窗口
|
|||
|
|
3. 在这一阶段仍允许 store 暂时存在,但不能再是 mutation 输入源
|
|||
|
|
|
|||
|
|
### Phase C. Split title refresh from projection
|
|||
|
|
|
|||
|
|
当前 `RefreshWindowTitle(...)` 被挂在 `CommitWindowProjection(...)` 后面,这是一种错误耦合。
|
|||
|
|
|
|||
|
|
必须单独拆出标题同步路径:
|
|||
|
|
|
|||
|
|
1. detached 窗口标题直接基于 live controller 刷新
|
|||
|
|
2. 标题刷新继续允许 frame-time 执行
|
|||
|
|
3. 但标题刷新不能再隐含 workspace mirror commit
|
|||
|
|
|
|||
|
|
完成后,删除“为了标题而保留每帧 projection”的借口。
|
|||
|
|
|
|||
|
|
### Phase D. Remove mirrored workspace/session from store
|
|||
|
|
|
|||
|
|
在 mutation 输入已经切换到 live snapshot 后:
|
|||
|
|
|
|||
|
|
1. 删除 `EditorWindowWorkspaceStore` 中的 `workspace/session` 镜像
|
|||
|
|
2. 删除 `BuildWindowState(...)` 这类整份复制逻辑
|
|||
|
|
3. 删除每帧 `CommitWindowProjection(...)`
|
|||
|
|
4. 删除围绕 projection 的整套验证与回退路径
|
|||
|
|
|
|||
|
|
如果此时 store 还存在,它只能保存:
|
|||
|
|
|
|||
|
|
1. 已注册窗口 id
|
|||
|
|
2. primary window id
|
|||
|
|
3. 可能仍有意义的轻量元数据
|
|||
|
|
|
|||
|
|
## 8. Functional Risk Analysis
|
|||
|
|
|
|||
|
|
这次重构不是零风险,但风险集中且可控。
|
|||
|
|
|
|||
|
|
### 8.1 Real risk: title synchronization
|
|||
|
|
|
|||
|
|
如果直接删除每帧 projection,而不拆标题刷新:
|
|||
|
|
|
|||
|
|
1. detached 窗口标题可能停留在旧 active panel
|
|||
|
|
2. 同一窗口内切 tab 后标题可能不再更新
|
|||
|
|
|
|||
|
|
因此标题刷新必须先和 projection 解耦,再删 projection。
|
|||
|
|
|
|||
|
|
### 8.2 Real risk: mutation input staleness
|
|||
|
|
|
|||
|
|
如果 mutation 仍从 store 读状态,而 store 不再每帧同步:
|
|||
|
|
|
|||
|
|
1. detach 可能从旧 workspace 提取 panel
|
|||
|
|
2. cross-window drop 可能基于过期 session 判断 panel owner
|
|||
|
|
3. open detached panel 可能错误判断某 panel 已经打开或未打开
|
|||
|
|
|
|||
|
|
因此必须先引入 live snapshot builder,再切 mutation 输入。
|
|||
|
|
|
|||
|
|
### 8.3 Medium risk: lifecycle and close path assumptions
|
|||
|
|
|
|||
|
|
当前 `IsPrimaryWindowId(...)`、`DescribeWindowSet()`、`RemoveWindowProjection(...)` 都依赖 store。
|
|||
|
|
|
|||
|
|
需要逐条确认:
|
|||
|
|
|
|||
|
|
1. 哪些只用于日志
|
|||
|
|
2. 哪些只用于关闭路径
|
|||
|
|
3. 哪些真影响行为
|
|||
|
|
|
|||
|
|
如果不区分,容易把“日志依赖”误当成“结构依赖”。
|
|||
|
|
|
|||
|
|
### 8.4 Low risk: cross-window mutation algorithm itself
|
|||
|
|
|
|||
|
|
`UIEditorWindowWorkspaceController` 和 `UIEditorWorkspaceTransfer` 负责的是跨窗口 panel 变换语义。
|
|||
|
|
|
|||
|
|
本计划不改:
|
|||
|
|
|
|||
|
|
1. extract 规则
|
|||
|
|
2. insert 规则
|
|||
|
|
3. dock 规则
|
|||
|
|
4. detached workspace 生成规则
|
|||
|
|
|
|||
|
|
因此只要 mutation 输入快照正确,这一层不是主要风险源。
|
|||
|
|
|
|||
|
|
## 9. Why This Refactor Is Actually Simplifying
|
|||
|
|
|
|||
|
|
它不是表面简化,而是实质上减少状态 owner。
|
|||
|
|
|
|||
|
|
完成后会得到:
|
|||
|
|
|
|||
|
|
1. 窗口 workspace 只在 live window 内持有一份
|
|||
|
|
2. mutation 输入不再依赖长期镜像,而是按需快照
|
|||
|
|
3. frame path 不再做整份 workspace/session 复制与整套 validate
|
|||
|
|
4. coordinator 的职责变成:
|
|||
|
|
- 从 live windows 取快照
|
|||
|
|
- 调用 mutation controller
|
|||
|
|
- 把 mutation 结果同步回 live windows
|
|||
|
|
5. title refresh 变成一个独立的小职责,而不是夹带在 projection 里
|
|||
|
|
|
|||
|
|
也就是说,复杂度不是被藏起来,而是真的减少了一层。
|
|||
|
|
|
|||
|
|
## 10. Why This Refactor Is Root-Cause-Oriented
|
|||
|
|
|
|||
|
|
它之所以是从根源出发,是因为它处理的是“谁拥有窗口 workspace 真相”。
|
|||
|
|
|
|||
|
|
如果不解决 owner 问题,只做局部删减,最终都会回到:
|
|||
|
|
|
|||
|
|
1. 某处还需要一份 mirror
|
|||
|
|
2. 某帧还得再同步一次
|
|||
|
|
3. 某条宿主功能又悄悄绑回 projection
|
|||
|
|
|
|||
|
|
而这份计划直接把真相源收回 live window,把跨窗口 controller 降级为临时 snapshot consumer,这才是对根因动刀。
|
|||
|
|
|
|||
|
|
## 11. Validation Requirements
|
|||
|
|
|
|||
|
|
没有以下验证,不允许落地删除 projection:
|
|||
|
|
|
|||
|
|
1. detached window 内切换 active panel 后,窗口标题实时更新
|
|||
|
|
2. primary window detach panel 正常创建新窗口
|
|||
|
|
3. detached window 再次拖拽 panel 发起 global tab drag 正常
|
|||
|
|
4. 跨窗口 drop 到 tab stack 和 relative dock 都正常
|
|||
|
|
5. 已打开 tool window 的 panel 重复打开请求仍然正确复用
|
|||
|
|
6. 关闭 detached window 后,其余窗口状态保持正确
|
|||
|
|
7. 关闭 primary window 时,其他窗口生命周期不异常
|
|||
|
|
8. `XCUIEditorApp` Debug 构建通过
|
|||
|
|
|
|||
|
|
## 12. Completion Criteria
|
|||
|
|
|
|||
|
|
只有满足以下条件,这次重构才算真正完成:
|
|||
|
|
|
|||
|
|
1. `EditorWindowWorkspaceStore` 不再持有整份 `workspace/session` 镜像
|
|||
|
|
2. `EditorWindowHostRuntime::RenderAllWindows(...)` 不再每帧调用 workspace projection commit
|
|||
|
|
3. 所有跨窗口 mutation 都从 live snapshot 构建输入
|
|||
|
|
4. detached 标题刷新与 projection 彻底解耦
|
|||
|
|
5. 现有 detach / drag / re-dock / close 行为无回归
|
|||
|
|
|
|||
|
|
## 13. Final Statement
|
|||
|
|
|
|||
|
|
这次重构不能靠“删几行同步代码”完成。
|
|||
|
|
|
|||
|
|
真正安全、真正彻底的路径只有一条:
|
|||
|
|
|
|||
|
|
1. 先建立 live snapshot 输入
|
|||
|
|
2. 再把 mutation 输入切过去
|
|||
|
|
3. 再拆标题刷新
|
|||
|
|
4. 最后删除 mirror
|
|||
|
|
|
|||
|
|
只有按这个顺序,才能既不破坏功能,又从根上消灭这套双写宿主残片。
|