Warp 统一新建标签菜单(Unified New Tab Menu)技术解析:Sidecar 侧栏面板替代内置子菜单的工程实践

原创2026-10-01 18:44:14592 阅读
文章标签:桌面应用开发者工具人工智能AI 应用AI Agent代码智能体

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")]):

  1. 安全三角(safe triangle)没有接线:MenuItem::Submenu 把子菜单渲染为同一 Menu 视图内部的覆盖层,而 with_safe_triangle() / set_safe_zone_target() 这套基础设施是为外部调用方(model picker)设计的。要让内置子菜单支持安全三角,需要 Menu 的 render 方法把子面板的 bounding rect 回传给 action handler,这违背了「先渲染后执行」的既有模型,属于跨关注点耦合。

  2. 悬停事件路由混乱:所有子菜单项动作(HoverSubmenuLeafNode、Select)都被分发到同一个 Menu<A> 视图。深度 0 的 SubMenu 处理它们,但深度 1 的子菜单项共享同一 action 命名空间——MenuAction::Select 没有 depth 参数,点击第 0 行的子菜单项会把 selected_row_index = 0 设置到深度 0 的菜单上,高亮错误的条目(例如高亮 "Agent" 而不是第一个仓库)。

  3. 悬停回调的竞态:on_hover 回调在悬停进入和移出时都会触发 HoverSubmenuLeafNode(因为判断条件是 is_hovered || is_enabled)。当鼠标在条目间移动时,条目 A 的 unhover 事件晚于条目 B 的 hover-in 事件到达,把选中状态重置回 A。(该 bug 已被修复:只保留 is_hovered。)

  4. 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),其处理流程为:

  1. 读取 menu.hovered_index()(注意不是 selected_index(),原因见第六节 bug 修复)与悬停条目的 label;
  2. 若 hovered 为 None(鼠标离开菜单、可能正移向侧栏),保持当前状态不变;
  3. label 为 None(分隔符)→ 隐藏侧栏并清除 safe zone 与 submenu_being_shown_for_item_index;
  4. label 为 "New worktree config" → 走 configure_worktree_new_session_sidecar() 组装 worktree 侧栏;
  5. label 为 "New tab config" → 隐藏侧栏;
  6. 其余可执行条目 → configure_action_sidecar_for_hovered_item() 为 Agent / Terminal / Cloud / Docker / 用户 tab 配置分别生成动作侧栏;
  7. 从上一帧通过 element_position_by_id_at_last_frame(window_id, "new_session_sidecar") 读取侧栏面板 rect;
  8. 设置 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 附近):

  1. 加载默认 worktree tab 配置(缺失时由嵌入模板创建,见下一节);
  2. 代入模板变量(repo 路径、自动生成的 branch name、pane type);
  3. 立即打开新标签页执行 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 步用户旅程,可与源码一一对上:

  1. 用户点击 + 按钮(纵向标签栏)或 chevron(横向标签栏);
  2. toggle_new_session_dropdown_menu() 调用 unified_new_session_menu_items() 填充主菜单;
  3. 悬停 "New worktree config" → MenuEvent::ItemHovered → update_new_session_sidecar() 读取 hovered_index()、识别标签、组装仓库列表、设置 safe zone target、显示侧栏覆盖层;
  4. 侧栏渲染可滚动的搜索行 + 仓库条目 + 底部钉住 footer;
  5. 在搜索框输入 → EditorEvent::Edited(_) 更新 query → refresh_new_session_sidecar_for_active_kind()(或 refresh_worktree_sidecar_if_active(),view.rs)用过滤后的仓库行重建侧栏;
  6. 鼠标移向侧栏 → 安全三角抑制中间 HoverSubmenuLeafNode 事件 → 侧栏保持可见;
  7. 点击仓库 → 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 同步修改的概率极低;
  • 侧栏关闭范围:点击两个菜单之外会触发主菜单的 Dismiss handler,关闭全部;侧栏自身未包裹 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-configs skill 引导用户交互式创建;
  • 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。

登录后查看全文
warp