Skip to content

Commit 3aa627f

Browse files
peicodesoz-agent
andcommitted
add support for cross-window tab drag
Removes WorkspaceAction::HandoffPendingTransfer, ReverseHandoff, and FinalizeDropTab. The cross-window drag flow no longer routes through WorkspaceAction; it is now coordinated through the upcoming CrossWindowTabDrag singleton model. hide window when dragging to target fix unstable drop zone render exact copy of tab rendering consistency checkin ghost state checkpoint Fix crashes Update view.rs fix edge case issues around persistence and detachment fix typo integration: add tests for cross-window tab drag Adds four end-to-end integration tests behind the new drag_tabs_to_windows feature on the integration crate: - test_reorder_tabs_with_drag - test_detach_tab_to_new_window_with_drag - test_attach_tab_to_other_window_and_continue_drag - test_single_tab_handoff_continues_drag Wires them into both the manual integration runner and the nextest ui_tests! suite, and adds the matching feature passthrough in crates/integration/Cargo.toml. app_state: skip persistence during cross-window tab drag While a cross-window tab drag is active, the dragged tab's pane group is in flight between the source and preview windows. Both can briefly claim the same terminal_panes.uuid, which trips SQLite's UNIQUE constraint when persistence runs mid-drag. Skip persistence entirely while CrossWindowTabDrag is active; the next mouse-up or non-drag change will trigger a save. Also switches the existing per-window workspace lookup to WorkspaceRegistry, mirroring root_view, and uses the renamed is_tab_drag_preview() helper. root_view: simplify after cross-window tab drag refactor Removes the bespoke DetachTabImmediateArg / TabTransferInfo plumbing and the root_view:detach_tab_immediate global action, both of which existed only to support the old cross-window drag flow. Their responsibilities now live in CrossWindowTabDrag and the workspace view. Updates create_transferred_window to take the TransferredTab and window placement directly and return just the new WindowId, and switches workspace_for_window to look up workspaces through WorkspaceRegistry instead of scanning views_of_type::<Workspace>. workspace view: integrate cross-window tab drag Wires the workspace view into the new CrossWindowTabDrag singleton: - Drives the drag state machine from on_drag/on_drop on tabs and the tab bar. - Exposes helpers (tab_bar_rects_for_window, TransferredTab, TAB_BAR_POSITION_ID) that the singleton uses to coordinate hit testing and view-tree transfers between windows. - Renames is_drag_preview_workspace to is_tab_drag_preview to match the new state model. - Adjusts vertical-tab drag behavior so that when DragTabsToWindows is enabled, vertical tabs can be dragged horizontally out of the panel to detach into a new window. When the flag is off the existing vertical-only constraint is preserved. workspace: add CrossWindowTabDrag singleton model Adds a new singleton model that owns all cross-window tab drag state across the application. The model tracks the drag lifecycle through three phases — Floating, InsertedInTarget, and Transitioning — and exposes on_drag/on_drop entry points that workspace views call to drive the state machine. Two drag sources are supported: - SingleTabWindow: the source window itself acts as the floating preview. - MultiTabWindow: a dedicated preview window is created for the tab. Registers the singleton in workspace::init() so it is available app-wide. The workspace view integration that actually exercises the new APIs is added in a follow-up commit. warpui: track window front-to-back ordering and add window bounds helper Adds a WindowOrderingState to WindowManager so callers can find which window is topmost at a given screen position, which is needed when deciding where a dragged tab should land. Adds a matching ordered_window_ids() API for both the production and integration-test window managers. Also adds AppContext::set_and_cache_window_bounds for callers that need to move a window and update the cache atomically, and tweaks the macOS window close path so force-terminated windows close immediately while normal closes still go through performClose:. Add tab dragging product and tech specs Includes the original drag-tabs-to-windows PRODUCT.md and TECH.md, plus follow-up TECH specs for fix-drag-drop, fix-dragging-out, pane-uuid-collision-on-handoff, and put-back-plus-new-window-overlap. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 4e80762 commit 3aa627f

26 files changed

Lines changed: 5605 additions & 432 deletions

File tree

app/src/app_state.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ use crate::tab::SelectedTabColor;
2121
use crate::terminal::ShellLaunchData;
2222
use crate::themes::theme::AnsiColorIdentifier;
2323
use crate::workspace::view::left_panel::ToolPanelView;
24-
use crate::workspace::Workspace;
24+
use crate::workspace::WorkspaceRegistry;
25+
use warpui::SingletonEntity as _;
2526

