From 81c5a767dfd70b1ea91cccb4814b781d3912850f Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Mon, 4 May 2026 19:58:00 -0700 Subject: [PATCH 1/4] Add tab context metadata copy actions Co-Authored-By: Oz --- app/src/tab.rs | 60 ++++++++++ crates/integration/src/bin/integration.rs | 1 + crates/integration/src/test/workspace.rs | 104 +++++++++++++++++- .../integration/tests/integration/ui_tests.rs | 1 + specs/tab-context-copy-metadata/PRODUCT.md | 36 ++++++ specs/tab-context-copy-metadata/TECH.md | 42 +++++++ 6 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 specs/tab-context-copy-metadata/PRODUCT.md create mode 100644 specs/tab-context-copy-metadata/TECH.md diff --git a/app/src/tab.rs b/app/src/tab.rs index 92735f63d..90834386a 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -192,6 +192,7 @@ impl TabData { for section_items in [ self.session_sharing_menu_items(index, ctx), + self.copy_metadata_menu_items(pane_name_target, ctx), self.modify_tab_menu_items(index, tabs_len, pane_name_target, ctx), self.close_tab_menu_items(index, tabs_len, ctx), Self::save_config_menu_items(index), @@ -286,6 +287,65 @@ impl TabData { menu_items } + fn copy_metadata_menu_items( + &self, + pane_name_target: Option, + ctx: &AppContext, + ) -> Vec> { + let pane_group = self.pane_group.as_ref(ctx); + let terminal_view = pane_name_target + .filter(|target| self.pane_group.id() == target.locator.pane_group_id) + .and_then(|target| pane_group.terminal_view_from_pane_id(target.locator.pane_id, ctx)) + .or_else(|| pane_group.focused_session_view(ctx)); + let mut menu_items = vec![]; + let tab_title = Self::copyable_metadata_value(Some(pane_group.display_title(ctx))); + + if let Some(terminal_view) = terminal_view { + let terminal_view = terminal_view.as_ref(ctx); + Self::push_copy_metadata_menu_item( + &mut menu_items, + "Copy branch", + Self::copyable_metadata_value(terminal_view.current_git_branch(ctx)), + ); + Self::push_copy_metadata_menu_item(&mut menu_items, "Copy tab title", tab_title); + Self::push_copy_metadata_menu_item( + &mut menu_items, + "Copy Working Directory", + Self::copyable_metadata_value( + terminal_view + .pwd() + .or_else(|| terminal_view.display_working_directory(ctx)), + ), + ); + Self::push_copy_metadata_menu_item( + &mut menu_items, + "Copy PR link", + Self::copyable_metadata_value(terminal_view.current_pull_request_url(ctx)), + ); + } else { + Self::push_copy_metadata_menu_item(&mut menu_items, "Copy tab title", tab_title); + } + + menu_items + } + + fn push_copy_metadata_menu_item( + menu_items: &mut Vec>, + label: &'static str, + value: Option, + ) { + if let Some(value) = value { + menu_items.push( + MenuItemFields::new(label) + .with_on_select_action(WorkspaceAction::CopyTextToClipboard(value)) + .into_item(), + ); + } + } + + fn copyable_metadata_value(value: Option) -> Option { + value.filter(|value| !value.trim().is_empty()) + } fn modify_tab_menu_items( &self, index: usize, diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index a51b0f1c9..803efacac 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -336,6 +336,7 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_context_chips_prompt_at_bootstrap); register_test!(test_active_session_follows_focus); + register_test!(test_tab_context_menu_copies_metadata); register_test!(test_focus_panes_on_hover); diff --git a/crates/integration/src/test/workspace.rs b/crates/integration/src/test/workspace.rs index 3d83567f2..73d677bc5 100644 --- a/crates/integration/src/test/workspace.rs +++ b/crates/integration/src/test/workspace.rs @@ -1,6 +1,6 @@ //! Integration tests for workspace-level behavior. -use std::fs; +use std::{fs, time::Duration}; use pathfinder_geometry::{ rect::RectF, @@ -21,23 +21,26 @@ use warp::integration_testing::workspace::{ use warp::{ cmd_or_ctrl_shift, integration_testing::{ + clipboard::assert_clipboard_contains_string, pane_group::assert_focused_pane_index, step::new_step_with_default_assertions, terminal::{ assert_active_session_local_path, execute_command, - execute_command_for_single_terminal_in_tab, util::ExpectedExitStatus, + execute_command_for_single_terminal_in_tab, + util::{current_shell_starter_and_version, ExpectedExitStatus}, wait_until_bootstrapped_pane, wait_until_bootstrapped_single_pane_for_tab, }, }, settings::PaneSettings, - workspace::NEW_TAB_BUTTON_POSITION_ID, + terminal::shell::ShellType, + workspace::{WorkspaceAction, NEW_TAB_BUTTON_POSITION_ID}, }; use warpui::{ async_assert, async_assert_eq, event::{Event, ModifiersState}, - integration::{AssertionOutcome, TestStep}, + integration::{AssertionCallback, AssertionOutcome, TestStep}, windowing::WindowManager, - SingletonEntity, WindowId, + SingletonEntity, TypedActionView, WindowId, }; use crate::{util::skip_if_powershell_core_2303, Builder}; @@ -52,6 +55,40 @@ fn tab_position_id(tab_index: usize) -> String { format!("tab_position_{tab_index}") } +fn set_active_tab_name(name: &'static str) -> TestStep { + TestStep::new("Set active tab name").with_action(move |app, window_id, _| { + let workspace = workspace_view(app, window_id); + workspace.update(app, |workspace, ctx| { + workspace.handle_action(&WorkspaceAction::SetActiveTabName(name.to_string()), ctx); + }); + }) +} + +fn assert_current_git_branch(expected_branch: &'static str) -> AssertionCallback { + Box::new(move |app, window_id| { + let terminal_view = terminal_view(app, window_id, 0, 0); + terminal_view.read(app, |terminal_view, ctx| { + async_assert_eq!( + terminal_view.current_git_branch(ctx), + Some(expected_branch.to_string()) + ) + }) + }) +} + +fn assert_clipboard_contains_home() -> AssertionCallback { + Box::new(|app, _window_id| { + let clipboard = app.update(|ctx| ctx.clipboard().read()); + let content = match clipboard.paths { + Some(paths) => paths.join(" "), + None => clipboard.plain_text, + }; + let home = std::env::var("HOME").expect("HOME should be set for integration tests"); + + async_assert_eq!(content, home) + }) +} + fn focus_other_window(other_window_key: &'static str, known_window_key: &'static str) -> TestStep { TestStep::new("Focus other window").with_action(move |app, _, data| { let known_window_id = *data @@ -211,6 +248,63 @@ pub fn test_active_session_follows_focus() -> Builder { ) } +pub fn test_tab_context_menu_copies_metadata() -> Builder { + new_builder() + .set_should_run_test(|| { + let (starter, _) = current_shell_starter_and_version(); + starter.shell_type() != ShellType::PowerShell + }) + .use_tmp_filesystem_for_test_root_directory() + .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) + .with_step(set_active_tab_name("Integration Metadata Tab")) + .with_step(execute_command_for_single_terminal_in_tab( + 0, + "git init -b main; git config user.email \"test@test.com\"; git config user.name \"Git TestUser\"".into(), + ExpectedExitStatus::Success, + (), + )) + .with_step(execute_command_for_single_terminal_in_tab( + 0, + "touch file".into(), + ExpectedExitStatus::Success, + (), + )) + .with_step( + new_step_with_default_assertions("Git branch metadata should be populated") + .set_timeout(Duration::from_secs(15)) + .add_assertion(assert_current_git_branch("main")), + ) + .with_step( + new_step_with_default_assertions("Open tab context menu for branch copy") + .with_right_click_on_saved_position(tab_position_id(0)), + ) + .with_step( + new_step_with_default_assertions("Copy branch from tab context menu") + .with_click_on_saved_position("Copy branch") + .add_assertion(assert_clipboard_contains_string("main".to_string())), + ) + .with_step( + new_step_with_default_assertions("Open tab context menu for title copy") + .with_right_click_on_saved_position(tab_position_id(0)), + ) + .with_step( + new_step_with_default_assertions("Copy tab title from tab context menu") + .with_click_on_saved_position("Copy tab title") + .add_assertion(assert_clipboard_contains_string( + "Integration Metadata Tab".to_string(), + )), + ) + .with_step( + new_step_with_default_assertions("Open tab context menu for working directory copy") + .with_right_click_on_saved_position(tab_position_id(0)), + ) + .with_step( + new_step_with_default_assertions("Copy working directory from tab context menu") + .with_click_on_saved_position("Copy Working Directory") + .add_assertion(assert_clipboard_contains_home()), + ) +} + pub fn test_focus_panes_on_hover() -> Builder { new_builder() .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) diff --git a/crates/integration/tests/integration/ui_tests.rs b/crates/integration/tests/integration/ui_tests.rs index 11cb78f8e..0a91e4b26 100644 --- a/crates/integration/tests/integration/ui_tests.rs +++ b/crates/integration/tests/integration/ui_tests.rs @@ -209,6 +209,7 @@ integration_tests! { test_secrets_are_always_redacted_in_ai_inputs, test_active_session_follows_focus, + test_tab_context_menu_copies_metadata, test_focus_panes_on_hover, diff --git a/specs/tab-context-copy-metadata/PRODUCT.md b/specs/tab-context-copy-metadata/PRODUCT.md new file mode 100644 index 000000000..de0b6b974 --- /dev/null +++ b/specs/tab-context-copy-metadata/PRODUCT.md @@ -0,0 +1,36 @@ +# Tab Context Menu Copy Metadata — Product Spec +## Summary +Add copy actions to the tab context menu for metadata Warp already knows about the tab or active pane: branch name, tab title, current working directory, and PR link. +## Problem +Vertical tabs surface useful metadata such as branch, working directory, and PR link, but users must manually select or rederive that information when they want to share it elsewhere. The attached tab context menu is the natural place to expose quick copy actions because it is already where users manage tabs and active panes. +## Goals +- Show copy actions for branch, tab title, current working directory, and PR link when the relevant metadata exists. +- Avoid disabled or empty menu items; if Warp does not have a value, omit the corresponding copy action. +- Use the same metadata sources that vertical tabs already use, so menu availability matches what Warp knows about the tab or pane. +- Support both regular tab context menus and vertical-tabs pane context menus. +## Non-goals +- Fetch branch or PR data synchronously when metadata is not already available. +- Add new settings or feature flags for the copy actions. +- Change how tab titles, branch labels, working directories, or PR badges are rendered in vertical tabs. +- Add toast notifications for these copy actions. +## User Experience +When a user opens the tab context menu, Warp includes a copy metadata section if at least one copyable metadata value is available. +Available actions: +- Copy branch +- Copy tab title +- Copy current working directory +- Copy PR link +The section appears only when one or more of those actions are present. Each item copies the corresponding raw value to the system clipboard. The menu keeps the existing separator behavior, so the copy metadata section is visually grouped with the rest of the menu. +For vertical-tabs pane context menus, terminal-specific metadata comes from the pane represented by the context menu target. For regular tab context menus, terminal-specific metadata comes from the focused terminal session in the tab. Tab title comes from the tab-level display title. +## Success Criteria +1. A tab with a known branch shows Copy branch, and selecting it copies the branch name. +2. A tab with a non-empty display title shows Copy tab title, and selecting it copies that title. +3. A terminal tab with a known current working directory shows Copy current working directory, and selecting it copies the directory. +4. A terminal tab with a known PR URL shows Copy PR link, and selecting it copies the URL. +5. Copy actions are omitted individually when their metadata is unavailable or empty. +6. Existing context menu actions and separators continue to behave as before. +## Validation +- Manually verify the menu on a terminal in a git repository with and without a PR chip. +- Manually verify the menu on a terminal outside a git repository. +- Manually verify the vertical-tabs active-pane context menu. +- Run formatting and a targeted compile check for the affected crate. diff --git a/specs/tab-context-copy-metadata/TECH.md b/specs/tab-context-copy-metadata/TECH.md new file mode 100644 index 000000000..7134561fb --- /dev/null +++ b/specs/tab-context-copy-metadata/TECH.md @@ -0,0 +1,42 @@ +# Tab Context Menu Copy Metadata — Tech Spec +Product spec: `specs/tab-context-copy-metadata/PRODUCT.md` +## Problem +`TabData::menu_items_with_pane_name_target` builds the tab right-click menu in `app/src/tab.rs`. It currently groups session sharing, tab modification, close actions, tab-config saving, and color options. The metadata needed for copy actions already exists on `PaneGroup` and `TerminalView`, but the menu does not expose it. +## Relevant Code +- `app/src/tab.rs` — tab context menu construction. +- `app/src/workspace/action.rs` — `WorkspaceAction::CopyTextToClipboard(String)` already exists. +- `app/src/workspace/view.rs` — `CopyTextToClipboard` writes plain text to the clipboard. +- `app/src/terminal/view/tab_metadata.rs` — `TerminalView` helpers for display working directory, terminal title, branch, PR URL, and diff stats. +- `app/src/pane_group/mod.rs` — `PaneGroup::display_title`, `custom_title`, `focused_session_view`, and `terminal_view_from_pane_id`. +- `app/src/workspace/view/vertical_tabs.rs` — current vertical-tabs metadata rendering, including pane-targeted context menu behavior. +## Current State +The tab context menu is assembled from section methods that return `Vec>`. Separators are inserted between non-empty sections by `menu_items_with_pane_name_target`. +`WorkspaceAction::CopyTextToClipboard(String)` is already handled in `Workspace::handle_action` and writes text to the system clipboard. Using this existing action avoids adding new workspace actions for each metadata type. +Vertical-tabs pane context menus pass a `PaneNameMenuTarget` with a `PaneViewLocator`. This locator can be reused to resolve terminal metadata for the clicked or active pane. Regular tab context menus have no pane target, so they should use the focused terminal session in the tab. +## Changes +### 1. Add a copy metadata menu section +Add a new `copy_metadata_menu_items` section to `TabData`. Insert it after session-sharing items and before tab-modification items so copy actions are grouped near other share/copy actions. +The section appends: +- `Copy branch` when `TerminalView::current_git_branch(ctx)` is non-empty. +- `Copy tab title` when `PaneGroup::display_title(ctx)` is non-empty. +- `Copy current working directory` when the selected terminal has a non-empty `pwd()`, falling back to `display_working_directory(ctx)` if needed. +- `Copy PR link` when `TerminalView::current_pull_request_url(ctx)` is non-empty. +Each item dispatches `WorkspaceAction::CopyTextToClipboard(value)`. +### 2. Resolve terminal metadata from the correct target +When `pane_name_target` is present and belongs to this tab's `PaneGroup`, use `PaneGroup::terminal_view_from_pane_id(target.locator.pane_id, ctx)`. +Otherwise, use `PaneGroup::focused_session_view(ctx)`. +If no terminal view is available, omit terminal-specific items but still show `Copy tab title` when the tab has a display title. +### 3. Keep metadata values clean +Filter all values through a small helper that trims whitespace for availability checks and stores the original non-empty string for copying. This prevents blank menu rows while preserving the copied value. +## Risks and Mitigations +**Menu noise:** Copy tab title may appear broadly because most tabs have a display title. This is intentional because title copying is useful and the value is known. +**Stale metadata:** Branch, CWD, and PR link are copied from already-known `TerminalView` state. The change does not perform new synchronous GitHub or filesystem lookups, so it preserves menu responsiveness. +**Pane targeting:** In vertical-tabs pane mode, terminal metadata should come from the clicked or active pane. The locator check avoids accidentally reading metadata from a pane in a different tab. +## Testing and Validation +- Run `cargo fmt`. +- Run a targeted compile check for the app crate or workspace slice affected by `app/src/tab.rs`. +- Manual checks: + - Git terminal with branch metadata shows Copy branch. + - Terminal with a PR chip shows Copy PR link. + - Terminal without git metadata omits branch and PR items. + - Non-terminal or metadata-less contexts still omit terminal-only copy items. From 04856d55da6111ad11c46363ceb6fa7f3bf72d54 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Mon, 4 May 2026 20:14:25 -0700 Subject: [PATCH 2/4] Add vertical tab metadata copy integration test Co-Authored-By: Oz --- crates/integration/src/bin/integration.rs | 1 + crates/integration/src/test/workspace.rs | 174 ++++++++++++------ .../integration/tests/integration/ui_tests.rs | 1 + 3 files changed, 122 insertions(+), 54 deletions(-) diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 803efacac..3fc868554 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -337,6 +337,7 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_active_session_follows_focus); register_test!(test_tab_context_menu_copies_metadata); + register_test!(test_vertical_tab_context_menu_copies_metadata); register_test!(test_focus_panes_on_hover); diff --git a/crates/integration/src/test/workspace.rs b/crates/integration/src/test/workspace.rs index 73d677bc5..c6eec168a 100644 --- a/crates/integration/src/test/workspace.rs +++ b/crates/integration/src/test/workspace.rs @@ -20,6 +20,7 @@ use warp::integration_testing::workspace::{ }; use warp::{ cmd_or_ctrl_shift, + features::FeatureFlag, integration_testing::{ clipboard::assert_clipboard_contains_string, pane_group::assert_focused_pane_index, @@ -33,6 +34,7 @@ use warp::{ }, settings::PaneSettings, terminal::shell::ShellType, + workspace::tab_settings::TabSettings, workspace::{WorkspaceAction, NEW_TAB_BUTTON_POSITION_ID}, }; use warpui::{ @@ -50,11 +52,36 @@ use super::new_builder; const SOURCE_WINDOW_KEY: &str = "source window"; const TARGET_WINDOW_KEY: &str = "target window"; const DETACHED_WINDOW_KEY: &str = "detached window"; +const METADATA_TAB_TITLE: &str = "Integration Metadata Tab"; +const METADATA_BRANCH: &str = "main"; fn tab_position_id(tab_index: usize) -> String { format!("tab_position_{tab_index}") } +fn vertical_tab_pane_row_position_id(app: &mut warpui::App, window_id: WindowId) -> String { + let workspace = workspace_view(app, window_id); + let pane_group = workspace.read(app, |workspace, _ctx| { + workspace + .get_pane_group_view(0) + .expect("pane group should exist") + .clone() + }); + let pane_group_id = pane_group.id(); + pane_group.read(app, |pane_group, _ctx| { + let pane_id = pane_group + .terminal_pane_ids() + .next() + .expect("terminal pane should exist"); + format!("vertical_tabs:pane_row:{pane_group_id:?}:{pane_id}") + }) +} + +fn should_run_tab_context_menu_metadata_test() -> bool { + let (starter, _) = current_shell_starter_and_version(); + starter.shell_type() != ShellType::PowerShell +} + fn set_active_tab_name(name: &'static str) -> TestStep { TestStep::new("Set active tab name").with_action(move |app, window_id, _| { let workspace = workspace_view(app, window_id); @@ -64,6 +91,91 @@ fn set_active_tab_name(name: &'static str) -> TestStep { }) } +fn enable_vertical_tabs() -> TestStep { + FeatureFlag::VerticalTabs.set_enabled(true); + new_step_with_default_assertions("Enable vertical tabs").add_assertion(|app, _window_id| { + TabSettings::handle(app).update(app, |settings, ctx| { + settings + .use_vertical_tabs + .set_value(true, ctx) + .expect("vertical tabs setting should update"); + async_assert!(*settings.use_vertical_tabs) + }) + }) +} + +fn open_horizontal_tab_context_menu(step_name: &'static str) -> TestStep { + new_step_with_default_assertions(step_name) + .with_right_click_on_saved_position(tab_position_id(0)) +} + +fn open_vertical_tab_context_menu(step_name: &'static str) -> TestStep { + new_step_with_default_assertions(step_name) + .with_right_click_on_saved_position_fn(vertical_tab_pane_row_position_id) +} + +fn add_tab_context_metadata_setup_steps(builder: Builder) -> Builder { + builder + .set_should_run_test(should_run_tab_context_menu_metadata_test) + .use_tmp_filesystem_for_test_root_directory() + .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) + .with_step(set_active_tab_name(METADATA_TAB_TITLE)) + .with_step(execute_command_for_single_terminal_in_tab( + 0, + format!( + "git init -b {METADATA_BRANCH}; git config user.email \"test@test.com\"; git config user.name \"Git TestUser\"" + ), + ExpectedExitStatus::Success, + (), + )) + .with_step(execute_command_for_single_terminal_in_tab( + 0, + "touch file".into(), + ExpectedExitStatus::Success, + (), + )) + .with_step( + new_step_with_default_assertions("Git branch metadata should be populated") + .set_timeout(Duration::from_secs(15)) + .add_assertion(assert_current_git_branch(METADATA_BRANCH)), + ) +} + +fn add_tab_context_metadata_copy_steps( + builder: Builder, + open_tab_context_menu: fn(&'static str) -> TestStep, +) -> Builder { + builder + .with_step(open_tab_context_menu( + "Open tab context menu for branch copy", + )) + .with_step( + new_step_with_default_assertions("Copy branch from tab context menu") + .with_click_on_saved_position("Copy branch") + .add_assertion(assert_clipboard_contains_string( + METADATA_BRANCH.to_string(), + )), + ) + .with_step(open_tab_context_menu( + "Open tab context menu for title copy", + )) + .with_step( + new_step_with_default_assertions("Copy tab title from tab context menu") + .with_click_on_saved_position("Copy tab title") + .add_assertion(assert_clipboard_contains_string( + METADATA_TAB_TITLE.to_string(), + )), + ) + .with_step(open_tab_context_menu( + "Open tab context menu for working directory copy", + )) + .with_step( + new_step_with_default_assertions("Copy working directory from tab context menu") + .with_click_on_saved_position("Copy Working Directory") + .add_assertion(assert_clipboard_contains_home()), + ) +} + fn assert_current_git_branch(expected_branch: &'static str) -> AssertionCallback { Box::new(move |app, window_id| { let terminal_view = terminal_view(app, window_id, 0, 0); @@ -249,60 +361,14 @@ pub fn test_active_session_follows_focus() -> Builder { } pub fn test_tab_context_menu_copies_metadata() -> Builder { - new_builder() - .set_should_run_test(|| { - let (starter, _) = current_shell_starter_and_version(); - starter.shell_type() != ShellType::PowerShell - }) - .use_tmp_filesystem_for_test_root_directory() - .with_step(wait_until_bootstrapped_single_pane_for_tab(0)) - .with_step(set_active_tab_name("Integration Metadata Tab")) - .with_step(execute_command_for_single_terminal_in_tab( - 0, - "git init -b main; git config user.email \"test@test.com\"; git config user.name \"Git TestUser\"".into(), - ExpectedExitStatus::Success, - (), - )) - .with_step(execute_command_for_single_terminal_in_tab( - 0, - "touch file".into(), - ExpectedExitStatus::Success, - (), - )) - .with_step( - new_step_with_default_assertions("Git branch metadata should be populated") - .set_timeout(Duration::from_secs(15)) - .add_assertion(assert_current_git_branch("main")), - ) - .with_step( - new_step_with_default_assertions("Open tab context menu for branch copy") - .with_right_click_on_saved_position(tab_position_id(0)), - ) - .with_step( - new_step_with_default_assertions("Copy branch from tab context menu") - .with_click_on_saved_position("Copy branch") - .add_assertion(assert_clipboard_contains_string("main".to_string())), - ) - .with_step( - new_step_with_default_assertions("Open tab context menu for title copy") - .with_right_click_on_saved_position(tab_position_id(0)), - ) - .with_step( - new_step_with_default_assertions("Copy tab title from tab context menu") - .with_click_on_saved_position("Copy tab title") - .add_assertion(assert_clipboard_contains_string( - "Integration Metadata Tab".to_string(), - )), - ) - .with_step( - new_step_with_default_assertions("Open tab context menu for working directory copy") - .with_right_click_on_saved_position(tab_position_id(0)), - ) - .with_step( - new_step_with_default_assertions("Copy working directory from tab context menu") - .with_click_on_saved_position("Copy Working Directory") - .add_assertion(assert_clipboard_contains_home()), - ) + let builder = add_tab_context_metadata_setup_steps(new_builder()); + add_tab_context_metadata_copy_steps(builder, open_horizontal_tab_context_menu) +} + +pub fn test_vertical_tab_context_menu_copies_metadata() -> Builder { + let builder = + add_tab_context_metadata_setup_steps(new_builder()).with_step(enable_vertical_tabs()); + add_tab_context_metadata_copy_steps(builder, open_vertical_tab_context_menu) } pub fn test_focus_panes_on_hover() -> Builder { diff --git a/crates/integration/tests/integration/ui_tests.rs b/crates/integration/tests/integration/ui_tests.rs index 0a91e4b26..c37947c3a 100644 --- a/crates/integration/tests/integration/ui_tests.rs +++ b/crates/integration/tests/integration/ui_tests.rs @@ -210,6 +210,7 @@ integration_tests! { test_active_session_follows_focus, test_tab_context_menu_copies_metadata, + test_vertical_tab_context_menu_copies_metadata, test_focus_panes_on_hover, From b92104b7e02a5c872e33a9c243555968c2b72fa2 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Tue, 5 May 2026 15:24:36 -0700 Subject: [PATCH 3/4] Update tab context metadata menu behavior Adjust copy metadata menu items to reflect horizontal tabs, vertical tab grouping, and vertical pane grouping. Add integration coverage for pane-grouped vertical tabs and update the specs. Co-Authored-By: Oz --- app/src/tab.rs | 61 +++++- crates/integration/src/bin/integration.rs | 1 + crates/integration/src/test/workspace.rs | 204 +++++++++++++++--- .../integration/tests/integration/ui_tests.rs | 1 + specs/tab-context-copy-metadata/PRODUCT.md | 42 ++-- specs/tab-context-copy-metadata/TECH.md | 40 ++-- 6 files changed, 274 insertions(+), 75 deletions(-) diff --git a/app/src/tab.rs b/app/src/tab.rs index 90834386a..8e2bce094 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -7,7 +7,7 @@ use crate::editor::EditorView; use crate::features::FeatureFlag; use crate::launch_configs::launch_config::LaunchConfig; use crate::menu::{MenuAction, MenuItem, MenuItemFields}; -use crate::pane_group::PaneGroup; +use crate::pane_group::{PaneGroup, PaneId}; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use settings::Setting as _; use std::sync::Arc; @@ -25,7 +25,9 @@ use crate::util::truncation::truncate_from_end; use crate::window_settings::WindowSettings; use crate::workspace::sync_inputs::SyncedInputState; -use crate::workspace::tab_settings::{TabCloseButtonPosition, TabSettings}; +use crate::workspace::tab_settings::{ + TabCloseButtonPosition, TabSettings, VerticalTabsDisplayGranularity, +}; use crate::workspace::{ PaneViewLocator, TabBarDropTargetData, TabBarLocation, TabContextMenuAnchor, WorkspaceAction, }; @@ -287,18 +289,56 @@ impl TabData { menu_items } + fn copyable_pane_title( + pane_group: &PaneGroup, + pane_id: PaneId, + ctx: &AppContext, + ) -> Option { + pane_group.pane_by_id(pane_id).and_then(|pane| { + let configuration = pane.pane_configuration(); + let configuration = configuration.as_ref(ctx); + let title = configuration + .custom_vertical_tabs_title() + .unwrap_or_else(|| configuration.title()); + Self::copyable_metadata_value(Some(title.to_string())) + }) + } + fn copy_metadata_menu_items( &self, pane_name_target: Option, ctx: &AppContext, ) -> Vec> { let pane_group = self.pane_group.as_ref(ctx); - let terminal_view = pane_name_target - .filter(|target| self.pane_group.id() == target.locator.pane_group_id) - .and_then(|target| pane_group.terminal_view_from_pane_id(target.locator.pane_id, ctx)) - .or_else(|| pane_group.focused_session_view(ctx)); let mut menu_items = vec![]; let tab_title = Self::copyable_metadata_value(Some(pane_group.display_title(ctx))); + if !uses_vertical_tabs(ctx) { + Self::push_copy_metadata_menu_item(&mut menu_items, "Copy tab title", tab_title); + return menu_items; + } + + let vertical_tabs_display_granularity = *TabSettings::as_ref(ctx) + .vertical_tabs_display_granularity + .value(); + let (title_label, title, terminal_view) = if matches!( + vertical_tabs_display_granularity, + VerticalTabsDisplayGranularity::Panes + ) { + let pane_id = pane_group.focused_pane_id(ctx); + ( + "Copy pane title", + Self::copyable_pane_title(pane_group, pane_id, ctx), + pane_group.terminal_view_from_pane_id(pane_id, ctx), + ) + } else { + let terminal_view = pane_name_target + .filter(|target| self.pane_group.id() == target.locator.pane_group_id) + .and_then(|target| { + pane_group.terminal_view_from_pane_id(target.locator.pane_id, ctx) + }) + .or_else(|| pane_group.focused_session_view(ctx)); + ("Copy tab title", tab_title, terminal_view) + }; if let Some(terminal_view) = terminal_view { let terminal_view = terminal_view.as_ref(ctx); @@ -307,10 +347,10 @@ impl TabData { "Copy branch", Self::copyable_metadata_value(terminal_view.current_git_branch(ctx)), ); - Self::push_copy_metadata_menu_item(&mut menu_items, "Copy tab title", tab_title); + Self::push_copy_metadata_menu_item(&mut menu_items, title_label, title); Self::push_copy_metadata_menu_item( &mut menu_items, - "Copy Working Directory", + "Copy working directory", Self::copyable_metadata_value( terminal_view .pwd() @@ -319,11 +359,11 @@ impl TabData { ); Self::push_copy_metadata_menu_item( &mut menu_items, - "Copy PR link", + "Copy pull request link", Self::copyable_metadata_value(terminal_view.current_pull_request_url(ctx)), ); } else { - Self::push_copy_metadata_menu_item(&mut menu_items, "Copy tab title", tab_title); + Self::push_copy_metadata_menu_item(&mut menu_items, title_label, title); } menu_items @@ -346,6 +386,7 @@ impl TabData { fn copyable_metadata_value(value: Option) -> Option { value.filter(|value| !value.trim().is_empty()) } + fn modify_tab_menu_items( &self, index: usize, diff --git a/crates/integration/src/bin/integration.rs b/crates/integration/src/bin/integration.rs index 3fc868554..3c817ec99 100644 --- a/crates/integration/src/bin/integration.rs +++ b/crates/integration/src/bin/integration.rs @@ -338,6 +338,7 @@ fn register_tests() -> HashMap<&'static str, BoxedBuilderFn> { register_test!(test_active_session_follows_focus); register_test!(test_tab_context_menu_copies_metadata); register_test!(test_vertical_tab_context_menu_copies_metadata); + register_test!(test_vertical_pane_context_menu_copies_metadata); register_test!(test_focus_panes_on_hover); diff --git a/crates/integration/src/test/workspace.rs b/crates/integration/src/test/workspace.rs index c6eec168a..203c7bc21 100644 --- a/crates/integration/src/test/workspace.rs +++ b/crates/integration/src/test/workspace.rs @@ -1,6 +1,6 @@ //! Integration tests for workspace-level behavior. -use std::{fs, time::Duration}; +use std::{fs, path::Path, time::Duration}; use pathfinder_geometry::{ rect::RectF, @@ -34,7 +34,7 @@ use warp::{ }, settings::PaneSettings, terminal::shell::ShellType, - workspace::tab_settings::TabSettings, + workspace::tab_settings::{TabSettings, VerticalTabsDisplayGranularity}, workspace::{WorkspaceAction, NEW_TAB_BUTTON_POSITION_ID}, }; use warpui::{ @@ -54,6 +54,9 @@ const TARGET_WINDOW_KEY: &str = "target window"; const DETACHED_WINDOW_KEY: &str = "detached window"; const METADATA_TAB_TITLE: &str = "Integration Metadata Tab"; const METADATA_BRANCH: &str = "main"; +const METADATA_PANE_TITLE: &str = "Integration Metadata Pane"; +const METADATA_PANE_BRANCH: &str = "pane-branch"; +const METADATA_PANE_DIRECTORY: &str = "active-pane"; fn tab_position_id(tab_index: usize) -> String { format!("tab_position_{tab_index}") @@ -68,11 +71,8 @@ fn vertical_tab_pane_row_position_id(app: &mut warpui::App, window_id: WindowId) .clone() }); let pane_group_id = pane_group.id(); - pane_group.read(app, |pane_group, _ctx| { - let pane_id = pane_group - .terminal_pane_ids() - .next() - .expect("terminal pane should exist"); + pane_group.read(app, |pane_group, ctx| { + let pane_id = pane_group.focused_pane_id(ctx); format!("vertical_tabs:pane_row:{pane_group_id:?}:{pane_id}") }) } @@ -91,19 +91,50 @@ fn set_active_tab_name(name: &'static str) -> TestStep { }) } -fn enable_vertical_tabs() -> TestStep { - FeatureFlag::VerticalTabs.set_enabled(true); - new_step_with_default_assertions("Enable vertical tabs").add_assertion(|app, _window_id| { - TabSettings::handle(app).update(app, |settings, ctx| { - settings - .use_vertical_tabs - .set_value(true, ctx) - .expect("vertical tabs setting should update"); - async_assert!(*settings.use_vertical_tabs) - }) +fn set_active_pane_name(name: &'static str) -> TestStep { + TestStep::new("Set active pane name").with_action(move |app, window_id, _| { + let workspace = workspace_view(app, window_id); + let pane_group = workspace.read(app, |workspace, _ctx| { + workspace + .get_pane_group_view(0) + .expect("pane group should exist") + .clone() + }); + pane_group.update(app, |pane_group, ctx| { + let pane_id = pane_group.focused_pane_id(ctx); + let pane = pane_group + .pane_by_id(pane_id) + .expect("focused pane should exist"); + pane.pane_configuration().update(ctx, |configuration, ctx| { + configuration.set_custom_vertical_tabs_title(name, ctx); + }); + }); }) } +fn enable_vertical_tabs(display_granularity: VerticalTabsDisplayGranularity) -> TestStep { + FeatureFlag::VerticalTabs.set_enabled(true); + new_step_with_default_assertions("Enable vertical tabs").add_assertion( + move |app, _window_id| { + TabSettings::handle(app).update(app, |settings, ctx| { + settings + .use_vertical_tabs + .set_value(true, ctx) + .expect("vertical tabs setting should update"); + settings + .vertical_tabs_display_granularity + .set_value(display_granularity, ctx) + .expect("vertical tabs display granularity should update"); + async_assert!( + *settings.use_vertical_tabs + && *settings.vertical_tabs_display_granularity.value() + == display_granularity + ) + }) + }, + ) +} + fn open_horizontal_tab_context_menu(step_name: &'static str) -> TestStep { new_step_with_default_assertions(step_name) .with_right_click_on_saved_position(tab_position_id(0)) @@ -137,11 +168,59 @@ fn add_tab_context_metadata_setup_steps(builder: Builder) -> Builder { .with_step( new_step_with_default_assertions("Git branch metadata should be populated") .set_timeout(Duration::from_secs(15)) - .add_assertion(assert_current_git_branch(METADATA_BRANCH)), + .add_assertion(assert_current_git_branch(0, METADATA_BRANCH)), + ) +} + +fn add_active_pane_context_metadata_setup_steps(builder: Builder) -> Builder { + builder + .with_step( + new_step_with_default_assertions("Create active split pane") + .with_keystrokes(&[cmd_or_ctrl_shift("d")]), + ) + .with_step(wait_until_bootstrapped_pane(0, 1)) + .with_step(set_active_pane_name(METADATA_PANE_TITLE)) + .with_step(execute_command( + 0, + 1, + format!( + "mkdir {METADATA_PANE_DIRECTORY}; cd {METADATA_PANE_DIRECTORY}; git init -b {METADATA_PANE_BRANCH}; git config user.email \"test@test.com\"; git config user.name \"Git TestUser\"; touch file" + ), + ExpectedExitStatus::Success, + (), + )) + .with_step( + new_step_with_default_assertions("Active pane git branch metadata should be populated") + .set_timeout(Duration::from_secs(15)) + .add_assertion(assert_current_git_branch(1, METADATA_PANE_BRANCH)), ) } -fn add_tab_context_metadata_copy_steps( +fn add_horizontal_tab_context_metadata_copy_steps( + builder: Builder, + open_tab_context_menu: fn(&'static str) -> TestStep, +) -> Builder { + builder + .with_step( + open_tab_context_menu("Open tab context menu for title copy").add_assertion( + assert_saved_positions_absent(&[ + "Copy branch", + "Copy pane title", + "Copy working directory", + "Copy pull request link", + ]), + ), + ) + .with_step( + new_step_with_default_assertions("Copy tab title from tab context menu") + .with_click_on_saved_position("Copy tab title") + .add_assertion(assert_clipboard_contains_string( + METADATA_TAB_TITLE.to_string(), + )), + ) +} + +fn add_vertical_tab_context_metadata_copy_steps( builder: Builder, open_tab_context_menu: fn(&'static str) -> TestStep, ) -> Builder { @@ -171,14 +250,54 @@ fn add_tab_context_metadata_copy_steps( )) .with_step( new_step_with_default_assertions("Copy working directory from tab context menu") - .with_click_on_saved_position("Copy Working Directory") + .with_click_on_saved_position("Copy working directory") .add_assertion(assert_clipboard_contains_home()), ) } -fn assert_current_git_branch(expected_branch: &'static str) -> AssertionCallback { +fn add_vertical_pane_context_metadata_copy_steps( + builder: Builder, + open_tab_context_menu: fn(&'static str) -> TestStep, +) -> Builder { + builder + .with_step(open_tab_context_menu( + "Open pane context menu for branch copy", + )) + .with_step( + new_step_with_default_assertions("Copy branch from pane context menu") + .with_click_on_saved_position("Copy branch") + .add_assertion(assert_clipboard_contains_string( + METADATA_PANE_BRANCH.to_string(), + )), + ) + .with_step(open_tab_context_menu( + "Open pane context menu for title copy", + )) + .with_step( + new_step_with_default_assertions("Copy pane title from pane context menu") + .with_click_on_saved_position("Copy pane title") + .add_assertion(assert_clipboard_contains_string( + METADATA_PANE_TITLE.to_string(), + )), + ) + .with_step(open_tab_context_menu( + "Open pane context menu for working directory copy", + )) + .with_step( + new_step_with_default_assertions("Copy working directory from pane context menu") + .with_click_on_saved_position("Copy working directory") + .add_assertion(assert_clipboard_contains_home_child( + METADATA_PANE_DIRECTORY, + )), + ) +} + +fn assert_current_git_branch( + pane_index: usize, + expected_branch: &'static str, +) -> AssertionCallback { Box::new(move |app, window_id| { - let terminal_view = terminal_view(app, window_id, 0, 0); + let terminal_view = terminal_view(app, window_id, 0, pane_index); terminal_view.read(app, |terminal_view, ctx| { async_assert_eq!( terminal_view.current_git_branch(ctx), @@ -188,6 +307,17 @@ fn assert_current_git_branch(expected_branch: &'static str) -> AssertionCallback }) } +fn assert_saved_positions_absent(labels: &'static [&'static str]) -> AssertionCallback { + Box::new(move |app, window_id| { + let presenter = app.presenter(window_id).expect("presenter should exist"); + let presenter = presenter.borrow(); + let position_cache = presenter.position_cache(); + async_assert!(labels + .iter() + .all(|label| position_cache.get_position(label).is_none())) + }) +} + fn assert_clipboard_contains_home() -> AssertionCallback { Box::new(|app, _window_id| { let clipboard = app.update(|ctx| ctx.clipboard().read()); @@ -201,6 +331,20 @@ fn assert_clipboard_contains_home() -> AssertionCallback { }) } +fn assert_clipboard_contains_home_child(child: &'static str) -> AssertionCallback { + Box::new(move |app, _window_id| { + let clipboard = app.update(|ctx| ctx.clipboard().read()); + let content = match clipboard.paths { + Some(paths) => paths.join(" "), + None => clipboard.plain_text, + }; + let home = std::env::var("HOME").expect("HOME should be set for integration tests"); + let path = Path::new(&home).join(child).to_string_lossy().to_string(); + + async_assert_eq!(content, path) + }) +} + fn focus_other_window(other_window_key: &'static str, known_window_key: &'static str) -> TestStep { TestStep::new("Focus other window").with_action(move |app, _, data| { let known_window_id = *data @@ -362,13 +506,21 @@ pub fn test_active_session_follows_focus() -> Builder { pub fn test_tab_context_menu_copies_metadata() -> Builder { let builder = add_tab_context_metadata_setup_steps(new_builder()); - add_tab_context_metadata_copy_steps(builder, open_horizontal_tab_context_menu) + add_horizontal_tab_context_metadata_copy_steps(builder, open_horizontal_tab_context_menu) } pub fn test_vertical_tab_context_menu_copies_metadata() -> Builder { - let builder = - add_tab_context_metadata_setup_steps(new_builder()).with_step(enable_vertical_tabs()); - add_tab_context_metadata_copy_steps(builder, open_vertical_tab_context_menu) + let builder = add_tab_context_metadata_setup_steps(new_builder()) + .with_step(enable_vertical_tabs(VerticalTabsDisplayGranularity::Tabs)); + add_vertical_tab_context_metadata_copy_steps(builder, open_vertical_tab_context_menu) +} + +pub fn test_vertical_pane_context_menu_copies_metadata() -> Builder { + let builder = add_active_pane_context_metadata_setup_steps( + add_tab_context_metadata_setup_steps(new_builder()) + .with_step(enable_vertical_tabs(VerticalTabsDisplayGranularity::Panes)), + ); + add_vertical_pane_context_metadata_copy_steps(builder, open_vertical_tab_context_menu) } pub fn test_focus_panes_on_hover() -> Builder { diff --git a/crates/integration/tests/integration/ui_tests.rs b/crates/integration/tests/integration/ui_tests.rs index c37947c3a..403843fc9 100644 --- a/crates/integration/tests/integration/ui_tests.rs +++ b/crates/integration/tests/integration/ui_tests.rs @@ -211,6 +211,7 @@ integration_tests! { test_active_session_follows_focus, test_tab_context_menu_copies_metadata, test_vertical_tab_context_menu_copies_metadata, + test_vertical_pane_context_menu_copies_metadata, test_focus_panes_on_hover, diff --git a/specs/tab-context-copy-metadata/PRODUCT.md b/specs/tab-context-copy-metadata/PRODUCT.md index de0b6b974..fd7533bd2 100644 --- a/specs/tab-context-copy-metadata/PRODUCT.md +++ b/specs/tab-context-copy-metadata/PRODUCT.md @@ -1,36 +1,38 @@ # Tab Context Menu Copy Metadata — Product Spec ## Summary -Add copy actions to the tab context menu for metadata Warp already knows about the tab or active pane: branch name, tab title, current working directory, and PR link. +Add copy actions to tab and vertical-tabs context menus for metadata Warp already knows about the visible tab or active pane. ## Problem -Vertical tabs surface useful metadata such as branch, working directory, and PR link, but users must manually select or rederive that information when they want to share it elsewhere. The attached tab context menu is the natural place to expose quick copy actions because it is already where users manage tabs and active panes. +Vertical tabs surface useful metadata such as branch, working directory, and pull request link, but users must manually select or rederive that information when they want to share it elsewhere. The attached context menu is the natural place to expose quick copy actions because it is already where users manage tabs and active panes. ## Goals -- Show copy actions for branch, tab title, current working directory, and PR link when the relevant metadata exists. +- Show copy actions for branch, tab or pane title, working directory, and pull request link when the relevant metadata is visible and exists. - Avoid disabled or empty menu items; if Warp does not have a value, omit the corresponding copy action. - Use the same metadata sources that vertical tabs already use, so menu availability matches what Warp knows about the tab or pane. -- Support both regular tab context menus and vertical-tabs pane context menus. +- Support regular tab context menus, vertical-tabs tab context menus, and vertical-tabs pane context menus. ## Non-goals -- Fetch branch or PR data synchronously when metadata is not already available. +- Fetch branch or pull request data synchronously when metadata is not already available. - Add new settings or feature flags for the copy actions. -- Change how tab titles, branch labels, working directories, or PR badges are rendered in vertical tabs. +- Change how tab titles, branch labels, working directories, or pull request badges are rendered in vertical tabs. - Add toast notifications for these copy actions. ## User Experience -When a user opens the tab context menu, Warp includes a copy metadata section if at least one copyable metadata value is available. -Available actions: +When a user opens the context menu, Warp includes a copy metadata section if at least one copyable metadata value is available. +Available actions in vertical tabs: - Copy branch - Copy tab title -- Copy current working directory -- Copy PR link +- Copy pane title +- Copy working directory +- Copy pull request link The section appears only when one or more of those actions are present. Each item copies the corresponding raw value to the system clipboard. The menu keeps the existing separator behavior, so the copy metadata section is visually grouped with the rest of the menu. -For vertical-tabs pane context menus, terminal-specific metadata comes from the pane represented by the context menu target. For regular tab context menus, terminal-specific metadata comes from the focused terminal session in the tab. Tab title comes from the tab-level display title. +For horizontal tab context menus, only Copy tab title is shown because the other metadata is not visible in that layout. For vertical tabs grouped by tabs, terminal-specific metadata comes from the focused terminal session in the tab and the title action is Copy tab title. For vertical tabs grouped by panes, terminal-specific metadata comes from the active pane and the title action is Copy pane title. ## Success Criteria -1. A tab with a known branch shows Copy branch, and selecting it copies the branch name. +1. A vertical tab or pane with a known branch shows Copy branch, and selecting it copies the branch name. 2. A tab with a non-empty display title shows Copy tab title, and selecting it copies that title. -3. A terminal tab with a known current working directory shows Copy current working directory, and selecting it copies the directory. -4. A terminal tab with a known PR URL shows Copy PR link, and selecting it copies the URL. -5. Copy actions are omitted individually when their metadata is unavailable or empty. -6. Existing context menu actions and separators continue to behave as before. +3. A vertical pane with a non-empty pane title shows Copy pane title, and selecting it copies that title. +4. A vertical terminal tab or pane with a known working directory shows Copy working directory, and selecting it copies the directory. +5. A vertical terminal tab or pane with a known pull request URL shows Copy pull request link, and selecting it copies the URL. +6. Copy actions are omitted individually when their metadata is unavailable, empty, or not visible in the current layout. +7. Existing context menu actions and separators continue to behave as before. ## Validation -- Manually verify the menu on a terminal in a git repository with and without a PR chip. -- Manually verify the menu on a terminal outside a git repository. -- Manually verify the vertical-tabs active-pane context menu. -- Run formatting and a targeted compile check for the affected crate. +- Manually verify the horizontal tab context menu only includes the tab-title copy item. +- Manually verify the vertical-tabs tab context menu on a terminal in a git repository with and without a pull request chip. +- Manually verify the vertical-tabs pane context menu with pane grouping enabled. +- Run formatting and targeted integration tests for the affected flows. diff --git a/specs/tab-context-copy-metadata/TECH.md b/specs/tab-context-copy-metadata/TECH.md index 7134561fb..e6fa0f98e 100644 --- a/specs/tab-context-copy-metadata/TECH.md +++ b/specs/tab-context-copy-metadata/TECH.md @@ -1,42 +1,44 @@ # Tab Context Menu Copy Metadata — Tech Spec Product spec: `specs/tab-context-copy-metadata/PRODUCT.md` ## Problem -`TabData::menu_items_with_pane_name_target` builds the tab right-click menu in `app/src/tab.rs`. It currently groups session sharing, tab modification, close actions, tab-config saving, and color options. The metadata needed for copy actions already exists on `PaneGroup` and `TerminalView`, but the menu does not expose it. +`TabData::menu_items_with_pane_name_target` builds the tab right-click menu in `app/src/tab.rs`. It currently groups session sharing, tab modification, close actions, tab-config saving, and color options. The metadata needed for copy actions already exists on `PaneGroup`, pane configuration, and `TerminalView`, but the menu does not expose it with layout-aware behavior. ## Relevant Code - `app/src/tab.rs` — tab context menu construction. - `app/src/workspace/action.rs` — `WorkspaceAction::CopyTextToClipboard(String)` already exists. - `app/src/workspace/view.rs` — `CopyTextToClipboard` writes plain text to the clipboard. -- `app/src/terminal/view/tab_metadata.rs` — `TerminalView` helpers for display working directory, terminal title, branch, PR URL, and diff stats. -- `app/src/pane_group/mod.rs` — `PaneGroup::display_title`, `custom_title`, `focused_session_view`, and `terminal_view_from_pane_id`. +- `app/src/terminal/view/tab_metadata.rs` — `TerminalView` helpers for display working directory, terminal title, branch, pull request URL, and diff stats. +- `app/src/pane_group/mod.rs` — `PaneGroup::display_title`, `custom_title`, `focused_session_view`, `focused_pane_id`, and `terminal_view_from_pane_id`. - `app/src/workspace/view/vertical_tabs.rs` — current vertical-tabs metadata rendering, including pane-targeted context menu behavior. ## Current State The tab context menu is assembled from section methods that return `Vec>`. Separators are inserted between non-empty sections by `menu_items_with_pane_name_target`. `WorkspaceAction::CopyTextToClipboard(String)` is already handled in `Workspace::handle_action` and writes text to the system clipboard. Using this existing action avoids adding new workspace actions for each metadata type. -Vertical-tabs pane context menus pass a `PaneNameMenuTarget` with a `PaneViewLocator`. This locator can be reused to resolve terminal metadata for the clicked or active pane. Regular tab context menus have no pane target, so they should use the focused terminal session in the tab. +Vertical-tabs pane context menus pass a `PaneNameMenuTarget` with a `PaneViewLocator`. Regular horizontal tab context menus have no visible terminal metadata, so they only expose the tab title. ## Changes ### 1. Add a copy metadata menu section Add a new `copy_metadata_menu_items` section to `TabData`. Insert it after session-sharing items and before tab-modification items so copy actions are grouped near other share/copy actions. The section appends: -- `Copy branch` when `TerminalView::current_git_branch(ctx)` is non-empty. -- `Copy tab title` when `PaneGroup::display_title(ctx)` is non-empty. -- `Copy current working directory` when the selected terminal has a non-empty `pwd()`, falling back to `display_working_directory(ctx)` if needed. -- `Copy PR link` when `TerminalView::current_pull_request_url(ctx)` is non-empty. +- `Copy branch` when vertical tabs are enabled and `TerminalView::current_git_branch(ctx)` is non-empty. +- `Copy tab title` when the current layout is horizontal tabs or vertical tabs grouped by tabs and `PaneGroup::display_title(ctx)` is non-empty. +- `Copy pane title` when vertical tabs are grouped by panes and the active pane has a non-empty title. +- `Copy working directory` when vertical tabs are enabled and the selected terminal has a non-empty `pwd()`, falling back to `display_working_directory(ctx)` if needed. +- `Copy pull request link` when vertical tabs are enabled and `TerminalView::current_pull_request_url(ctx)` is non-empty. Each item dispatches `WorkspaceAction::CopyTextToClipboard(value)`. ### 2. Resolve terminal metadata from the correct target -When `pane_name_target` is present and belongs to this tab's `PaneGroup`, use `PaneGroup::terminal_view_from_pane_id(target.locator.pane_id, ctx)`. -Otherwise, use `PaneGroup::focused_session_view(ctx)`. -If no terminal view is available, omit terminal-specific items but still show `Copy tab title` when the tab has a display title. +For horizontal tabs, only use `PaneGroup::display_title(ctx)`. +For vertical tabs grouped by panes, use `PaneGroup::focused_pane_id(ctx)` to resolve the active pane title and terminal metadata. +For vertical tabs grouped by tabs, when `pane_name_target` is present and belongs to this tab's `PaneGroup`, use `PaneGroup::terminal_view_from_pane_id(target.locator.pane_id, ctx)`. Otherwise, use `PaneGroup::focused_session_view(ctx)`. +If no terminal view is available, omit terminal-specific items but still show the appropriate title copy item when the title exists. ### 3. Keep metadata values clean Filter all values through a small helper that trims whitespace for availability checks and stores the original non-empty string for copying. This prevents blank menu rows while preserving the copied value. ## Risks and Mitigations -**Menu noise:** Copy tab title may appear broadly because most tabs have a display title. This is intentional because title copying is useful and the value is known. -**Stale metadata:** Branch, CWD, and PR link are copied from already-known `TerminalView` state. The change does not perform new synchronous GitHub or filesystem lookups, so it preserves menu responsiveness. -**Pane targeting:** In vertical-tabs pane mode, terminal metadata should come from the clicked or active pane. The locator check avoids accidentally reading metadata from a pane in a different tab. +**Menu noise:** Horizontal tabs only expose Copy tab title, so metadata that is not visible in that layout does not add noise. +**Stale metadata:** Branch, working directory, and pull request link are copied from already-known `TerminalView` state. The change does not perform new synchronous GitHub or filesystem lookups, so it preserves menu responsiveness. +**Pane targeting:** In vertical-tabs pane mode, terminal metadata should come from the active pane. The locator check for tab mode avoids accidentally reading metadata from a pane in a different tab. ## Testing and Validation - Run `cargo fmt`. -- Run a targeted compile check for the app crate or workspace slice affected by `app/src/tab.rs`. +- Run targeted integration tests for horizontal tab, vertical-tab grouping, and vertical-pane grouping context menus. - Manual checks: - - Git terminal with branch metadata shows Copy branch. - - Terminal with a PR chip shows Copy PR link. - - Terminal without git metadata omits branch and PR items. - - Non-terminal or metadata-less contexts still omit terminal-only copy items. + - Horizontal tab context menu only shows Copy tab title. + - Vertical tab with branch metadata shows Copy branch. + - Vertical pane mode shows Copy pane title and copies active-pane metadata. + - Terminal without git metadata omits branch and pull request items. From c63e0e2ffbc90cfb587c4a3585efab489f6cdbf4 Mon Sep 17 00:00:00 2001 From: Zachary Lloyd Date: Tue, 5 May 2026 16:46:26 -0700 Subject: [PATCH 4/4] Fix vertical pane metadata copy target Use the clicked pane row as the metadata source when vertical tabs are grouped by panes, falling back to the focused pane only when there is no valid clicked-pane target. Update the integration coverage to right-click a non-focused pane row so the regression fails before the fix and passes after. Co-Authored-By: Oz --- app/src/tab.rs | 9 +++- crates/integration/src/test/workspace.rs | 60 +++++++++++++++--------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/app/src/tab.rs b/app/src/tab.rs index 8e2bce094..2edd0dfae 100644 --- a/app/src/tab.rs +++ b/app/src/tab.rs @@ -324,7 +324,14 @@ impl TabData { vertical_tabs_display_granularity, VerticalTabsDisplayGranularity::Panes ) { - let pane_id = pane_group.focused_pane_id(ctx); + let pane_id = pane_name_target + .filter(|target| self.pane_group.id() == target.locator.pane_group_id) + .and_then(|target| { + pane_group + .pane_by_id(target.locator.pane_id) + .map(|_| target.locator.pane_id) + }) + .unwrap_or_else(|| pane_group.focused_pane_id(ctx)); ( "Copy pane title", Self::copyable_pane_title(pane_group, pane_id, ctx), diff --git a/crates/integration/src/test/workspace.rs b/crates/integration/src/test/workspace.rs index 203c7bc21..e91d50cc1 100644 --- a/crates/integration/src/test/workspace.rs +++ b/crates/integration/src/test/workspace.rs @@ -1,6 +1,6 @@ //! Integration tests for workspace-level behavior. -use std::{fs, path::Path, time::Duration}; +use std::{fs, time::Duration}; use pathfinder_geometry::{ rect::RectF, @@ -54,6 +54,7 @@ const TARGET_WINDOW_KEY: &str = "target window"; const DETACHED_WINDOW_KEY: &str = "detached window"; const METADATA_TAB_TITLE: &str = "Integration Metadata Tab"; const METADATA_BRANCH: &str = "main"; +const METADATA_CLICKED_PANE_TITLE: &str = "Integration Metadata Clicked Pane"; const METADATA_PANE_TITLE: &str = "Integration Metadata Pane"; const METADATA_PANE_BRANCH: &str = "pane-branch"; const METADATA_PANE_DIRECTORY: &str = "active-pane"; @@ -77,6 +78,31 @@ fn vertical_tab_pane_row_position_id(app: &mut warpui::App, window_id: WindowId) }) } +fn vertical_tab_pane_row_position_id_for_pane_index( + app: &mut warpui::App, + window_id: WindowId, + pane_index: usize, +) -> String { + let workspace = workspace_view(app, window_id); + let pane_group = workspace.read(app, |workspace, _ctx| { + workspace + .get_pane_group_view(0) + .expect("pane group should exist") + .clone() + }); + let pane_group_id = pane_group.id(); + pane_group.read(app, |pane_group, _ctx| { + let pane_id = pane_group + .pane_id_from_index(pane_index) + .expect("pane should exist at index"); + format!("vertical_tabs:pane_row:{pane_group_id:?}:{pane_id}") + }) +} + +fn first_vertical_tab_pane_row_position_id(app: &mut warpui::App, window_id: WindowId) -> String { + vertical_tab_pane_row_position_id_for_pane_index(app, window_id, 0) +} + fn should_run_tab_context_menu_metadata_test() -> bool { let (starter, _) = current_shell_starter_and_version(); starter.shell_type() != ShellType::PowerShell @@ -144,6 +170,10 @@ fn open_vertical_tab_context_menu(step_name: &'static str) -> TestStep { new_step_with_default_assertions(step_name) .with_right_click_on_saved_position_fn(vertical_tab_pane_row_position_id) } +fn open_first_vertical_tab_pane_context_menu(step_name: &'static str) -> TestStep { + new_step_with_default_assertions(step_name) + .with_right_click_on_saved_position_fn(first_vertical_tab_pane_row_position_id) +} fn add_tab_context_metadata_setup_steps(builder: Builder) -> Builder { builder @@ -174,6 +204,7 @@ fn add_tab_context_metadata_setup_steps(builder: Builder) -> Builder { fn add_active_pane_context_metadata_setup_steps(builder: Builder) -> Builder { builder + .with_step(set_active_pane_name(METADATA_CLICKED_PANE_TITLE)) .with_step( new_step_with_default_assertions("Create active split pane") .with_keystrokes(&[cmd_or_ctrl_shift("d")]), @@ -267,7 +298,7 @@ fn add_vertical_pane_context_metadata_copy_steps( new_step_with_default_assertions("Copy branch from pane context menu") .with_click_on_saved_position("Copy branch") .add_assertion(assert_clipboard_contains_string( - METADATA_PANE_BRANCH.to_string(), + METADATA_BRANCH.to_string(), )), ) .with_step(open_tab_context_menu( @@ -277,7 +308,7 @@ fn add_vertical_pane_context_metadata_copy_steps( new_step_with_default_assertions("Copy pane title from pane context menu") .with_click_on_saved_position("Copy pane title") .add_assertion(assert_clipboard_contains_string( - METADATA_PANE_TITLE.to_string(), + METADATA_CLICKED_PANE_TITLE.to_string(), )), ) .with_step(open_tab_context_menu( @@ -286,9 +317,7 @@ fn add_vertical_pane_context_metadata_copy_steps( .with_step( new_step_with_default_assertions("Copy working directory from pane context menu") .with_click_on_saved_position("Copy working directory") - .add_assertion(assert_clipboard_contains_home_child( - METADATA_PANE_DIRECTORY, - )), + .add_assertion(assert_clipboard_contains_home()), ) } @@ -331,20 +360,6 @@ fn assert_clipboard_contains_home() -> AssertionCallback { }) } -fn assert_clipboard_contains_home_child(child: &'static str) -> AssertionCallback { - Box::new(move |app, _window_id| { - let clipboard = app.update(|ctx| ctx.clipboard().read()); - let content = match clipboard.paths { - Some(paths) => paths.join(" "), - None => clipboard.plain_text, - }; - let home = std::env::var("HOME").expect("HOME should be set for integration tests"); - let path = Path::new(&home).join(child).to_string_lossy().to_string(); - - async_assert_eq!(content, path) - }) -} - fn focus_other_window(other_window_key: &'static str, known_window_key: &'static str) -> TestStep { TestStep::new("Focus other window").with_action(move |app, _, data| { let known_window_id = *data @@ -520,7 +535,10 @@ pub fn test_vertical_pane_context_menu_copies_metadata() -> Builder { add_tab_context_metadata_setup_steps(new_builder()) .with_step(enable_vertical_tabs(VerticalTabsDisplayGranularity::Panes)), ); - add_vertical_pane_context_metadata_copy_steps(builder, open_vertical_tab_context_menu) + add_vertical_pane_context_metadata_copy_steps( + builder, + open_first_vertical_tab_pane_context_menu, + ) } pub fn test_focus_panes_on_hover() -> Builder {