Warp 标签页配置 `[on_close]`:在 Tab 关闭时声明式执行清理命令

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

Warp 标签页配置 [on_close]:在 Tab 关闭时声明式执行清理命令

Tab 配置(Tab Config)让 Warp 用户可以用一个 TOML 文件声明如何打开标签页——包括窗格布局、启动命令和模板参数,但打开之后如何“善后”一直缺少声明式手段。本文基于仓库中的技术规格 specs/APP-3736/TECH.md,系统讲解新增的 [on_close] 配置:它如何在标签页关闭时自动执行清理命令、如何渲染模板参数与自动生成的分支名、如何异步非阻塞地执行,以及它解决了哪些实际问题(尤其是 git worktree 配置的目录与分支清理),并给出可直接落地的配置示例与源码级实现依据。

背景与问题:打开容易,清理靠手

Warp 的 Tab 配置能力早在 APP-3736 之前就已具备:一个 ~/.warp/tab_configs/ 下的 .toml 文件可以声明标签页名称、标题模板、颜色、窗格布局(支持分屏树)、每个窗格的 directory 与启动 commands,以及用户打开前需要填写的参数(params)。打开一个配置时,Warp 会渲染标题与窗格模板、弹出参数填写界面,然后创建真实标签页。

但这个能力有一个明显缺口:没有声明式的方式在标签页关闭时运行清理逻辑。尤其是 worktree 类配置——打开时执行 git worktree add 创建一个新的工作树,关闭后却只能靠用户手动删除工作树目录、手动删除分支。这既繁琐又容易遗忘,残留的 worktree 目录会持续占用磁盘空间。

APP-3736 的核心目标就是补上这个闭环:让配置作者在 TOML 中声明一组“关闭时命令”,当该配置打开的标签页被关闭时,Warp 自动执行这些命令完成清理。

配置 Schema:在 TOML 中声明 [on_close]

新增顶层字段

TabConfig 新增了一个可选的顶层字段 on_close: Option<TabConfigOnClose>,定义在 app/src/tab_configs/tab_config.rs 的配置结构体中。TabConfigOnClose 与关闭时的命令契约一一对应:

字段 类型 含义
directory Option<String> 清理命令的工作目录(模板变量会在此渲染)
commands Vec<String> 关闭标签页时按顺序执行的命令列表

从 TOML 的写法上看,这对应一个顶层 [on_close] 表:

name = "Worktree Dev"
title = "{{worktree_branch_name}}"

[[panes]]
id = "main"
type = "terminal"
directory = "{{repo}}"
commands = [
  "git worktree add -b {{worktree_branch_name}} $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}} {{branch}}",
  "cd $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}}",
]

[params.repo]
type = "repo"
description = "Absolute path to repository"

[params.branch]
type = "branch"
description = "Base branch to branch from"

[on_close]
directory = "{{repo}}"
commands = [
  "git worktree remove --force $HOME/.warp/worktrees/$(basename {{repo}})/{{worktree_branch_name}}",
  "git branch -D {{worktree_branch_name}}",
]

这个示例展示了 worktree 场景的典型闭环:打开时用 git worktree add -b ... 创建分支与工作树,关闭时先用 git worktree remove --force 删除工作树目录,再执行 git branch -D 删除分支。

生成的配置默认不带关闭行为

为了保证存量行为不变,两个生成配置的入口——build_tab_config(生成启动/会话配置)和 tab_config_from_pane_snapshot(从存活标签页保存配置)——都会把 on_close 初始化为 None。也就是说,除非用户显式编辑 TOML 添加 [on_close],否则生成的配置不会改变既有关闭行为。这一默认值策略也体现在 app/src/tab_configs/session_config.rs 中。

参数类型的既有基础

[on_close] 命令里能引用 {{repo}}、{{branch}}、{{worktree_branch_name}} 等模板变量,依赖的是 Tab 配置既有的参数体系。在 app/src/tab_configs/tab_config.rs 中可以看到:

  • TabConfigParamType:参数类型,text(默认,单行文本输入)、branch(下拉选择当前仓库的本地 git 分支)、repo(下拉选择已知仓库并可新建);
  • TabConfigParam:每个参数可声明 description、default 与 type;
  • 没有填写 UI 时,default_param_values() 会用默认值(无默认值则取空字符串)作为回退。

渲染模型:模板上下文与引号规则

打开与关闭共用一套渲染规则

规格中最关键的工程决策是把模板上下文构建收拢到一个共享助手 build_template_contexts 中。此前 render_tab_config 自己构造渲染上下文;现在它同时服务于两个渲染入口:

  • render_tab_config —— 渲染标签页标题 + 窗格布局模板(打开时);
  • render_tab_close_behavior —— 渲染 [on_close] 的清理计划(打开时一并渲染,供关闭时使用)。