2627
#[derive(Debug, Clone, PartialEq)]
2728
pub struct AppState {
@@ -353,13 +354,14 @@ pub fn get_app_state(app: &AppContext) -> AppState {
353354
}
354355
}
355356

356-
if let Some(first_workspace) = app
357-
.views_of_type::<Workspace>(window_id)
358-
.as_ref()
359-
.and_then(|workspaces| workspaces.first())
360-
{
361-
let ws = first_workspace.as_ref(app);
362-
if ws.is_drag_preview_workspace() {
357+
if let Some(workspace) = WorkspaceRegistry::as_ref(app).get(window_id, app) {
358+
let ws = workspace.as_ref(app);
359+
// Transient drag-preview windows are not real user-visible
360+
// workspaces; skip them so they never end up in the persisted
361+
// session. (Persistence is also short-circuited entirely while a
362+
// cross-window drag is active; see `save_app` in
363+
// `workspace/global_actions.rs`.)
364+
if ws.is_tab_drag_preview() {
363365
continue;
364366
}
365367
let snapshot = ws.snapshot(

app/src/root_view.rs

Lines changed: 28 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ use crate::{
7474
auth::auth_override_warning_modal::{AuthOverrideWarningModal, AuthOverrideWarningModalEvent},
7575
auth::auth_view_modal::{AuthView, AuthViewVariant},
7676
server::server_api::ServerApi,
77-
workspace::{view::OnboardingTutorial, PaneViewLocator, Workspace},
77+
workspace::{view::OnboardingTutorial, PaneViewLocator, Workspace, WorkspaceRegistry},
7878
};
7979
use crate::{features::FeatureFlag, ChannelState};
8080
use crate::{send_telemetry_from_app_ctx, GlobalResourceHandles, GlobalResourceHandlesProvider};
@@ -112,8 +112,7 @@ use warpui::{id, AddWindowOptions, DisplayId, SingletonEntity};
112112
use warpui::{
113113
platform::{WindowBounds, WindowStyle},
114114
presenter::ChildView,
115-
AppContext, Element, Entity, EntityId, TypedActionView, View, ViewContext, ViewHandle,
116-
WindowId,
115+
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle, WindowId,
117116
};
118117
use warpui::{FocusContext, NextNewWindowsHasThisWindowsBoundsUponClose};
119118

@@ -241,29 +240,6 @@ pub struct CreateEnvironmentArg {
241240
pub repos: Vec<String>,
242241
}
243242

244-
/// Arguments for the immediate tab detach action dispatched during drag.
245-
/// This contains minimal info needed to identify which tab to detach.
246-
pub struct DetachTabImmediateArg {
247-
/// Index of the tab to detach
248-
pub tab_index: usize,
249-
/// Pre-calculated window position for the new window (in screen coordinates).
250-
/// This is calculated to position the window so the mouse is in the tab bar region.
251-
pub window_position: Option<Vector2F>,
252-
/// Source window ID - the window containing the tab to detach.
253-
/// We need this because the active window might be the preview window.
254-
pub source_window_id: WindowId,
255-
}
256-
257-
/// Pre-gathered information for creating a transferred window.
258-
/// This is used when the caller already has access to the workspace (e.g., from within a view method)
259-
/// and cannot rely on workspace lookup (which fails during view updates).
260-
pub struct TabTransferInfo {
261-
pub transferred_tab: crate::workspace::view::TransferredTab,
262-
pub window_size: Vector2F,
263-
pub window_position: Vector2F,
264-
pub source_window_id: WindowId,
265-
}
266-
267243
impl CreateEnvironmentArg {
268244
/// Formats the `/create-environment` slash command invocation.
269245
pub fn to_query(&self) -> String {
@@ -310,9 +286,6 @@ pub fn init(app: &mut AppContext) {
310286
);
311287
app.add_global_action("root_view:open_launch_config", open_launch_config);
312288
app.add_global_action("root_view:send_feedback", send_feedback);
313-
app.add_global_action("root_view:detach_tab_immediate", |arg, ctx| {
314-
let _ = detach_tab_with_transfer(arg, ctx);
315-
});
316289
app.add_global_action(
317290
"root_view:toggle_quake_mode_window",
318291
toggle_quake_mode_window,
@@ -540,17 +513,7 @@ fn maybe_register_global_window_shortcuts(
540513
/// Find the root [`Workspace`] view for the active window.
541514
fn active_workspace(ctx: &mut AppContext) -> Option<ViewHandle<Workspace>> {
542515
let window_id = ctx.windows().active_window()?;
543-
ctx.views_of_type::<Workspace>(window_id)
544-
.and_then(|views| views.first().cloned())
545-
}
546-
547-
/// Find the root [`Workspace`] view for a specific window.
548-
pub fn workspace_for_window(
549-
window_id: WindowId,
550-
ctx: &mut AppContext,
551-
) -> Option<ViewHandle<Workspace>> {
552-
ctx.views_of_type::<Workspace>(window_id)
553-
.and_then(|views| views.first().cloned())
516+
WorkspaceRegistry::as_ref(ctx).get(window_id, ctx)
554517
}
555518

556519
fn open_launch_config(arg: &OpenLaunchConfigArg, ctx: &mut AppContext) {
@@ -621,73 +584,29 @@ fn send_feedback(_: &(), ctx: &mut AppContext) {
621584
}
622585
}
623586

624-
/// Handler for tab detachment using the transferable views framework.
625-
/// Instead of extracting and recreating views, this transfers the PaneGroup view tree directly.
626-
/// Returns the new window ID if successful.
627-
pub fn detach_tab_with_transfer(
628-
arg: &DetachTabImmediateArg,
629-
ctx: &mut AppContext,
630-
) -> Option<WindowId> {
631-
let Some(source_workspace) = workspace_for_window(arg.source_window_id, ctx) else {
632-
log::warn!(
633-
"No workspace found for source window {:?}",
634-
arg.source_window_id
635-
);
636-
return None;
637-
};
638-
639-
let transferred_tab = source_workspace.read(ctx, |workspace, ctx| {
640-
workspace.get_tab_transfer_info(arg.tab_index, ctx)
641-
})?;
642-
643-
let window_size = ctx
644-
.windows()
645-
.platform_window(arg.source_window_id)
646-
.map(|window| window.as_ctx().size())
647-
.unwrap_or(*FALLBACK_WINDOW_SIZE);
648-
649-
let window_position = arg.window_position.unwrap_or_default();
650-
651-
let info = TabTransferInfo {
652-
transferred_tab,
653-
window_size,
654-
window_position,
655-
source_window_id: arg.source_window_id,
656-
};
657-
658-
let (new_window_id, _transferred_view_ids) = create_transferred_window(info, false, ctx);
659-
660-
source_workspace.update(ctx, |workspace, ctx| {
661-
workspace.remove_tab_without_undo(arg.tab_index, ctx);
662-
});
663-
664-
Some(new_window_id)
665-
}
666-
667587
/// Creates a new window with the transferred pane group.
668-
/// This function takes pre-gathered TabTransferInfo, allowing it to be called
669-
/// from within a view method where workspace lookup would fail.
670588
///
671-
/// If `for_drag` is true, the window is created without stealing focus (for drag preview).
589+
/// If `is_tab_drag_preview` is true, the window is created without stealing
590+
/// focus so it can follow the cursor during a tab drag.
672591
///
673-
/// Returns the new window ID and the list of transferred view entity IDs.
674-
/// The transferred view IDs are needed by `tab_drag::on_tab_drag` to track which
675-
/// views must follow the tab during subsequent handoff/reverse-handoff cycles.
592+
/// Returns the new window ID.
676593
pub fn create_transferred_window(
677-
info: TabTransferInfo,
678-
for_drag: bool,
594+
transferred_tab: crate::workspace::view::TransferredTab,
595+
source_window_id: WindowId,
596+
window_size: Vector2F,
597+
window_position: Vector2F,
598+
is_tab_drag_preview: bool,
679599
ctx: &mut AppContext,
680-
) -> (WindowId, Vec<EntityId>) {
600+
) -> WindowId {
681601
let global_resource_handles = GlobalResourceHandlesProvider::handle(ctx)
682602
.as_ref(ctx)
683603
.get()
684604
.clone();
685605
let window_settings = WindowSettings::handle(ctx).as_ref(ctx);
686606

687-
let window_bounds =
688-
WindowBounds::ExactPosition(RectF::new(info.window_position, info.window_size));
607+
let window_bounds = WindowBounds::ExactPosition(RectF::new(window_position, window_size));
689608

690-
let window_style = if for_drag {
609+
let window_style = if is_tab_drag_preview {
691610
WindowStyle::PositionedNoFocus
692611
} else {
693612
WindowStyle::Normal
@@ -707,35 +626,34 @@ pub fn create_transferred_window(
707626
let mut view = RootView::new(
708627
global_resource_handles.clone(),
709628
NewWorkspaceSource::TransferredTab {
710-
tab_color: info.transferred_tab.color,
711-
custom_title: info.transferred_tab.custom_title.clone(),
712-
left_panel_open: info.transferred_tab.left_panel_open,
713-
vertical_tabs_panel_open: info.transferred_tab.vertical_tabs_panel_open,
714-
right_panel_open: info.transferred_tab.right_panel_open,
715-
is_right_panel_maximized: info.transferred_tab.is_right_panel_maximized,
716-
for_drag_preview: for_drag,
629+
tab_color: transferred_tab.color,
630+
custom_title: transferred_tab.custom_title.clone(),
631+
left_panel_open: transferred_tab.left_panel_open,
632+
vertical_tabs_panel_open: transferred_tab.vertical_tabs_panel_open,
633+
right_panel_open: transferred_tab.right_panel_open,
634+
is_right_panel_maximized: transferred_tab.is_right_panel_maximized,
635+
is_tab_drag_preview,
717636
},
718637
ctx,
719638
);
720-
if !for_drag {
639+
if !is_tab_drag_preview {
721640
view.focus(ctx);
722641
}
723642
view
724643
},
725644
);
726645

727-
let pane_group_id = info.transferred_tab.pane_group.id();
728-
let transferred_view_ids =
729-
ctx.transfer_view_tree_to_window(pane_group_id, info.source_window_id, new_window_id);
646+
let pane_group_id = transferred_tab.pane_group.id();
647+
ctx.transfer_view_tree_to_window(pane_group_id, source_window_id, new_window_id);
730648

731-
if let Some(new_workspace) = workspace_for_window(new_window_id, ctx) {
649+
if let Some(new_workspace) = WorkspaceRegistry::as_ref(ctx).get(new_window_id, ctx) {
732650
new_workspace.update(ctx, |workspace, ctx| {
733-
workspace.adopt_transferred_pane_group(info.transferred_tab.pane_group.clone(), ctx);
651+
workspace.adopt_transferred_pane_group(transferred_tab.pane_group.clone(), ctx);
734652
});
735653
} else {
736654
log::warn!("Failed to find workspace in newly created window {new_window_id:?}");
737655
}
738-
(new_window_id, transferred_view_ids)
656+
new_window_id
739657
}
740658

741659
#[cfg(feature = "crash_reporting")]
@@ -1606,7 +1524,7 @@ pub enum NewWorkspaceSource {
16061524
/// Whether the right panel was maximized in the source tab
16071525
is_right_panel_maximized: bool,
16081526
/// Whether this transferred tab window is currently being used as a drag preview.
1609-
for_drag_preview: bool,
1527+
is_tab_drag_preview: bool,
16101528
},
16111529
}
16121530

app/src/tab.rs

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,16 @@ pub struct TabComponent<'a> {
601601
tooltip_git_branch: Option<String>,
602602
is_drag_target: bool,
603603
background_opacity: u8,
604+
/// Set to `true` when this `TabComponent` is being rendered inside the
605+
/// floating chip overlay used during a cross-window tab drag. In that
606+
/// mode `build()` skips the outer `SavePosition`, `Draggable`, and
607+
/// `DropTarget` wrappers so the chip:
608+
/// * does not write to `tab_position_id(tab_index)` in the target
609+
/// window's position cache (which would corrupt
610+
/// `tab_insertion_index_for_cursor` and cause the empty-slot
611+
/// flicker), and
612+
/// * does not act as its own draggable / drop target.
613+
for_drag_ghost: bool,
604614
}
605615

606616
/// Structure that holds TabComponent styles.
@@ -755,9 +765,17 @@ impl<'a> TabComponent<'a> {
755765
tooltip_git_branch,
756766
is_drag_target,
757767
background_opacity,
768+
for_drag_ghost: false,
758769
}
759770
}
760771

772+
/// Marks this tab as being rendered inside the floating chip used by the
773+
/// cross-window tab drag overlay. See [`TabComponent::for_drag_ghost`].
774+
pub fn for_drag_ghost(mut self) -> Self {
775+
self.for_drag_ghost = true;
776+
self
777+
}
778+
761779
/// Returns the agent indicator for the focused session's active conversation,
762780
/// or `None` if there is no non-empty, non-passive conversation to display.
763781
/// When a shell command is long-running the status is overridden to
@@ -1442,6 +1460,13 @@ impl<'a> TabComponent<'a> {
14421460
.into_solid_bias_top_color(),
14431461
)
14441462
.finish()
1463+
} else if self.for_drag_ghost {
1464+
// The chip overlay is a purely visual snapshot of the source tab.
1465+
// It must not act as a drop target — both because that's not
1466+
// semantically meaningful for an element that follows the cursor,
1467+
// and because the inner `DropTarget`'s position is unrelated to
1468+
// any real tab in the target window.
1469+
tab.finish()
14451470
} else {
14461471
DropTarget::new(
14471472
tab.finish(),
@@ -1472,6 +1497,8 @@ impl UiComponent for TabComponent<'_> {
14721497
let is_any_tab_dragging = self.tab_bar.is_any_tab_dragging;
14731498
let draggable_state = self.tab.draggable_state.clone();
14741499
let mouse_close_state = self.tab.close_mouse_state.clone();
1500+
// Capture before `self` is moved into the Hoverable closure below.
1501+
let for_drag_ghost = self.for_drag_ghost;
14751502

14761503
// Extract values before moving self into closure
14771504
let tooltip_text = self.tooltip_message.clone();
@@ -1656,22 +1683,32 @@ impl UiComponent for TabComponent<'_> {
16561683
.finish()
16571684
};
16581685

1659-
let draggable = Draggable::new(draggable_state, constrained_tab)
1660-
.on_drag_start(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::StartTabDrag))
1661-
.on_drag(move |ctx, _, rect, _| {
1662-
ctx.dispatch_typed_action(WorkspaceAction::DragTab {
1663-
tab_index,
1664-
tab_position: rect,
1665-
});
1666-
})
1667-
.on_drop(|ctx, _, _, _| ctx.dispatch_typed_action(WorkspaceAction::DropTab));
1668-
let draggable = if FeatureFlag::DragTabsToWindows.is_enabled() {
1669-
draggable
1686+
// Skip the `Draggable` and `SavePosition` wrappers when rendering
1687+
// the tab inside the cross-window drag chip overlay. Wrapping the
1688+
// chip in another `Draggable` would interfere with the in-flight
1689+
// drag, and writing a `SavePosition` keyed by `tab_position_id(0)`
1690+
// would clobber the target window's real tab 0 entry in the
1691+
// position cache, breaking `tab_insertion_index_for_cursor`.
1692+
let full_tab: Box<dyn Element> = if for_drag_ghost {
1693+
constrained_tab
16701694
} else {
1671-
draggable.with_drag_axis(DragAxis::HorizontalOnly)
1695+
let draggable = Draggable::new(draggable_state, constrained_tab)
1696+
.on_drag_start(|ctx, _, _| ctx.dispatch_typed_action(WorkspaceAction::StartTabDrag))
1697+
.on_drag(move |ctx, _, rect, _| {
1698+
ctx.dispatch_typed_action(WorkspaceAction::DragTab {
1699+
tab_index,
1700+
tab_position: rect,
1701+
});
1702+
})
1703+
.on_drop(|ctx, _, _, _| ctx.dispatch_typed_action(WorkspaceAction::DropTab));
1704+
let draggable = if FeatureFlag::DragTabsToWindows.is_enabled() {
1705+
draggable
1706+
} else {
1707+
draggable.with_drag_axis(DragAxis::HorizontalOnly)
1708+
};
1709+
let tab_with_drag: Box<dyn Element> = draggable.finish();
1710+
SavePosition::new(tab_with_drag, &tab_position_id(tab_index)).finish()
16721711
};
1673-
let tab_with_drag: Box<dyn Element> = draggable.finish();
1674-
let full_tab = SavePosition::new(tab_with_drag, &tab_position_id(tab_index)).finish();
16751712

16761713
if FeatureFlag::NewTabStyling.is_enabled() {
16771714
Shrinkable::new(1.0, full_tab)

app/src/workspace/action.rs

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -240,16 +240,7 @@ pub enum WorkspaceAction {
240240
tab_index: usize,
241241
tab_position: RectF,
242242
},
243-
HandoffPendingTransfer {
244-
target_window_id: WindowId,
245-
insertion_index: usize,
246-
},
247-
ReverseHandoff {
248-
target_window_id: WindowId,
249-
target_insertion_index: usize,
250-
},
251243
DropTab,
252-
FinalizeDropTab,
253244
/// Toggles the left panel. In Code Mode V1 this toggles Warp Drive.
254245
/// In Code Mode V2 this toggles the left panel which contains both the project explorer and
255246
/// Warp Drive. This happens as explicit action from the user.
@@ -820,10 +811,7 @@ impl WorkspaceAction {
820811
| CreateTeamAIPrompt
821812
| OpenInExplorer { .. }
822813
| DragTab { .. }
823-
| HandoffPendingTransfer { .. }
824-
| ReverseHandoff { .. }
825814
| StartTabDrag
826-
| FinalizeDropTab
827815
| ToggleLeftPanel
828816
| ToggleWarpDrive
829817
| OpenWarpDrive

0 commit comments

Comments
 (0)