Warp 标签页配置 `[on_close]`:在 Tab 关闭时声明式执行清理命令
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)现在是打开与关闭行为的连接点:
- 调用
render_tab_config渲染标题与窗格模板; - 调用
render_tab_close_behavior渲染可选的关闭行为; - 通过
add_tab_with_pane_layout打开标签页; - 把解析后的关闭行为写入
self.tabs[self.active_tab_index].resolved_tab_close_behavior; - 照旧应用配置里的
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 在既有流程中插入了清理步骤:
- 构建
tab_indices_vec; - 按需弹出未保存状态确认对话框;
- 取消任何进行中的标签页重命名;
- 逐个
.take()(消费)待关闭标签页的resolved_tab_close_behavior,并在标签页上标记cleanup_was_run = true; - 对每个收集到的行为调用
run_tab_close_cleanup; - 照旧按逆序索引移除标签页。
先收集再移除,是为了避免标签页被移除后索引失效带来的依赖问题。而“消费式”取走行为(.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 配置从打开到关闭的完整链路是:
- 用户从新建会话菜单中选择一个 Tab 配置;
- 若配置带参数,参数模态框收集值;否则使用默认值。若任一窗格设置了
worktree_name_autogenerated = true,maybe_generate_worktree_name会生成唯一分支名; open_tab_config_with_params渲染窗格模板、标题与可选的关闭行为,打开标签页并把解析后的关闭行为存到新的TabData上;- 用户后续关闭该标签页;
close_tabs收集每个标签页解析后的关闭行为,在移除标签页前调用run_tab_close_cleanup;- 在原生 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 文件里。