两者共享同一套上下文,从而保证“打开时”和“关闭时”的模板替换遵循完全一致的规则。build_template_contexts 在 app/src/tab_configs/tab_config.rs 中构建两个上下文:

  • unquoted_context:原始参数值,外加可选的 autogenerated_branch_name;
  • quoted_context:经 shell_words::quote 转义后的参数值,外加同样转义后的 autogenerated_branch_name。

使用规则(与打开时保持一致):

  • 标题(title)与窗格 directory 使用 unquoted 值——路径加上引号会被破坏;
  • 命令(commands) 使用 shell-quoted 值——防止注入,且能正确处理含空格的值。

render_tab_config 内部的 resolve_pane_tree/resolve_pane_node 正是这样实现:叶子窗格的 cwd 用 handlebars::render_template(c, unquoted) 渲染并做 ~ 展开,而 commands 用 handlebars::render_template(cmd, quoted) 渲染成 CommandTemplate。这一引号规则被 app/src/tab_configs/tab_config_tests.rs 中的渲染测试覆盖(如 test_render_tab_config_shell_quotes_commands_with_spaces 断言含空格的值在命令中会被正确转义,test_render_tab_config_cwd_is_not_quoted 断言 cwd 保持原始路径)。

解析后的清理计划:ResolvedTabCloseBehavior

渲染结果不是直接引用原始 TOML,而是落到一个独立的解析形态 ResolvedTabCloseBehavior:

  • directory: Option<PathBuf>
  • commands: Vec<String>

它保存的是该标签页实例打开那一刻的最终值:参数模态框里用户手填的值、以及 maybe_generate_worktree_name 生成的分支名,都已被渲染进命令。这样做的用意很明确:关闭时直接使用打开时确定的值执行清理,无需重新读取配置文件、也无需再次弹窗询问参数。

运行时流程:从打开到关闭的完整生命周期

打开:渲染并挂载关闭行为

Workspace::open_tab_config_with_params(位于 app/src/workspace/view.rs)现在是打开与关闭行为的连接点:

  1. 调用 render_tab_config 渲染标题与窗格模板;
  2. 调用 render_tab_close_behavior 渲染可选的关闭行为;
  3. 通过 add_tab_with_pane_layout 打开标签页;
  4. 把解析后的关闭行为写入 self.tabs[self.active_tab_index].resolved_tab_close_behavior;
  5. 照旧应用配置里的 color。

对应地,TabData(标签页的内存状态容器,定义于 app/src/tab.rs)新增字段 resolved_tab_close_behavior: Option<ResolvedTabCloseBehavior>,TabData::new 默认初始化为 None。这意味着:

  • 普通标签页、以及不是从配置打开的标签页,resolved_tab_close_behavior 为 None,关闭行为与以前完全一致;
  • 该字段只存在于内存中的存活 TabData,不会被序列化进 workspace 快照。

进入该流程的入口有两类:无参数配置直接打开(open_tab_config 中 params.is_empty() 的分支,且 worktree 配置会调用 maybe_generate_worktree_name 生成唯一分支名);有参数配置则先弹参数模态框,用户确认后同样汇聚到 open_tab_config_with_params。

关闭:先收集清理计划,再移除标签页

Workspace::close_tabs(同样位于 app/src/workspace/view.rs)是标签页关闭的总入口,此前它已经负责:构建待关闭的索引列表、按需弹出未保存状态确认框、取消进行中的标签页重命名、按逆序移除标签页、发送遥测。APP-3736 在既有流程中插入了清理步骤:

  1. 构建 tab_indices_vec;
  2. 按需弹出未保存状态确认对话框;
  3. 取消任何进行中的标签页重命名;
  4. 逐个 .take()(消费)待关闭标签页的 resolved_tab_close_behavior,并在标签页上标记 cleanup_was_run = true;
  5. 对每个收集到的行为调用 run_tab_close_cleanup;
  6. 照旧按逆序索引移除标签页。

先收集再移除,是为了避免标签页被移除后索引失效带来的依赖问题。而“消费式”取走行为(.take())保证了清理命令的单次触发语义:一个标签页的关闭钩子只会执行一次。

异步 best-effort 清理执行

本地 local-tty 构建上的执行路径

在带 local_tty 的原生构建上,run_tab_close_cleanup 会读取当前 LocalShellState,并 spawn 一个异步任务执行 execute_tab_close_behavior,传入:

  • shell 类型;
  • shell 路径;
  • 当前本地 shell 环境的 PATH。

execute_tab_close_behavior 构造一个 LocalCommandExecutor,按顺序执行解析后的清理命令。每条命令都携带:

  • 解析后的 directory(若存在);
  • 可用时的 HOME 与 PATH 环境变量;
  • ExecuteCommandOptions { run_command_in_same_shell_as_session: true }——在会话的同一 shell 上下文中运行命令。

执行语义是顺序且中断的:某条命令成功退出则继续下一条;返回非零状态则停止执行,返回包含失败命令及其输出的错误,由 spawn 出来的回调通过 log::warn! 记录。

