10 KiB
APP-3736: Allow specifying tab-close behavior in tab configs — Tech Spec
Product spec: specs/APP-3736/PRODUCT.md
Problem
Tab configs can declare how to open a tab, including pane layout, startup commands, and templated params, but there was no declarative way to run cleanup when a tab created from a config is later closed. This was especially painful for worktree configs, which can create a worktree on open but previously left users to manually remove the worktree directory and branch.
Relevant Code
app/src/tab_configs/tab_config.rs—TabConfigOnClose,ResolvedTabCloseBehavior,TabConfig::on_close,render_tab_close_behavior,build_template_contextsapp/src/tab_configs/mod.rs— exportsrender_tab_close_behaviorandResolvedTabCloseBehaviorapp/src/tab.rs— storesresolved_tab_close_behavioronTabDataapp/src/workspace/view.rs— resolves close behavior when opening a tab config, runs cleanup during tab close, and executes close commands withLocalCommandExecutorapp/src/tab_configs/session_config.rs— initializeson_close: Nonefor generated configs that do not define close behaviorapp/src/tab_configs/tab_config_tests.rs— parser and template-rendering coverage for[on_close]
Current State
TabConfig already supports templated titles, pane directory, pane startup commands, and params. Rendering uses two template contexts: unquoted values for paths/titles and shell-quoted values for commands.
TabData is the in-memory state container for each tab. Before this change, it tracked pane group state, colors, mouse state, and telemetry flags, but not any tab-config-specific close behavior.
Tab close flows already funnel through Workspace::close_tabs, which optionally shows an unsaved-state confirmation dialog, cancels tab renaming, removes tabs in reverse index order, and emits telemetry once tabs are actually closed.
Changes
1. Tab-config schema and resolved close behavior
TabConfig now has an optional top-level on_close: Option<TabConfigOnClose>.
TabConfigOnClose mirrors the close-time command contract:
directory: Option<String>— optional working directory for cleanup commandscommands: Vec<String>— ordered commands to run on tab closeResolvedTabCloseBehaviorstores the fully rendered cleanup plan for one opened tab instance:directory: Option<PathBuf>commands: Vec<String>The resolved form is intentionally separate fromTabConfigOnCloseso close-time execution can use the exact values chosen when that tab was opened, without re-reading config files or re-prompting for params.
2. Shared template-context builder
build_template_contexts now centralizes construction of both template contexts used by tab config rendering:
unquoted_context: raw param values, plusautogenerated_branch_namewhen providedquoted_context: shell-quoted param values, plus a shell-quotedautogenerated_branch_namewhen providedrender_tab_configandrender_tab_close_behaviorboth use this helper so open-time and close-time template substitution follow the same quoting rules:- titles and pane
directoryuse unquoted values - commands use shell-quoted values This avoids duplicating context setup and preserves existing shell-quoting behavior for command templates with spaces.
3. Render and attach close behavior when opening tab configs
Workspace::open_tab_config_with_params now resolves both:
- the tab title + pane layout via
render_tab_config - the optional close behavior via
render_tab_close_behaviorAfter callingadd_tab_with_pane_layout, the method writes the resolved close behavior intoself.tabs[self.active_tab_index].resolved_tab_close_behavior. It also applies the tab color from the config as before. This means each tab instance carries its own rendered cleanup commands, including manual param values and the generated branch name frommaybe_generate_worktree_name.
4. Store close behavior on tab state
TabData has a new field:
resolved_tab_close_behavior: Option<ResolvedTabCloseBehavior>TabData::newinitializes it toNone, so ordinary tabs and tabs not opened from configs have no close hook and keep the existing close behavior. The field is stored only in memory on the liveTabData; it is not serialized into workspace snapshots.
5. Run cleanup from the existing tab-close flow
Workspace::close_tabs now collects each closing tab's resolved_tab_close_behavior before removing any tabs:
- Build
tab_indices_vec - Show the existing unsaved-state confirmation dialog if needed
- Cancel any in-progress tab rename
- Take (consume) each selected tab's resolved close behavior and mark
cleanup_was_run = trueon the tab - Call
run_tab_close_cleanupfor each collected behavior - Remove tabs in reverse index order as before Collecting the close behaviors first avoids depending on tab indices after tabs have been removed.
6. Async best-effort cleanup execution on native local-tty builds
On native builds with local_tty, run_tab_close_cleanup reads the current LocalShellState and spawns execute_tab_close_behavior with:
- shell type
- shell path
- PATH from the active local shell environment
If
LocalShellStateis unavailable, cleanup is skipped and a warning is logged.execute_tab_close_behaviorconstructs aLocalCommandExecutorand executes the resolved cleanup commands sequentially. For each command, it passes: - the resolved
directory, if present - environment variables containing
HOMEandPATHwhen available ExecuteCommandOptions { run_command_in_same_shell_as_session: true }If a command exits successfully, execution continues to the next command. If a command returns a non-zero status, execution stops and returns an error containing the failed command and command output. The spawned callback logs that error withlog::warn!. The workspace does not await this task before removing the tab, so cleanup is intentionally best-effort and non-blocking from the user's perspective. If cleanup fails, or ifLocalShellStateis unavailable,run_tab_close_cleanupalso adds a persistentDismissibleToast::error(...)to the workspace toast stack so the failure is visible without blocking tab close. On builds without native local-tty support,run_tab_close_cleanupis a no-op stub.
7. Default on_close value for generated configs
build_tab_config and tab_config_from_pane_snapshot now initialize on_close: None so generated startup/session configs and saved-from-live-tab configs preserve current behavior unless a user explicitly edits the TOML to add [on_close].
8. Tests
tab_config_tests.rs adds coverage for:
- parsing
[on_close]in a worktree config render_tab_close_behaviorsubstituting manual paramsrender_tab_close_behaviorsubstitutingautogenerated_branch_name- shell-quoting command params with spaces while leaving pane
directoryunquoted Existing render tests continue to cover open-time title/layout rendering and fallback behavior for invalid pane trees.
End-to-End Flow
- User selects a tab config from the new-session menu.
- If the config has params, the params modal collects values; otherwise defaults are used. If any pane sets
worktree_name_autogenerated = true,maybe_generate_worktree_namecreates a unique branch name. open_tab_config_with_paramsrenders the pane template, title, and optional close behavior, opens the tab, and stores the resolved close behavior on the newTabData.- User later closes that tab.
close_tabsgathers each tab's resolved close behavior and callsrun_tab_close_cleanupbefore removing the tabs.- On native local-tty builds, cleanup commands run asynchronously through
LocalCommandExecutor. Tabs disappear immediately; failures are logged and stop any remaining cleanup commands for that tab.
Risks and Limitations
- Single-fire cleanup:
resolved_tab_close_behavioris consumed (.take()n) before the tab is removed, so re-closing an undo-restored tab does not fire cleanup again. Tabs restored from workspace snapshots also do not recover a prior close hook because the field is not serialized. - Undo-close CWD fallback: when a tab with
cleanup_was_run = trueis restored via undo-close,restore_closed_tabiterates its terminal panes and callscd_home_if_dir_missingon each. If the CWD no longer exists (e.g., the worktree was removed by cleanup), the shell falls back to the home directory. - Race between cleanup and undo-close: cleanup runs asynchronously via
ctx.spawn, so it may not have finished by the time the user triggers undo-close. In that case, the CWD still exists at restoration time and the fallback is a no-op. If the user then runs commands, the directory may disappear mid-session once cleanup finishes. - Best-effort async cleanup: Because tab removal does not wait for cleanup completion, users can immediately create another worktree or branch with the same name and race against the cleanup task.
- Non-blocking failure toast only: Cleanup failures show a persistent toast and log a warning, but there is still no blocking retry/undo flow in the close path.
- Depends on local shell availability: If
LocalShellStateis unavailable, cleanup is skipped. - Command environment is intentionally minimal: Cleanup gets
HOMEandPATH, but not a full clone of the pane's process environment.
Testing and Validation
- Run targeted tab-config tests covering parser and rendering behavior, especially
[on_close]and shell-quoting cases. - Run targeted workspace tests for the close-cleanup failure path to verify persistent error toasts are shown.
- Manually verify a config that removes only a worktree on close.
- Manually verify a config that removes a worktree and then deletes the branch on close.
- Manually verify that a failing cleanup command does not block tab close, shows a persistent error toast, and prevents later cleanup commands from running.
- Manually verify that tabs opened from configs without
[on_close]keep existing close behavior. - Run
cargo fmtand a Rust test/lint pass appropriate for the touched modules before sending the branch for review.
Follow-ups
- Persist close behavior in tab/workspace snapshots if restored tabs should continue running
[on_close]. - Consider generating
[on_close]automatically inbuild_worktree_config_tomlif default worktree configs should clean themselves up without manual edits.