Files
galaxy/specs/APP-3736/TECH.md
T

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.rsTabConfigOnClose, ResolvedTabCloseBehavior, TabConfig::on_close, render_tab_close_behavior, build_template_contexts
  • app/src/tab_configs/mod.rs — exports render_tab_close_behavior and ResolvedTabCloseBehavior
  • app/src/tab.rs — stores resolved_tab_close_behavior on TabData
  • app/src/workspace/view.rs — resolves close behavior when opening a tab config, runs cleanup during tab close, and executes close commands with LocalCommandExecutor
  • app/src/tab_configs/session_config.rs — initializes on_close: None for generated configs that do not define close behavior
  • app/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 commands
  • commands: Vec<String> — ordered commands to run on tab close ResolvedTabCloseBehavior stores the fully rendered cleanup plan for one opened tab instance:
  • directory: Option<PathBuf>
  • commands: Vec<String> The resolved form is intentionally separate from TabConfigOnClose so 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, plus autogenerated_branch_name when provided
  • quoted_context: shell-quoted param values, plus a shell-quoted autogenerated_branch_name when provided render_tab_config and render_tab_close_behavior both use this helper so open-time and close-time template substitution follow the same quoting rules:
  • titles and pane directory use 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_behavior After calling add_tab_with_pane_layout, the method writes the resolved close behavior into self.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 from maybe_generate_worktree_name.

4. Store close behavior on tab state

TabData has a new field:

  • resolved_tab_close_behavior: Option<ResolvedTabCloseBehavior> TabData::new initializes it to None, 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 live TabData; 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:

  1. Build tab_indices_vec
  2. Show the existing unsaved-state confirmation dialog if needed
  3. Cancel any in-progress tab rename
  4. Take (consume) each selected tab's resolved close behavior and mark cleanup_was_run = true on the tab
  5. Call run_tab_close_cleanup for each collected behavior
  6. 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 LocalShellState is unavailable, cleanup is skipped and a warning is logged. execute_tab_close_behavior constructs a LocalCommandExecutor and executes the resolved cleanup commands sequentially. For each command, it passes:
  • the resolved directory, if present
  • environment variables containing HOME and PATH when 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 with log::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 if LocalShellState is unavailable, run_tab_close_cleanup also adds a persistent DismissibleToast::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_cleanup is 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_behavior substituting manual params
  • render_tab_close_behavior substituting autogenerated_branch_name
  • shell-quoting command params with spaces while leaving pane directory unquoted Existing render tests continue to cover open-time title/layout rendering and fallback behavior for invalid pane trees.

End-to-End Flow

  1. User selects a tab config from the new-session menu.
  2. 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_name creates a unique branch name.
  3. open_tab_config_with_params renders the pane template, title, and optional close behavior, opens the tab, and stores the resolved close behavior on the new TabData.
  4. User later closes that tab.
  5. close_tabs gathers each tab's resolved close behavior and calls run_tab_close_cleanup before removing the tabs.
  6. 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_behavior is 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 = true is restored via undo-close, restore_closed_tab iterates its terminal panes and calls cd_home_if_dir_missing on 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 LocalShellState is unavailable, cleanup is skipped.
  • Command environment is intentionally minimal: Cleanup gets HOME and PATH, 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 fmt and 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 in build_worktree_config_toml if default worktree configs should clean themselves up without manual edits.