非阻塞与失败可见性

两个设计点值得强调:

  • 非阻塞:workspace 不会在移除标签页前等待该异步任务完成,清理对用户而言是 best-effort、不挡手的——标签页立即消失;
  • 失败可见:无论清理失败,还是 LocalShellState 不可用导致清理被跳过,run_tab_close_cleanup 都会向 workspace 的 toast 栈压入一个持久化的 DismissibleToast::error(...),让用户看到清理没有成功,同时不阻塞标签页关闭。

非 local-tty 构建

在不支持原生 local-tty 的构建上,run_tab_close_cleanup 是一个 no-op 空实现——清理行为只在本地原生终端会话可用。

端到端流程一览

综合上述各环节,一次带 [on_close] 的 worktree 配置从打开到关闭的完整链路是:

  1. 用户从新建会话菜单中选择一个 Tab 配置;
  2. 若配置带参数,参数模态框收集值;否则使用默认值。若任一窗格设置了 worktree_name_autogenerated = true,maybe_generate_worktree_name 会生成唯一分支名;
  3. open_tab_config_with_params 渲染窗格模板、标题与可选的关闭行为,打开标签页并把解析后的关闭行为存到新的 TabData 上;
  4. 用户后续关闭该标签页;
  5. close_tabs 收集每个标签页解析后的关闭行为,在移除标签页前调用 run_tab_close_cleanup;
  6. 在原生 local-tty 构建上,清理命令通过 LocalCommandExecutor 异步执行;标签页立即消失,失败会被记录日志并停止该标签页剩余的清理命令。

边界情况与限制

规格 specs/APP-3736/TECH.md 明确列出了需要接受的设计权衡:

  • 单次触发:resolved_tab_close_behavior 在标签页移除前被消费,因此 undo-close 恢复的标签页再次关闭不会重复触发清理;从 workspace 快照恢复的标签页也不会找回此前的关闭钩子(字段不参与序列化);
  • undo-close 的 CWD 回退:当 cleanup_was_run = true 的标签页被 undo-close 恢复时,restore_closed_tab 会遍历其终端窗格调用 cd_home_if_dir_missing;若原 CWD 已不存在(例如 worktree 已被清理删除),shell 回退到主目录;
  • 清理与 undo-close 的竞态:清理通过 ctx.spawn 异步执行,可能尚未完成用户就触发了 undo-close;此时 CWD 仍存在、回退是 no-op,但用户后续运行命令时目录可能随着清理完成而消失;
  • 同名资源的竞态:标签页移除不等清理完成,用户可能立即用相同名字创建新的 worktree/分支,与清理任务竞争;
  • 失败仅提示:清理失败只有持久 toast + 警告日志,关闭路径中没有阻塞式的重试/撤销流程;
  • 依赖本地 shell:LocalShellState 不可用时清理被跳过;
  • 环境刻意最小化:清理命令只拿到 HOME 与 PATH,并不是窗格进程环境的完整克隆。

测试与验证

app/src/tab_configs/tab_config_tests.rs 是这块功能的主要测试阵地,新增覆盖包括:

  • 解析 worktree 配置中的 [on_close];
  • render_tab_close_behavior 替换用户手填参数;
  • render_tab_close_behavior 替换 autogenerated_branch_name;
  • 含空格的命令参数做 shell-quoting、而窗格 directory 保持不转义。

既有渲染测试继续覆盖打开时的标题/布局渲染,以及对非法窗格树的回退行为(渲染失败时回退为单个空终端窗格)。注意该文件里 test_parse_toml_with_on_close_fails 这类“旧版拒绝 on_close”的用例对应的是功能落地前的解析行为,验证的是 serde(deny_unknown_fields) 的严格性。

APP-3736 的验收路径还包括:运行针对 workspace 关闭清理失败路径的测试以验证持久错误 toast;手动验证“关闭时仅删除 worktree”的配置、手动验证“关闭时先删 worktree 再删分支”的配置、手动验证失败命令不阻塞标签页关闭且阻止后续命令执行、以及手动验证不含 [on_close] 的配置保持既有关闭行为。提交前还需对涉及模块运行 cargo fmt 与 Rust 测试/lint。

后续演进方向

规格 specs/APP-3736/TECH.md 预留了两个明确的 follow-up:

  • 快照持久化:若希望恢复的标签页继续执行 [on_close],需要把关闭行为持久化进 tab/workspace 快照;
  • 自动生成关闭配置:考虑在 build_worktree_config_toml 中自动生成 [on_close],让默认 worktree 配置无需手动编辑就能自我清理。

对于使用 worktree 工作流的开发者,这是让 Tab 配置从“一次性打开脚本”升级为“完整生命周期管理”的关键能力——打开创建、关闭回收,全部声明在同一个 TOML 文件里。

登录后查看全文
warp