Warp 统一新建标签菜单(Unified New Tab Menu)技术解析:Sidecar 侧栏面板替代内置子菜单的工程实践
Warp 统一新建标签菜单(Unified New Tab Menu)技术解析:Sidecar 侧栏面板替代内置子菜单的工程实践
导读
本文深入拆解 Warp 终端中一项重要的 UI 架构演进——「统一新建标签菜单」:它将横向标签栏的 chevron 下拉菜单与纵向标签栏的 + 菜单合并为同一套菜单结构,并引入悬停驱动的 Sidecar 侧栏面板(用于仓库 worktree 快速创建与 Windows 平台 shell 选择)。读完本文你将掌握:内置 MenuItem::Submenu 子菜单方案为何被弃用、侧栏面板模式的完整事件编排流程、worktree 模板的变量替换原理,以及 menu.rs 中多个悬停竞态 bug 的修复思路,全程可对照仓库源码逐行验证。
一、问题背景:两套菜单的割裂与 worktree 创建的高门槛
在本次改动之前,Warp 的「新建会话」入口存在明显的割裂问题:
- 横向标签栏的 chevron 菜单与纵向标签栏的
+菜单由两套独立函数(new_session_menu_items()与vertical_tabs_new_session_menu_items())生成,条目、顺序、文案各不相同,用户切换布局后需要重新适应; - 菜单内部没有任何「子菜单 / 侧栏」能力,无法把 shell 归类到 "Terminal" 下、也无法把仓库归类到 worktree 入口下;
- 创建 worktree 需要打开一个包含多个字段(repo、branch、checkbox)的模态窗口,操作路径过长,无法满足「选一个仓库、立刻得到 worktree」的快路径诉求。
技术规格文档 specs/APP-3743/TECH.md 与产品文档 specs/APP-3743/PRODUCT.md 共同定义了本次改动的目标:合并菜单为单一结构、新增 "Worktree in"(仓库 worktree 侧栏)、Windows 平台为 Terminal 增加 shell 选择侧栏、并提供参数化的默认 worktree tab 配置。
二、核心设计决策:为什么放弃 MenuItem::Submenu,改用 Sidecar
这是整个改动的灵魂章节。团队最初尝试使用 Menu 组件内置的 MenuItem::Submenu 变体(由 PR #13305 引入,Jan 2025),但在深入调试后发现了四个根本性问题(见 app/src/menu.rs 中该变体标注的 #[deprecated("Submenus are not ready for use yet")]):
-
安全三角(safe triangle)没有接线:
MenuItem::Submenu把子菜单渲染为同一Menu视图内部的覆盖层,而with_safe_triangle()/set_safe_zone_target()这套基础设施是为外部调用方(model picker)设计的。要让内置子菜单支持安全三角,需要Menu的 render 方法把子面板的 bounding rect 回传给 action handler,这违背了「先渲染后执行」的既有模型,属于跨关注点耦合。 -
悬停事件路由混乱:所有子菜单项动作(
HoverSubmenuLeafNode、Select)都被分发到同一个Menu<A>视图。深度 0 的SubMenu处理它们,但深度 1 的子菜单项共享同一 action 命名空间——MenuAction::Select没有 depth 参数,点击第 0 行的子菜单项会把selected_row_index = 0设置到深度 0 的菜单上,高亮错误的条目(例如高亮 "Agent" 而不是第一个仓库)。 -
悬停回调的竞态:
on_hover回调在悬停进入和移出时都会触发HoverSubmenuLeafNode(因为判断条件是is_hovered || is_enabled)。当鼠标在条目间移动时,条目 A 的 unhover 事件晚于条目 B 的 hover-in 事件到达,把选中状态重置回 A。(该 bug 已被修复:只保留is_hovered。) -
UnhoverSubmenuParent立即关闭子菜单:鼠标从子菜单父项移向子面板时,路径上的中间条目会触发HoverSubmenuLeafNode从而关闭子菜单。没有安全三角,对角线移动鼠标成为不可能操作。
决策结论:转向 model picker 已被验证的 sidecar 模式——由 Workspace 管理两个相互独立的 Menu 视图(主菜单 + 侧栏菜单),并把既有的安全三角基础设施在外部接线。这一决策直接决定了后文所有的实现细节。
三、统一菜单项:unified_new_session_menu_items
两个入口统一后,菜单条目的唯一生成函数是 Workspace::unified_new_session_menu_items()(app/src/workspace/view.rs),toggle_new_session_dropdown_menu 不再根据 is_vertical_tabs 分支生成条目,仅保留定位与宽度差异。
菜单顺序与条件如下(均可在源码中逐一验证):
| 顺序 | 条目 | 行为与条件 |
|---|---|---|
| 1 | Agent | WorkspaceAction::AddAgentTab,图标 LayoutAlt01;当默认会话模式为 Agent 时显示 ⌘T 快捷键标签;AI 禁用时隐藏 |
| 2 | Terminal | macOS/Linux 为普通条目(AddTerminalTab);Windows 下为「默认终端 + 可用 shells」的条目组(详见下文) |
| 3 | Cloud Agent | AddAmbientAgentTab,仅在 AgentView + CloudMode 两个 feature flag 同时启用时出现 |
| 4 | Local Docker Sandbox | AddDockerSandboxTab,受 LocalDockerSandbox flag 门控 |
| 5 | [用户 tab 配置] | 来自 WarpConfig::as_ref(ctx).tab_configs();worktree 类配置用 Dataflow02 图标、普通配置用 LayoutAlt01;同名配置自动消歧("My Tab Config (1)" 等) |
| 6 | Separator | |
| 7 | New worktree config | 子菜单父项(MenuItemFields::new_submenu),悬停触发侧栏 |
| 8 | New tab config | CreateNewTabConfig 动作,图标 Plus,V0 版本直接打开 TOML 模板 |
| 9 | New tab group | 受 GroupedTabs flag 门控(带 TODO 注释) |
| 10 | Reopen closed session | ReopenClosedSession,当 UndoClose 栈为空时置灰 |
注:技术规格文档中该子菜单父项文案为 "Worktree in",当前仓库实现中对应标签为 "New worktree config"(view.rs),悬停识别的标签匹配逻辑也相应使用该字符串。
关于 Terminal 的平台差异:规格文档描述的是「Windows 上 Terminal 为子菜单父项、悬停弹出含 'Default Terminal' 与各 shell 的侧栏」;当前代码的实现进一步演进为在 Windows 上直接把默认终端与每个可用 shell 列成独立顶级条目(view.rs),每个条目悬停时各自获得一个动作侧栏(shell 条目由 AvailableShells 枚举、经 ShellSelector flag 与 local_tty feature 门控、按 ShellIndicatorType 选择图标)。无论哪种形态,核心思路一致:shell 选择能力仅在 Windows 生效,macOS/Linux 上 Terminal 始终是带 ⌘T 快捷键的普通条目。
主菜单宽度被固定为 268px(对应 Figma OptionMenuItem 组件宽度,见 view.rs 注释)。
四、Sidecar 侧栏菜单的构建与悬停编排
4.1 侧栏菜单的创建
Workspace 新增字段 new_session_sidecar_menu: ViewHandle<Menu<WorkspaceAction>>,在 build_menus() 中以 Menu::new() 创建,配置为:宽度 NEW_SESSION_SIDECAR_WIDTH(规格文档记录为 300px)、可滚动变体、最大高度 400px。
4.2 主菜单的安全三角配置
主菜单 new_session_dropdown_menu 通过 .with_safe_triangle().with_ignore_hover_when_covered() 开启两项关键能力:
- Safe triangle(安全三角):鼠标在指向侧栏面板的三角形安全区内移动时,抑制
HoverSubmenuLeafNode事件,避免中间条目把侧栏关掉; - Ignore hover when covered(被覆盖时忽略悬停):侧栏覆盖层下方的深度 0 条目不再触发 hover 事件。
4.3 悬停驱动的编排逻辑
事件入口是 handle_new_session_menu_event(view.rs):收到 MenuEvent::ItemHovered 或 MenuEvent::ItemSelected 时调用 update_new_session_sidecar()(view.rs),其处理流程为:
- 读取
menu.hovered_index()(注意不是selected_index(),原因见第六节 bug 修复)与悬停条目的 label; - 若 hovered 为
None(鼠标离开菜单、可能正移向侧栏),保持当前状态不变; - label 为
None(分隔符)→ 隐藏侧栏并清除 safe zone 与submenu_being_shown_for_item_index; - label 为 "New worktree config" → 走
configure_worktree_new_session_sidecar()组装 worktree 侧栏; - label 为 "New tab config" → 隐藏侧栏;
- 其余可执行条目 →
configure_action_sidecar_for_hovered_item()为 Agent / Terminal / Cloud / Docker / 用户 tab 配置分别生成动作侧栏; - 从上一帧通过
element_position_by_id_at_last_frame(window_id, "new_session_sidecar")读取侧栏面板 rect; - 设置
main_menu.set_safe_zone_target(sidecar_rect)与set_submenu_being_shown_for_item_index(Some(hovered_index))。
4.4 侧栏渲染定位
当 show_new_session_sidecar 为真时,workspace 渲染阶段添加一个定位的覆盖子节点:
- 锚定到悬停条目的
SavePosition(每个MenuItemFields在渲染时用SavePosition(label)包裹自身元素); - 外层再用
SavePosition("new_session_sidecar")包裹,使 safe zone rect 能在下一帧被读取; - 定位方式为 TopRight → TopLeft,与父条目间距 4px;
should_render_sidecar_left()(view.rs)会检测窗口左右边界,右侧放不下时自动改为左侧渲染。
五、Worktree 侧栏:搜索行与实时过滤
Worktree 侧栏在 Workspace 上拥有三块新状态(view.rs 附近初始化):
new_session_sidecar_kind—— 标记当前侧栏是 Terminal 还是 Worktree;worktree_sidecar_search_editor—— 一个专用的单行EditorView;worktree_sidecar_search_query—— 当前过滤文本。
build_worktree_sidecar_search_input() 构造该编辑器,并订阅两类事件:EditorEvent::Edited(_) → 更新 query 并重建侧栏;EditorEvent::Escape → 清空 query 与缓冲区并重建侧栏。
build_worktree_sidecar_items()(view.rs)通过 MenuItemFields::new_with_custom_label(...) 把搜索行作为自定义首行插入菜单内容流:
- 非交互(
.with_no_interaction_on_hover())、悬停不高亮(.no_highlight_on_hover()); - 使用自定义 menu item padding 覆盖(
.with_padding_override(0., 0.)); - 视觉上是紧凑的带边框搜索框:
SearchSmall图标 + 边框容器 + 仅顶部圆角(CornerRadius::with_top(Radius::Pixels(4.))); - 搜索行是普通可滚动菜单内容的一部分而非钉住头部,因此会随仓库列表一起滚动消失。
仓库行的过滤逻辑(view.rs)非常直白:query 取 trim().to_lowercase(),逐行判断 repo_path.to_string_lossy().to_lowercase().contains(query);仓库来源是 PersistedWorkspace.workspaces(),先过滤掉路径不存在的条目;显示用 user_friendly_path() 相对化、完整路径放进 tooltip、图标为 Folder、文本溢出用 ClipConfig::start() 截断。
值得注意的细节:should_include_worktree_sidecar_repo()(view.rs)在 local_fs feature 下会通过 DetectedRepositories 检查 external_git_directory(),排除 linked worktree 与 submodule,确保列表里只出现主仓库 checkout,避免在 sidecar 里重复列出 worktree 自身。
六、钉住的 Footer 与布局微调
Worktree 侧栏通过 set_pinned_footer_builder(...) 让 "+ Add new repo" 始终钉在面板底部,仓库列表在其上方独立滚动(configure_worktree_new_session_sidecar(),view.rs)。点击 footer 时依次 dispatch OpenWorktreeAddRepoPicker 与 MenuAction::Close(true),打开文件夹选择器把新仓库注册进 PersistedWorkspace,随后该仓库即出现在列表中。
为贴合设计稿,侧栏还做了三处布局微调:
set_content_padding_overrides(Some(0.), None)让首行内容(搜索框)与侧栏顶部齐平;- 搜索框用仅顶部圆角、footer 用仅底部圆角(
CornerRadius::with_bottom(Radius::Pixels(5.))); - footer 悬停时切换为 accent 背景色(
theme.accent_button_color()),并提供PointingHand光标。
这些能力依赖 app/src/menu.rs 中的一个小增强:允许深度 0 内容的顶部/底部 padding 覆盖,与既有的逐条目 padding 覆盖并存。
七、Sidecar 事件处理与 Worktree-in-Repo 动作
事件处理器是 handle_new_session_sidecar_event(view.rs):
MenuEvent::Close { via_select_item: true }:侧栏中点击了条目 → 执行选中动作(execute_new_session_sidecar_selection),同时关闭主菜单;MenuEvent::Close { via_select_item: false }:未选择即被关闭 → 只隐藏侧栏,主菜单保持;MenuEvent::ItemHovered→sync_new_session_sidecar_selection_to_hover保持侧栏选中态跟随鼠标。
仓库条目对应的动作是 WorkspaceAction::OpenWorktreeInRepo { repo_path }(定义于 app/src/workspace/action.rs),处理流程为(view.rs 附近):
- 加载默认 worktree tab 配置(缺失时由嵌入模板创建,见下一节);
- 代入模板变量(repo 路径、自动生成的 branch name、pane type);
- 立即打开新标签页执行
git worktree add与cd命令——无模态框,这就是「worktree 快路径」。
八、默认 Worktree Tab 配置与模板变量替换
8.1 存放位置与首次创建
内置默认 tab 配置存放于 default_tab_configs 目录(与用户配置目录 tab_configs 区分)。app/src/user_config/mod.rs 中的 default_tab_configs_dir()(L229)返回 base_dir().join("default_tab_configs"),mod_tests.rs 的测试 test_default_tab_configs_dir_uses_underscores 明确断言目录名使用下划线。ensure_default_worktree_config()(L253)负责:若 worktree.toml 不存在,则创建目录并把嵌入模板(include_str!("../../resources/tab_configs/default_worktree.toml"))写入;已存在则不动——用户可自由编辑该文件,Warp 更新时不会覆盖。
8.2 嵌入模板内容
仓库中实际的嵌入模板位于 app/resources/tab_configs/default_worktree.toml:
name = "Worktree"
[[panes]]
id = "main"
type = "{{pane_type}}"
directory = "{{repo}}"
commands = [
"git worktree add -b {{autogenerated_branch_name}} \"{{worktree_path_prefix}}{{autogenerated_branch_name}}\"",
"cd \"{{worktree_path_prefix}}{{autogenerated_branch_name}}\"",
]
[params.repo]
type = "repo"
description = "Repository to create worktree in"
[params.pane_type]
type = "text"
description = "Pane type (terminal or agent)"
default = "terminal"
注意模板注释中说明 worktree 会被创建在 ~/.warp/worktrees/<repo-name>/<worktree-name> 下(macOS 上数据目录随渠道区分:Local 渠道为 ~/.warp-local/,Stable 渠道为 ~/.warp/)。
8.3 变量替换必须先于 TOML 解析
这是最关键的工程细节之一:{{repo}}、{{autogenerated_branch_name}}、{{pane_type}} 等模板变量必须在把原始 TOML 字符串解析成 TabConfig 之前替换。原因在于 TOML 反序列化器会校验枚举字段(如 type 只能是已知变体 terminal / agent / cloud),直接把 {{pane_type}} 喂给反序列化器必然报错。materialize_default_worktree_config()(user_config/mod.rs)在解析前完成字符串级替换,并通过 generated_worktree_path_string() 计算目标路径。
pane_type 的取值并非写死:open_worktree_in_repo handler 读取用户的 DefaultSessionMode 设置——AI 启用且默认模式为 Agent 时置为 "agent",否则置为 "terminal"。worktree 会话因此尊重用户的默认会话偏好:偏好 Agent 模式的用户会得到 Agent 模式的 worktree。branch name 则由 generate_worktree_branch_name() 自动生成(worktree-1、worktree-2……)。
九、menu.rs 中的四个关键 Bug 修复
9.1 on_hover 竞态:is_hovered || is_enabled → is_hovered
MenuItemFields::render 的 on_hover 回调原先在 is_hovered || is_enabled 时派发 HoverSubmenuLeafNode。当叶节点被移出(is_hovered=false)时仍会携带过期的 row_index 派发事件;若该事件在下一个条目 hover-in 事件之后才被处理,就会用错误的值覆盖 hovered_row_index——表现为从上方进入子菜单父项时侧栏不显示。修复后仅在 is_hovered 时派发;安全三角所需的连续位置追踪由独立的 on_mouse_in handler 负责。
9.2 以 hovered_index 而非 selected_index 为准
update_new_session_sidecar 以 hovered_index() 为唯一事实来源。原因:hovered_row_index 能精确跟随鼠标,且在 reset_selection()(只清 selected_row_index/selected_item_index)后依然存活。此前用 selected_index 时,由于设置了 submenu_being_shown_for_item_index 导致 UnhoverSubmenuParent 被抑制,选中状态会卡在子菜单父项上——用户移向无关条目时侧栏仍然残留。
9.3 移除 UnhoverSubmenuParent 的整体抑制
submenu_being_shown_for_item_index.is_some() 时对 UnhoverSubmenuParent 的 blanket suppression 被移除。安全三角已经处理了指向侧栏的对角线鼠标移动(通过抑制 HoverSubmenuLeafNode),整体抑制既多余、又导致用户移开时 selected_index 永远无法清空。
9.4 有限宽度的搜索编辑器布局
自定义 worktree 搜索行最初通过一个多余的 clipped container 路径渲染编辑器,运行时会向编辑器传入无限宽度约束。最终实现改用代码库中通用的 icon + Shrinkable::new(1., ChildView::new(&editor)) 模式(view.rs):视觉上保持搜索框紧凑,同时保证编辑器拿到有限宽度。
十、端到端流程
规格文档给出了完整的 7 步用户旅程,可与源码一一对上:
- 用户点击
+按钮(纵向标签栏)或 chevron(横向标签栏); toggle_new_session_dropdown_menu()调用unified_new_session_menu_items()填充主菜单;- 悬停 "New worktree config" →
MenuEvent::ItemHovered→update_new_session_sidecar()读取hovered_index()、识别标签、组装仓库列表、设置 safe zone target、显示侧栏覆盖层; - 侧栏渲染可滚动的搜索行 + 仓库条目 + 底部钉住 footer;
- 在搜索框输入 →
EditorEvent::Edited(_)更新 query →refresh_new_session_sidecar_for_active_kind()(或refresh_worktree_sidecar_if_active(),view.rs)用过滤后的仓库行重建侧栏; - 鼠标移向侧栏 → 安全三角抑制中间
HoverSubmenuLeafNode事件 → 侧栏保持可见; - 点击仓库 → dispatch
OpenWorktreeInRepo→ 侧栏以via_select_item: true关闭 → 主菜单同步关闭 → 新标签页立即执行 worktree 命令。
十一、风险与缓解
规格文档明确列出了三项已知风险及其对策:
- 安全区首帧延迟:首次悬停打开侧栏时,上一帧的侧栏 rect 为
None(面板尚未渲染),safe zone 被设为None,首帧没有安全三角保护;下一帧 rect 即可用。缓解:延迟仅一帧(约 16ms),实际感知不到(源码中的日志"Worktree sidecar safe-zone target from previous frame available: {}"正是用于观测该现象); - 基于标签字符串识别条目:hover handler 通过比对 label 字符串("Terminal"、"New worktree config")识别子菜单父项,label 一旦改动侧栏即失效。缓解:这些是硬编码 UI 字符串,且 handler 与 label 同步修改的概率极低;
- 侧栏关闭范围:点击两个菜单之外会触发主菜单的
Dismisshandler,关闭全部;侧栏自身未包裹Dismiss,它只是主菜单 dismiss 作用域内的一个定位覆盖层。
十二、测试与验证
技术规格给出的验证矩阵同样可直接复用:
- 构建检查:
cargo check -p warp无错误通过; - 菜单一致性:同时打开横向与纵向标签菜单,确认条目完全一致;
- 平台行为:macOS/Linux 点击 Terminal 打开终端标签;Windows 悬停 Terminal(及 shell 条目)验证侧栏显示默认终端行与可用 shells;
- Worktree 侧栏:悬停 "New worktree config" 验证搜索行、仓库条目、钉住 footer 齐全;
- 实时过滤:输入 "Search repos" 验证仓库行实时过滤且 footer 保持钉住;
- 安全三角:对角线移向侧栏验证不会提前关闭;
- 动作闭环:点击侧栏条目验证动作触发且两个菜单同时关闭;
- 回归:确认 tab 右键菜单、overflow 菜单、model picker 等既有菜单不受
is_hovered修复与 padding 覆盖影响。
相关的自动化测试证据还包括 app/src/workspace/view_tests.rs(unified_new_session_menu_items 的使用方)与 app/src/user_config/mod_tests.rs(默认 worktree 配置目录与文件断言)。
十三、后续工作
规格文档预留了三个明确的 follow-up,可作为继续贡献的入口:
- New Tab Config 技能调用:V0 仅打开 TOML 模板,后续计划自动通过 Oz agent 调用
tab-configsskill 引导用户交互式创建; MenuItem::Submenu清理:内置子菜单变体仍以 deprecated 状态保留在代码库中,未来若出现需要内联子菜单的场景,可考虑移除或补齐安全三角接线(侧栏模式在 app/src/terminal/profile_model_selector.rs 中保留了可参考的参考实现);- 长路径左侧淡出:
ClipConfig::start()目前只能右对齐文本,真正的左对齐 + 左侧淡出需要 UI 框架层面的支持; - macOS/Linux shell 选择器:目前仅 Windows 显示 Terminal shell 侧栏,其他平台如需 shell 选择,移除
#[cfg(target_os = "windows")]门控即可重新启用。
延伸阅读:产品视角的需求、成功标准与边界情况见 specs/APP-3743/PRODUCT.md;本文所有源码引用集中于 app/src/workspace/view.rs、app/src/menu.rs、app/src/user_config/mod.rs、app/src/workspace/action.rs 与模板文件 app/resources/tab_configs/default_worktree.toml。