这是什么
本 issue 是 macOS 参考产品交互帧预算 的 tracking / 母任务,不是一次性重写 CodeEditorView 的 PR。
仓库里已经有过几轮针对性修复(分栏拖动合并、脏标记不每键广播、Git 图行级 Equatable、LitheSplitPaneView 把拖动尺寸留在容器内)。这些能消掉某些 hitch,改不了「SwiftUI 响应式树 + TextKit 富文本 + 热路径 O(N)」这条天花板 。
目标:把卡顿从「感觉修一处 UI」收成可拆、可验收、可回滚的子任务。Windows 不在范围内。
任务
谁拥有
不拥有
AppModel 拆分、scoped Feature、工具窗不再 @EnvironmentObject AppModel
#322
不写 GPU、不写高亮、不写 LSP 协议
输入 / 搜索弹层 / 全部分栏 / Git 图 / LSP 的帧预算
本 epic
不重开一套 AppModel 架构
扇出的实施见 #322 。本 epic 只加性能不变量:场景 T/N 下,已经 dirty 的按键不得触发 Workbench 重求值;scheduleObjectWillChangeRelay 在输入热路径上为 0。
不要按模块拆,按热路径拆
可弹出、可拖大小的表面很多(底栏 Git / Terminal / Run / Tests / Debug / Maven / Problems、侧栏工程树 / Changes / Search / Database、Git Log 内部三条分隔条、Diff 双栏、Search Everywhere 等)。它们共用同一套壳 ,不是每个模块都要单独重构绘制。
判断要不要新开子 issue 的尺子:
这个表面是不是有一条别人没有的 热路径(每键 / 每帧 / 每个 PTY chunk / 每次滚动)?
若只是「开着时跟着 AppModel 抖一下」——那是 #322 ,不要新 issue。
表面全清单(综合覆盖,不是只修 Git 图)
A. 工作台壳上的分栏(一次修好,所有工具窗受益)
WorkbenchView 自己有两条 SplitHandleView:
左侧栏宽度(工程树 / Changes / Search / Database / PR)
底部工具窗高度(Git Log / Terminal / Run / Tests / Problems / Maven / Spring / Debug 互斥叠在同一条底栏上)
拖终端、拖 Git、拖 Run,拖的是同一条底栏 。
B. 工具窗内部的分栏(issue 325 点名的 8 条 + Diff)
位置
条数
文件
Workbench 侧栏 + 底栏
2
WorkbenchView.swift
Git Log 内部(分支树 / 图 / 详情)
3
GitLogView.swift
Changes 提交区
1
ChangesSidebarView.swift
Run 配置列表
1
RunView.swift
Tests 列表
1
LanguageTestsView.swift
Git Worktrees
另有
GitWorktreesView.swift
Diff 双栏
1 套独立实现
DiffSplitPaneView.swift(尚未迁到 LitheSplitPaneView)
这些全部是 D1 的覆盖清单。少测一条,就会再出现「Git 修好了终端还阶梯」。
LitheSplitPaneView 已把「拖动尺寸留在容器、松手 onCommit」做成公共组件。D1 的工作是:所有可拖分栏都迁进去 ,禁止再把逐帧宽度写进 Feature @Published。
WorkbenchModuleUIRegistry 已用 ModuleToolContent: Equatable 避免底栏每帧因 AnyView 重求值。新 renderer 必须带 contentIdentity。
C. 弹层 / overlay
表面
类型
归属
Search Everywhere
overlay,每次 new 完整视图
S
Replace in Project
overlay
并进 S
Settings / Clone
sheet
#322 即可
Go to Line
dialog
非目标
Branch switcher / Git filter
popover
#322
Implementation chooser
overlay
先并进 S 或小修补
D. 底栏 / 侧栏模块怎么归类
模块
打开/切换顿
拖大小
内容绘制
工程树
#322
D1 侧栏
暂不单独开;大仓滚动若 SwiftUI 爆了再开
Changes
#322
D1
不单独开绘制
Search 侧栏
#322
D1
与 S 是两条:侧栏 vs 全局弹层
Database
#322 (模块激活)
D1
第一阶段不做
Git Log
#322
D1(外+内)
只有提交图 → D2
Terminal
#322
D1 底栏
已是 session.nativeView / AppKit,不开绘制引擎
Run / Maven / Tests / Debug
#322
D1
输出若整份刷,基线后再决定是否开 perf-output
Problems / References / Spring
#322
D1
列表,不是 16ms 热路径
Diff 审查
#322
DiffSplitPaneView → 迁入 D1
内容偏编辑器,严重再并进 T
History / GitHub PR
#322
侧栏或无
第一阶段不做
真正独有绘制的只有:编辑器(T) 、Git 图(D2) 、以及基线证明之后才可能出现的工程树/输出控制台。终端画面已经是保留模式 NSView,差的是 chrome 订了整个 AppModel。
根因(压缩保留,方案写在子 issue)
对比常见编辑器:
VS Code
IntelliJ IDEA
Lithe macOS
用户看到的壳
Electron / Chromium
IntelliJ Platform UI
SwiftUI + AppKit
真正决定输入手感的东西
Monaco:GPU canvas,只画可见行
EditorImpl:视口绘制 + 增量 Lexer
NSTextView + TextKit:通用富文本控件
输入热路径走不走 UI 框架 diff
不走
不走
走。任意 Feature 可能打醒整棵 Workbench
分词 / 高亮
Worker 里增量 TextMate / tree-sitter
增量 Lexer
主线程 NSRegularExpression,多趟扫文本
命令面板
常驻、预热、虚拟列表
索引预热
每次现场 new 一个完整 SwiftUI 弹层
搜索后端
独立进程 ripgrep
Indexes + Stub Index
JSON C ABI,首次还要 activateCapability
进程隔离
Renderer / Extension Host
IDE 进程 + 外部工具链
可选模块跑在应用进程内
按体感权重:
NSTextView + 主线程正则 + 每键整串拷贝(textDidChange → applyLiveEditorText(textView.string))
AppModel.scheduleObjectWillChangeRelay() 扇出到整棵 Workbench([重构] 按职责拆分 AppModel 并收敛 Git UI 边界 #322 )
SwiftUI 承担本该是保留视图的分栏 / Git 图 / overlay
JSON C ABI + LSP 全文 didChange 退路(initialize 里 synchronization: {})
模块冷启动、WebKit 冷启动、无 signpost
相关症状 issue(已关 ≠ 根因消失):#161 #162 #171 #187 #324 #325 。
子任务拆分(本 issue 只当母 issue)
#467 [epic] macOS 交互帧预算
├── #322 已有:AppModel / Workbench 所有权 ← 只链接
├── #perf-0 基线:fixture + signpost ← 第 0 周,先合、不合优化
├── #perf-T1 禁止每键 textView.string
├── #perf-T2 增量高亮(删全文注释正则)
├── #perf-T3 LSP incremental didChange + 诊断合帧
├── #perf-S Search Everywhere + Replace in Project 常驻/预热
├── #perf-D1 所有可拖分栏迁到 LitheSplitPaneView(含 Diff)
└── #perf-D2 Git 提交图改一块 NSView 脏矩形
GPU / 视口编辑器先不要建子 issue。 等 T1+T2 合完、500 KiB 输入仍常 > 33ms 且 Instruments 顶栈是 NSLayoutManager,再开 #perf-engine。
实施顺序(不可按模块兴趣并行开火):
perf-0 → #322 (可与 0 部分重叠) → T1 → T2 → T3 → S 与 D1/D2 可并行
T 系列必须串行。S 和 D 在 T1 合完后可以两人并行。终端 / Run / Tests 不另开绘制 issue ,它们吃 D1 + #322 。
每个子 issue 只拥有一个不变量,模板:不变量 / 改哪些文件 / 验收数 / 明确不改。禁止一个 PR 同时改高亮、分栏、Search 和 LSP。
第 0 周场景(先立尺子)
用提交进仓库的 fixture,不要拿每个人的 Spring 大仓当测试床。每场景 3 次中位数。Release 测体感,Debug 只看栈。
ID
场景
假设
T
10 KiB / 500 KiB / 2 MiB 连续输入 10 秒,停笔 2 秒
每键 O(N)、LSP、blame
D
连续拖:侧栏、底栏、Git Log 内部分隔条、Run、Tests、Diff
分栏尺寸打进大页面
S
冷启动后第一次双击 Shift 开/关 20 次;再热路径 20 次
overlay 创建、模块激活
N
打开 500 KiB 后空闲 10 秒
扇出、poll、FPS monitor
Term
yes 或狂吐日志 10 秒
若只有工具栏抖 → 只做 #322 ;若栈顶是 SwiftUI Text 再开 perf-output
R
Run/测试输出刷一屏
同 Term
埋点:editor.input / search.everywhere / split.drag / appmodel.relay。测量期 FrameRateMonitor 关掉或降到 2Hz(它每个 vsync 都 Task { @MainActor })。
非目标
不在一个 PR 里重写 234KB 的 CodeEditorView.swift
不改 JSON 命令名 / C ABI / 错误码 / 跨平台契约
不把 macOS 方案套到 Windows
不把 debounce 从 180ms 改成 50ms、不把 16ms 改成 8ms 当优化
不用 Preview / Debug 手感给 Release 关 issue
不为了跑分关掉高亮
不给 Terminal / Run / Maven / Database 各开一个「重构绘制引擎」
不把 [重构] 按职责拆分 AppModel 并收敛 Git UI 边界 #322 的 Phase 1–6 抄进本 issue
门禁(标题里「禁止继续走老路径」的可检查形式)
输入路径禁止 textView.string 全量拷贝
新分栏不得把逐帧尺寸写进 Feature @Published;必须用 LitheSplitPaneView(Diff 迁入同一组件)
Search / Replace overlay 不得在变为可见时 init 完整视图;空查询不 activateSearchModule()
诊断回灌不得触发 Workbench objectWillChange
2 MiB / 5 万行关掉语义高亮、LSP、blame
新底栏 renderer 必须提供 contentIdentity,禁止光 AnyView
验收(60 Hz、Release、500 KiB)
第一阶段(T/S/D + #322 ,不换引擎)成功定义:输入不再随文档线性恶化;全部 可拖分栏不再阶梯跟随;Shift-Shift 热路径进入两帧内。体感目标是从「1/3」到「1/2」,不是追平 VS Code。
第二阶段(仅当 T2 后数据证明 TextKit 到顶)才开视口/GPU 引擎 issue。
这是什么
本 issue 是 macOS 参考产品交互帧预算 的 tracking / 母任务,不是一次性重写
CodeEditorView的 PR。仓库里已经有过几轮针对性修复(分栏拖动合并、脏标记不每键广播、Git 图行级
Equatable、LitheSplitPaneView把拖动尺寸留在容器内)。这些能消掉某些 hitch,改不了「SwiftUI 响应式树 + TextKit 富文本 + 热路径 O(N)」这条天花板。目标:把卡顿从「感觉修一处 UI」收成可拆、可验收、可回滚的子任务。Windows 不在范围内。
和 #322 的区别
AppModel拆分、scoped Feature、工具窗不再@EnvironmentObject AppModel扇出的实施见 #322。本 epic 只加性能不变量:场景 T/N 下,已经 dirty 的按键不得触发 Workbench 重求值;
scheduleObjectWillChangeRelay在输入热路径上为 0。不要按模块拆,按热路径拆
可弹出、可拖大小的表面很多(底栏 Git / Terminal / Run / Tests / Debug / Maven / Problems、侧栏工程树 / Changes / Search / Database、Git Log 内部三条分隔条、Diff 双栏、Search Everywhere 等)。它们共用同一套壳,不是每个模块都要单独重构绘制。
判断要不要新开子 issue 的尺子:
表面全清单(综合覆盖,不是只修 Git 图)
A. 工作台壳上的分栏(一次修好,所有工具窗受益)
WorkbenchView自己有两条SplitHandleView:拖终端、拖 Git、拖 Run,拖的是同一条底栏。
B. 工具窗内部的分栏(issue 325 点名的 8 条 + Diff)
WorkbenchView.swiftGitLogView.swiftChangesSidebarView.swiftRunView.swiftLanguageTestsView.swiftGitWorktreesView.swiftDiffSplitPaneView.swift(尚未迁到LitheSplitPaneView)这些全部是 D1 的覆盖清单。少测一条,就会再出现「Git 修好了终端还阶梯」。
LitheSplitPaneView已把「拖动尺寸留在容器、松手onCommit」做成公共组件。D1 的工作是:所有可拖分栏都迁进去,禁止再把逐帧宽度写进 Feature@Published。WorkbenchModuleUIRegistry已用ModuleToolContent: Equatable避免底栏每帧因AnyView重求值。新 renderer 必须带contentIdentity。C. 弹层 / overlay
new完整视图D. 底栏 / 侧栏模块怎么归类
session.nativeView/ AppKit,不开绘制引擎perf-output真正独有绘制的只有:编辑器(T)、Git 图(D2)、以及基线证明之后才可能出现的工程树/输出控制台。终端画面已经是保留模式 NSView,差的是 chrome 订了整个
AppModel。根因(压缩保留,方案写在子 issue)
对比常见编辑器:
EditorImpl:视口绘制 + 增量 LexerNSTextView+ TextKit:通用富文本控件NSRegularExpression,多趟扫文本activateCapability按体感权重:
NSTextView+ 主线程正则 + 每键整串拷贝(textDidChange→applyLiveEditorText(textView.string))AppModel.scheduleObjectWillChangeRelay()扇出到整棵 Workbench([重构] 按职责拆分 AppModel 并收敛 Git UI 边界 #322)didChange退路(initialize里synchronization: {})相关症状 issue(已关 ≠ 根因消失):#161 #162 #171 #187 #324 #325。
子任务拆分(本 issue 只当母 issue)
#467 [epic] macOS 交互帧预算
├── #322 已有:AppModel / Workbench 所有权 ← 只链接
├── #perf-0 基线:fixture + signpost ← 第 0 周,先合、不合优化
├── #perf-T1 禁止每键 textView.string
├── #perf-T2 增量高亮(删全文注释正则)
├── #perf-T3 LSP incremental didChange + 诊断合帧
├── #perf-S Search Everywhere + Replace in Project 常驻/预热
├── #perf-D1 所有可拖分栏迁到 LitheSplitPaneView(含 Diff)
└── #perf-D2 Git 提交图改一块 NSView 脏矩形
GPU / 视口编辑器先不要建子 issue。 等 T1+T2 合完、500 KiB 输入仍常 > 33ms 且 Instruments 顶栈是
NSLayoutManager,再开#perf-engine。实施顺序(不可按模块兴趣并行开火):
perf-0 → #322(可与 0 部分重叠) → T1 → T2 → T3 → S 与 D1/D2 可并行
T 系列必须串行。S 和 D 在 T1 合完后可以两人并行。终端 / Run / Tests 不另开绘制 issue,它们吃 D1 + #322。
每个子 issue 只拥有一个不变量,模板:不变量 / 改哪些文件 / 验收数 / 明确不改。禁止一个 PR 同时改高亮、分栏、Search 和 LSP。
第 0 周场景(先立尺子)
用提交进仓库的 fixture,不要拿每个人的 Spring 大仓当测试床。每场景 3 次中位数。Release 测体感,Debug 只看栈。
yes或狂吐日志 10 秒perf-output埋点:
editor.input/search.everywhere/split.drag/appmodel.relay。测量期FrameRateMonitor关掉或降到 2Hz(它每个 vsync 都Task { @MainActor })。非目标
CodeEditorView.swift门禁(标题里「禁止继续走老路径」的可检查形式)
textView.string全量拷贝@Published;必须用LitheSplitPaneView(Diff 迁入同一组件)init完整视图;空查询不activateSearchModule()objectWillChangecontentIdentity,禁止光AnyView验收(60 Hz、Release、500 KiB)
第一阶段(T/S/D + #322,不换引擎)成功定义:输入不再随文档线性恶化;全部可拖分栏不再阶梯跟随;Shift-Shift 热路径进入两帧内。体感目标是从「1/3」到「1/2」,不是追平 VS Code。
perf-0基线表贴在本 issueAppModel.objectWillChange< 5@EnvironmentObject AppModel吃全树刷新([重构] 按职责拆分 AppModel 并收敛 Git UI 边界 #322)./scripts/test-macos.sh、./scripts/verify-service-boundaries.sh、./scripts/verify-git-graph.sh通过第二阶段(仅当 T2 后数据证明 TextKit 到顶)才开视口/GPU 引擎 issue。