gpui-kit 的 History 与 UndoHistory 拆分:一次编译安全的导航/撤销历史 API 重构计划
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
本文围绕 gpui-kit 仓库中的实施计划 History and UndoHistory Split Implementation Plan 展开:它记录了如何将 gpui-base 中一个职责过载的公共历史类型,拆分为浏览器风格的导航轨迹History<T>与分组化的撤销/重做日志UndoHistory<T>,并在原子任务中迁移 NavStack、Dock 与 Input 等全部消费方。读完本文,你能掌握该拆分的完整动机、两个新类型的事务级语义、每个迁移步骤的 RED/GREEN 测试策略,以及仓库中对应源码的最终落地形态。
背景与目标:为什么要拆
拆分计划给出的目标(Goal)非常明确:用浏览器风格的History<T>和分组化的UndoHistory<T>替换过载的公共历史类型,并迁移仓库内每一个消费方(Replace the overloaded public history type with a browser-styleHistory<T>and a groupedUndoHistory<T>, then migrate every in-repository consumer)。
配套的设计文档 History and UndoHistory Split 解释了拆分动机:
- 旧的公共
History<I>本质是一个 undo/redo 存储,要求每个条目携带版本号(version),并暴露分组(grouping)、忽略(ignore)、undo 栈、redo 栈等概念; - Input 组件早已不再使用它——Input 拥有自己的事务感知
UndoManager(见 crates/base/src/input/base/undo_manager.rs); - Dock 把
History<TileChange>当作撤销存储使用,而 NavStack 却把它当作导航轨迹使用,把undo翻译成pop、把redo翻译成forward; - 这两种用法具有不同的契约:撤销(undo)返回“跨过的变更”以便调用者反向或重放;导航(navigation)返回“到达的位置”且必须保留根节点。仅仅重命名方法会掩盖这种错配。
因此设计决策是一次破坏性 API 重构:从gpui-base与旧的gpui-component::history模块公开两个相互独立的数据结构,同时删除旧的HistoryItemtrait 与旧 API,且不保留任何 deprecated 兼容别名。
架构策略上,计划采用“先加后换”的顺序,把风险隔离在两个原子任务里:
- 先添加
UndoHistory<T>,不触碰现有类型; - 再在同一个编译安全(compile-safe)任务中替换
History<T>,并一次性迁移 NavStack、Dock、Input 与兼容导出。
History负责持有“根到当前”的栈与“最近一次”的前向栈;UndoHistory负责持有事务向量与全部分组元数据。技术栈为 Rust 2024、instant库、GPUI 单元/组件测试与 VitePress 文档。
全局约束(Global Constraints)
计划为整个分支设定了不可违反的约束:
History<T>与UndoHistory<T>都必须从gpui-base和旧的gpui-component::history路径公开;- 移除
HistoryItem与旧的重载 API,不留 deprecated 别名; History::back与History::forward返回“目的地条目”(destination entry);History::back永远不会移除根节点;UndoHistory::undo按“最新优先”(newest-first)返回;redo按“最旧优先”(oldest-first)返回;- 两种数据结构都不引入任何新依赖;
- 本 PR 不修改同家族的
longbridge-gpui仓库。
Task 1:添加分组化的 UndoHistory
涉及文件:
- 新建:crates/base/src/undo_history.rs
- 修改:crates/base/src/lib.rs
接口:消费instant::{Duration, Instant};产出公共UndoHistory<T>,包含new、max_undos、group_interval、push、undo、redo、can_undo、can_redo、start_grouping、end_grouping、is_ignoring、set_ignoring、clear。
Step 1:先写失败的事务与顺序测试
计划要求先用测试模块和一个“刻意不完整”的类型声明来创建undo_history.rs,让测试能编译到足以证明缺失方法。测试断言的是一条字面顺序契约:
let mut history = UndoHistory::new(); history.start_grouping(); history.push(1); history.push(2); history.push(3); history.end_grouping(); assert_eq!(history.undo(), Some(vec![3, 2, 1])); assert_eq!(history.redo(), Some(vec![1, 2, 3]));此外还要补充独立测试,分别证明:未分组的 push 各自形成独立事务;group_interval(Duration::from_secs(60))会把紧邻的 push 合并;新的 push 会清空 redo;ignore 模式丢弃 push;clear清空双向;max_undos(2)淘汰最旧事务;max_undos(0)不保留任何事务。
Step 2:运行测试并确认 RED
cargo test -p gpui-base undo_history::tests --lib预期:由于UndoHistory的方法刻意缺失,编译失败——以此证明新 API 尚未实现。
Step 3:实现“事务持有”的分组
计划特别强调:元数据应放在事务存储层,而不是塞进T(也就是说不需要T实现伴生 trait 或携带 version 字段):
#[derive(Debug)] pub struct UndoHistory<T> { undos: Vec<Vec<T>>, redos: Vec<Vec<T>>, last_changed_at: Instant, max_undos: usize, group_interval: Option<Duration>, grouping: bool, ignoring: bool, }push只在显式分组激活、或配置的时间间隔未超时时追加到上一个事务,否则创建新事务;只有“被记录”的 push 才清空 redo。undo把存储的事务移到 redo 侧并返回反转克隆;redo移回并返回最旧优先的克隆。Default实现为new,零上限(zero limit)不记录任何内容。
Step 4/5:导出并确认 GREEN,随后提交
在 crates/base/src/lib.rs 中声明模块并导出UndoHistory,再次运行上述测试命令,预期所有事务、顺序、分组、忽略与容量测试通过且无警告,然后提交:
git add crates/base/src/undo_history.rs crates/base/src/lib.rs git commit -m "base: add grouped UndoHistory"Task 2:替换 History 并原子迁移消费方
涉及文件:
- 替换:crates/base/src/history.rs
- 修改:crates/base/src/nav_stack.rs
- 修改:crates/base/src/dock/tiles_state.rs
- 修改:crates/base/src/dock/tiles_geometry.rs
- 修改:crates/base/src/input/base/change.rs
- 修改:crates/base/src/lib.rs
- 修改:crates/component/src/history.rs
- 修改:crates/component/tests/base_compat.rs
接口:消费 Task 1 的UndoHistory<T>;产出导航型History<T>,包含new、max_entries、push、current、replace_current、remove_current、can_back、can_forward、back、forward、entries、forward_entries、retain、clear;迁移后的 NavStack 与 Dock;以及仓库中任何位置都不再出现HistoryItem。
Step 1:把 History 与兼容测试改为新契约
用整数条目替换旧的HistoryItem夹具,覆盖这条完整轨迹:
let mut history = History::new().max_entries(3); history.push(1); history.push(2); history.push(3); assert_eq!(history.current(), Some(&3)); assert_eq!(history.back(), Some(2)); assert_eq!(history.back(), Some(1)); assert_eq!(history.back(), None); assert_eq!(history.forward(), Some(2)); assert_eq!(history.entries().copied().collect::<Vec<_>>(), [1, 2]); assert_eq!(history.entries().rev().copied().collect::<Vec<_>>(), [2, 1]); assert_eq!(history.forward_entries().copied().collect::<Vec<_>>(), [3]);另需独立测试:前向分支截断、重复的1 -> 2 -> 1条目、容量淘汰、零容量、replace、remove、两侧栈上的 retain、clear。在 crates/component/tests/base_compat.rs 中,还要编译验证两条旧的再导出路径:
let _: gpui_component::history::History<u8> = gpui_base::History::new(); let _: gpui_component::history::UndoHistory<u8> = gpui_base::UndoHistory::new();Step 2:先确认 RED
cargo test -p gpui-base history::tests --lib预期:由于max_entries、back、forward、entries等导航方法尚不存在,编译失败。
Step 3:实现导航型 History
#[derive(Debug)] pub struct History<T> { entries: Vec<T>, forward_entries: Vec<T>, // nearest entry is last max_entries: usize, } pub fn back(&mut self) -> Option<T> where T: Clone, { if self.entries.len() <= 1 { return None; } self.forward_entries.push(self.entries.pop().unwrap()); self.current().cloned() } pub fn forward(&mut self) -> Option<T> where T: Clone, { let entry = self.forward_entries.pop()?; self.entries.push(entry); self.current().cloned() }两个迭代方法返回impl DoubleEndedIterator<Item = &T> + ExactSizeIterator;实现Default。push时清空前向条目、零容量时跳过存储、到达上限时淘汰最旧条目。retain过滤两侧栈但不重排;remove_current只移除当前条目且保留前向条目。
Step 4:迁移 NavStack 到导航语义
移除HistoryItem for NavEntry及其 version。改用entries().len()、entries()、forward_entries()做检查。pop在调用history.back()之前先克隆出栈顶,返回这个“离开的视图”;forward使用history.forward()返回的目的地;pop_to_root的每次迭代都记录离开的视图,同时由back()选择目的地。
Step 5:迁移 Dock 与 Input 到拆分后的结构
把 Dock 画布的 tile 字段改为UndoHistory<TileChange>,保留其 100 ms 的分组间隔与公共 undo/redo 行为。移除TileChange.version及其 trait 实现;移除 Input 侧Change.version、其过期的HistoryItem实现及未使用的导入。
Step 6:收尾导出并确认 GREEN
从gpui-base导出History与UndoHistory,从gpui-component::history再导出两者,然后运行一组验证命令:
cargo test -p gpui-base history::tests --lib cargo test -p gpui-base nav_stack --lib cargo test -p gpui-base dock --lib cargo test -p gpui-component --test base_compat legacy_history_path_reexports_the_base_type rg -n "HistoryItem|\.undos\(|\.redos\(" crates --glob '*.rs'预期:全部测试通过,且最后的全文搜索无任何结果——这证明HistoryItem与旧 API 已从整个仓库消失。
Step 7:提交原子迁移
git add crates/base/src/history.rs crates/base/src/nav_stack.rs crates/base/src/dock/tiles_state.rs crates/base/src/dock/tiles_geometry.rs crates/base/src/input/base/change.rs crates/base/src/lib.rs crates/ui/src/history.rs crates/ui/tests/base_compat.rs git commit -m "base: split navigation and undo history"Task 3:重写文档并验证分支
涉及文件:crates/base/README.md、website/base/history.md、website/zh-CN/base/history.md。
接口:消费 Task 1 与 Task 2 的最终 API;产出与两个类型匹配的英文/中文公共文档,以及最终 PR 证据。
文档重写要求:
- 先以“浏览器风格轨迹”写
History,用A -> B -> C的例子展示back()返回B;说明entries()与entries().rev()的顺序;再单独写UndoHistory,给出分组拖拽示例与 newest-first undo / oldest-first redo;删除所有对HistoryItem、unique与 MRU 行为的引用;更新 README 目录行,同时列出两个类型及各自用途。 - 运行新的格式与定向验证:
cargo fmt --all -- --check cargo test -p gpui-base history::tests --lib cargo test -p gpui-base undo_history::tests --lib cargo test -p gpui-base nav_stack --lib cargo test -p gpui-base dock --lib cargo test -p gpui-component --test base_compat预期所有命令退出码为零且无失败测试。
- 运行宽范围编译门禁:
cargo check --workspace --all-targets git diff --check origin/main...HEAD- 提交文档:
git add crates/base/README.md website/base/history.md website/zh-CN/base/history.md git commit -m "docs: distinguish navigation and undo history"- 准备独立 PR:推送
history-split分支,向longbridge/gpui-kit:main发起 PR,总结破坏性 API 拆分、消费方迁移与验证;确认 PR 起点是 #2922 的 squash merge0c746dff,且不含原nav-stack分支的任何提交。
仓库落地现状:源码中的最终形态
从源码看,上述计划已在当前仓库中完整落地,各关键事实均可在源码中直接验证。
History:浏览器风格轨迹的完整实现
crates/base/src/history.rs 中的最终结构与计划一致,仅含三个私有字段entries、forward_entries、max_entries,T不携带任何 version 或伴生 trait:
new()默认max_entries为1000(history.rs);max_entries(n)是 builder 风格方法,降低上限会立即淘汰最旧条目(enforce_max_entries,history.rs);push先清空forward_entries(对应“浏览器打开新页面丢弃前进分支”),零容量时直接返回,从而把零上限变成无操作的 no-op 而不是 panic;back()在entries.len() <= 1时返回None,因此根节点永不被移除;forward()在零容量时返回None,恢复前向条目后会重新执行enforce_max_entries,即“在上限时前进会先淘汰最旧激活条目”(该行为由测试lowering_max_entries_truncates_populated_entries_and_caps_forward_restores覆盖,history.rs);replace_current在空栈上退化为 push;remove_current只弹出当前条目且不动前向分支;retain对两侧栈分别过滤而不重排。
该文件内置的单元测试(history.rs)与计划 Step 1 列出的清单一一对应:导航不越过根节点、前进分支截断、重复条目保留(A -> B -> A)、max_entries淘汰、零容量、replace、remove、retain 与 clear。其中navigation_moves_between_entries_without_backing_past_the_root测试就是计划中那段max_entries(3)轨迹断言的原文。
UndoHistory:事务向量的分组实现
crates/base/src/undo_history.rs 的最终实现与计划的结构体一致,其中last_changed_at在最终代码中是Option<Instant>——只有存在时间分组语义时才有意义。核心逻辑:
push先检查ignoring与max_undos == 0(两者都直接丢弃);分组判定为“显式分组激活,或last_changed_at距现在不超过group_interval”(undo_history.rs);undo弹出事务后先反转克隆再入 redo 栈,并置空last_changed_at——这正是“成功的 undo 或 redo 结束时间分组窗口”的机制(测试undo_breaks_timed_grouping_across_the_branch_boundary验证了这一点,undo_history.rs);max_undos(0)的特殊处理:push不记录、redo返回None,但已存在于 redo 侧的事务仍被保留(测试redo_at_zero_max_undos_keeps_the_transaction_available,undo_history.rs);- 显式分组独立于时间分组:即使刚做过 undo,
start_grouping期间的 push 仍会追加到当前事务(测试explicit_grouping_still_appends_after_undo,undo_history.rs)。
NavStack:History 的真实消费方
crates/base/src/nav_stack.rs 中,NavStackState持有history: History<NavEntry>。从源码可以确认计划 Step 4 描述的迁移全部完成:
depth()使用self.history.entries().len()(nav_stack.rs);views()与forward_views()分别映射entries()与forward_entries()(nav_stack.rs);pop()在 depth 为 1 或更少时返回None(根节点永不弹出),先取出离开的视图再调用history.back(),返回离开的视图供应用做退场过渡(nav_stack.rs);forward()使用history.forward()?.view作为返回目的地(nav_stack.rs);pop_to_root()循环调用history.back(),每次迭代先克隆当前视图再回退,最后按 root 侧优先返回(nav_stack.rs);- 代码中已无任何
HistoryItem for NavEntry的痕迹,NavEntry现在只是一个持有AnyView的克隆体(nav_stack.rs)。
NavStack 的 GPUI 组件测试(nav_stack.rs)进一步验证了迁移后的行为契约:根节点保留、pop_to_root 返回除根外所有视图、replace 在空栈上退化为 push、被弹出的视图停留在forward_views直到下一次 push 丢弃它们、立即(Immediate)变更会取代正在运行的过渡。
Dock:UndoHistory 的真实消费方
crates/base/src/dock/tiles_state.rs 中,TilesState持有history: UndoHistory<TileChange>,构造时保留了计划要求的行为:
history: UndoHistory::new().group_interval(std::time::Duration::from_millis(100)),(tiles_state.rs)
即 100 ms 内连续的 tile 边界变更被合并为一个事务,这正是“一次拖拽 = 一次可撤销动作”的语义。公共undo/redo行为通过self.history.undo()/self.history.redo()获取事务后逐条应用(tiles_state.rs、tiles_state.rs),新变更则通过self.history.push(TileChange::bounds_change(...))记录(tiles_state.rs)。
TileChange本身(crates/base/src/dock/tiles_geometry.rs)现在只包含tile_id、old_bounds、new_bounds字段,version字段与HistoryItem实现已按计划移除。
导出与兼容性验证
两个类型从 crate 根导出:crates/base/src/lib.rs 中pub use history::History;,crates/base/src/lib.rs 中pub use undo_history::UndoHistory;。旧的组件层路径只剩一行再导出:crates/component/src/history.rs 的全部内容即pub use gpui_base::{History, UndoHistory};。
兼容性测试legacy_history_path_reexports_the_base_type(crates/component/tests/base_compat.rs)与计划 Step 1 中的两行类型断言完全一致,确保从gpui-component::history与gpui_base两个路径得到的是同一类型。crates/base/README.md 的 API 目录行也已更新为两个类型并排列出:History(Browser-style navigation trail with back and forward entries)与UndoHistory(Grouped undo and redo transactions)。公共文档 website/base/history.md 则给出了“选哪个类型”的决策指引:当每个条目是一个位置、且后退/前进返回“到达的位置”时用History;当每个条目是一个可逆变更、且撤销/重做必须返回一个用户事务的全部变更时用UndoHistory;当分组语义比时间或显式边界更丰富时用领域专属管理器——Input 的私有事务感知 undo 管理器正是第三种情况的例子(crates/base/src/input/base/undo_manager.rs)。
验证体系与工程要点总结
这套拆分计划的可复价值在于其工程组织方式,而非仅仅是两个数据结构本身:
- TDD 的 RED/GREEN 节奏贯穿每个任务:每个任务都先写失败测试(甚至刻意保留缺失方法以制造编译失败),再实现,再导出,形成可审计的证据链;
- 原子迁移保证编译安全:Task 2 把所有消费方(NavStack、Dock、Input)与兼容导出放在同一个提交内完成替换,
rg -n "HistoryItem|\.undos\(|\.redos\(" crates --glob '*.rs'返回空结果作为“旧 API 已彻底消失”的最终断言; - 分层验证门禁:定向单测(
history::tests、undo_history::tests、nav_stack、dock)→ 兼容性测试(base_compat)→ 工作区级编译(cargo check --workspace --all-targets)与git diff --check; - 文档与代码同分支交付:Task 3 把中英双语文档、README 目录行纳入同一个 PR 的验收范围,避免文档描述已删除的
unique/MRU 行为; - 下游迁移被显式排除:
longbridge-gpui的迁移被刻意标记为下游工作,其NavJournal可以在保留领域逻辑(RecordOutcome、refinement、不可达目标恢复)的前提下包装History<NavLocation>,这说明了拆分为下游仓库留下的扩展点。
从源码结构看,当前仓库即是该计划完成后的状态:History<T>(crates/base/src/history.rs)与UndoHistory<T>(crates/base/src/undo_history.rs)各司其职,NavStack 与 Dock 分别作为两者的生产级消费方运行,HistoryItem与 version 字段已从仓库中消失,两条公共导出路径(gpui_base与gpui_component::history)由 crates/component/tests/base_compat.rs 持续看护。对于维护 gpui-kit 的开发者,这份计划与配套设计文档 docs/superpowers/specs/2026-09-02-history-split-design.md 一起,构成了理解这两个历史类型语义边界与验证方式的权威依据。
【免费下载链接】gpui-kitRust GUI components for building fantastic cross-platform desktop application by using GPUI.项目地址: https://gitcode.com/GitHub_Trending/gp/gpui-kit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考