From b3cca826c82b88a603bbe14f2558240b506a5c08 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Wed, 14 Oct 2026 05:10:05 +0900 Subject: [PATCH] workspace: Move tabs across windows by drag and drop Dropping a tab on another window's pane, or releasing it outside every window, previously had no defined behavior: the item entity would be re-parented into a pane whose project it does not belong to, or the drag would simply stick. Both gestures now transfer the item by round-tripping it through the workspace database: the source item is serialized, recreated in the target pane via the SerializableItem registry, and closed in its source window. Items that are dirty or not serializable snap back instead. A release outside every window opens a new window for the item, sized like the source window and placed at the cursor, clamped to the display. Workspace::new_local_with_bounds exposes the window placement for that; new_local keeps its previous behavior. --- crates/workspace/src/background_image.rs | 40 ++-- crates/workspace/src/pane.rs | 269 ++++++++++++++++++++++- crates/workspace/src/workspace.rs | 123 ++++++++++- 3 files changed, 395 insertions(+), 37 deletions(-) diff --git a/crates/workspace/src/background_image.rs b/crates/workspace/src/background_image.rs index 7821cffee2..2c7d2b4594 100644 --- a/crates/workspace/src/background_image.rs +++ b/crates/workspace/src/background_image.rs @@ -7,16 +7,16 @@ use std::{ use fs::Fs; use gpui::{ - point, px, relative, size, BackgroundExecutor, Bounds, Context, Corners, Element, - ElementId, Empty, GlobalElementId, InspectorElementId, IntoElement, LayoutId, - ParentElement, Pixels, Render, RenderImage, Style, Styled, Subscription, Task, Window, + BackgroundExecutor, Bounds, Context, Corners, Element, ElementId, Empty, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Render, RenderImage, Style, + Styled, Subscription, Task, Window, point, px, relative, size, }; -use util::ResultExt; use image::Frame; use settings::{ BackgroundImageFit, BackgroundImagePosition, RegisterSetting, Settings, SettingsStore, }; use smallvec::SmallVec; +use util::ResultExt; /// Default opacity of the editor and terminal surfaces drawn on top of /// the background image. Lower values let the image show through more. @@ -125,11 +125,9 @@ impl BackgroundImage { let path = path.clone(); let path_for_check = path.clone(); let cache_dir = cache_dir.clone(); - let load_task = executor - .clone() - .spawn(async move { - load_background_image(&fs, &path, &cache_dir, executor.clone()).await - }); + let load_task = executor.clone().spawn(async move { + load_background_image(&fs, &path, &cache_dir, executor.clone()).await + }); let image = load_task.await; this.update(cx, |this, cx| { @@ -335,7 +333,7 @@ async fn load_background_image( ); load_from_cache(fs, path, cache_dir).await } - } + }, futures::future::Either::Left((Err(error), _)) => { log::info!( "failed to load background image at {}: {error}", @@ -441,9 +439,7 @@ mod tests { cx: &mut TestAppContext, fs: Arc, ) -> gpui::Entity { - cx.update(|cx| { - cx.new(|cx| BackgroundImage::new(fs, Arc::from(Path::new(CACHE_DIR)), cx)) - }) + cx.update(|cx| cx.new(|cx| BackgroundImage::new(fs, Arc::from(Path::new(CACHE_DIR)), cx))) } /// A minimal 2x2 PNG of the given color. @@ -523,7 +519,8 @@ mod tests { settings_store(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255]))).await; + fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255]))) + .await; let background_image = new_background_image(cx, fs.clone()); cx.run_until_parked(); @@ -581,11 +578,10 @@ mod tests { settings_store(cx); let fs = FakeFs::new(cx.executor()); - let cache_path = cache_path_for( - Path::new("/remote/background.png"), - Path::new(CACHE_DIR), - ); - fs.write(&cache_path, &png_bytes(image::Rgba([255, 0, 0, 255]))).await.unwrap(); + let cache_path = cache_path_for(Path::new("/remote/background.png"), Path::new(CACHE_DIR)); + fs.write(&cache_path, &png_bytes(image::Rgba([255, 0, 0, 255]))) + .await + .unwrap(); let background_image = new_background_image(cx, fs); set_background_path(cx, Some("/remote/background.png")); @@ -618,7 +614,8 @@ mod tests { settings_store(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_file("/broken.png", b"not an image".to_vec()).await; + fs.insert_file("/broken.png", b"not an image".to_vec()) + .await; let background_image = new_background_image(cx, fs); set_background_path(cx, Some("/broken.png")); @@ -634,7 +631,8 @@ mod tests { settings_store(cx); let fs = FakeFs::new(cx.executor()); - fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255]))).await; + fs.insert_file("/background.png", png_bytes(image::Rgba([255, 0, 0, 255]))) + .await; let background_image = new_background_image(cx, fs); set_background_path(cx, Some("/background.png")); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 25771a8ee1..043b1d9453 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,7 +1,8 @@ use crate::{ CloseWindow, NewCenterTerminal, NewFile, NewTerminal, OpenInTerminal, OpenOptions, - OpenTerminal, OpenVisible, SplitDirection, ToggleFileFinder, ToggleProjectSymbols, ToggleZoom, - Workspace, WorkspaceItemBuilder, ZoomIn, ZoomOut, + OpenTerminal, OpenVisible, SerializableItemRegistry, SplitDirection, ToggleFileFinder, + ToggleProjectSymbols, ToggleZoom, Workspace, WorkspaceId, WorkspaceItemBuilder, ZoomIn, + ZoomOut, focus_follows_mouse::FocusFollowsMouse as _, invalid_item_view::InvalidItemView, item::{ @@ -14,7 +15,7 @@ use crate::{ toolbar::Toolbar, workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings}, }; -use anyhow::Result; +use anyhow::{Result, anyhow}; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; use futures::{StreamExt, stream::FuturesUnordered}; use git::{CopyFilePermalink, OpenFilePermalink}; @@ -3884,9 +3885,27 @@ impl Pane { let from_pane = dragged_tab.pane.clone(); + let dragged_item = dragged_tab.item.boxed_clone(); + self.workspace .update(cx, |_, cx| { cx.defer_in(window, move |workspace, window, cx| { + let from_workspace_id = + from_pane.read_with(cx, |pane, _| pane.workspace.entity_id()); + if from_workspace_id != cx.entity_id() { + let to_pane = to_pane.downgrade(); + if let Some(transfer) = + prepare_item_transfer(&*dragged_item, &from_pane, cx) + { + cx.spawn_in(window, async move |_, cx| { + complete_item_transfer(transfer, to_pane, ix, cx) + .await + .log_err(); + }) + .detach(); + } + return; + } if let Some(split_direction) = split_direction { to_pane = workspace.split_pane(to_pane, split_direction, window, cx); } @@ -5080,18 +5099,108 @@ impl Render for DraggedTab { } } +/// The pieces needed to recreate an item in another workspace by round +/// tripping it through the workspace database. Item entities are bound to +/// their workspace's project, so moves across windows cannot move the entity +/// itself. +pub(crate) struct ItemTransfer { + kind: Arc, + source_workspace_id: WorkspaceId, + item_id: EntityId, + source_pane: Entity, + serialize_task: Task>, +} + +/// Prepares to recreate `item` in another workspace. Returns `None` when the +/// item has unsaved changes or is not serializable, in which case the drag +/// snaps back instead of moving the entity across projects. +pub(crate) fn prepare_item_transfer( + item: &dyn ItemHandle, + source_pane: &Entity, + cx: &mut App, +) -> Option { + if item.is_dirty(cx) { + return None; + } + let serializable = + SerializableItemRegistry::view_to_serializable_item_handle(item.to_any_view(), cx)?; + let source_workspace = source_pane.read(cx).workspace.upgrade()?; + let source_workspace_id = source_workspace.read(cx).database_id()?; + let serialize_task = source_workspace.update(cx, |workspace, cx| { + serializable.serialize(workspace, false, cx) + })?; + Some(ItemTransfer { + kind: Arc::from(serializable.serialized_item_kind()), + source_workspace_id, + item_id: item.item_id(), + source_pane: source_pane.clone(), + serialize_task, + }) +} + +/// Recreates a transferred item at index `ix` of `to_pane`, then closes the +/// source item in its own window. +pub(crate) async fn complete_item_transfer( + transfer: ItemTransfer, + to_pane: WeakEntity, + ix: usize, + cx: &mut AsyncWindowContext, +) -> Result<()> { + transfer.serialize_task.await.log_err(); + let project = to_pane + .update(cx, |pane, _| pane.project.upgrade())? + .ok_or_else(|| anyhow!("target pane's project was dropped"))?; + let workspace = to_pane.update(cx, |pane, _| pane.workspace.clone())?; + let item = to_pane + .update_in(cx, |_, window, cx| { + SerializableItemRegistry::deserialize( + &transfer.kind, + project, + workspace, + transfer.source_workspace_id, + transfer.item_id.as_u64(), + window, + cx, + ) + })? + .await?; + let to_pane = to_pane + .upgrade() + .ok_or_else(|| anyhow!("target pane was dropped"))?; + to_pane.update_in(cx, |pane, window, cx| { + pane.add_item(item, true, true, None, window, cx); + })?; + let last_index = to_pane.read_with(cx, |pane, _| pane.items.len().saturating_sub(1)); + if ix < last_index { + to_pane.update_in(cx, |_, window, cx| { + move_item(&to_pane, &to_pane, transfer.item_id, ix, true, window, cx); + })?; + } + let source_workspace = transfer + .source_pane + .read_with(cx, |pane, _| pane.workspace.clone()); + source_workspace + .update_in(cx, |_, window, cx| { + transfer.source_pane.update(cx, |pane, cx| { + pane.close_item_by_id(transfer.item_id, SaveIntent::Close, window, cx) + }) + })? + .await + .log_err(); + Ok(()) +} #[cfg(test)] mod tests { - use std::{cell::Cell, iter::zip, num::NonZero, rc::Rc}; + use std::{cell::Cell, iter::zip, num::NonZero, rc::Rc, sync::Arc}; use super::*; use crate::{ - Member, + Member, MultiWorkspace, item::test::{TestItem, TestProjectItem}, }; use gpui::{ - AppContext, Axis, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - TestAppContext, VisualTestContext, size, + AnyDrag, AppContext, Axis, Empty, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, TestAppContext, VisualTestContext, point, px, size, }; use project::FakeFs; use settings::SettingsStore; @@ -9664,4 +9773,150 @@ mod tests { } } } + + #[gpui::test] + async fn test_tab_drop_across_workspaces_transfers_item(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), None, cx).await; + let project_b = Project::test(fs.clone(), None, cx).await; + cx.update(crate::register_serializable_item::); + + let (workspace_a, cx_a) = + cx.add_window_view(|window, cx| Workspace::test_new(project_a.clone(), window, cx)); + workspace_a.update(cx_a, |workspace, _| { + workspace.database_id = Some(WorkspaceId(1)) + }); + let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); + let item = cx_a.new(|cx| TestItem::new(cx).with_serialize(|| Some(Task::ready(Ok(()))))); + pane_a.update_in(cx_a, |pane, window, cx| { + pane.add_item(Box::new(item.clone()), true, false, None, window, cx) + }); + let source_workspace_id = + workspace_a.read_with(cx_a, |workspace, _| workspace.database_id()); + + let window_b = + cx_a.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx)); + let cx_b = &mut VisualTestContext::from_window(window_b.into(), cx_a); + cx_b.run_until_parked(); + let pane_b = window_b + .update(cx_b, |workspace, _, _| workspace.active_pane().clone()) + .unwrap(); + + let dragged_tab = DraggedTab { + pane: pane_a.clone(), + item: Box::new(item.clone()), + ix: 0, + detail: 0, + is_active: true, + }; + pane_b.update_in(cx_b, |pane, window, cx| { + pane.handle_tab_drop(&dragged_tab, 0, false, window, cx) + }); + cx_a.run_until_parked(); + + assert_eq!(pane_a.read_with(cx_a, |pane, _| pane.items_len()), 0); + let transferred = pane_b + .read_with(cx_b, |pane, _| pane.items().next().cloned()) + .expect("item moved to the other workspace") + .downcast::() + .expect("transferred item is a test item"); + assert_eq!( + transferred.read_with(cx_b, |item, _| item.workspace_id), + source_workspace_id + ); + } + + #[allow(clippy::arc_with_non_send_sync)] + #[gpui::test] + async fn test_tab_dropped_outside_opens_new_window(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), None, cx).await; + cx.update(crate::register_serializable_item::); + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + workspace.update(cx, |workspace, _| { + workspace.database_id = Some(WorkspaceId(1)) + }); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let item = cx.new(|cx| TestItem::new(cx).with_serialize(|| Some(Task::ready(Ok(()))))); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(item.clone()), true, false, None, window, cx) + }); + let source_workspace_id = workspace.read_with(cx, |workspace, _| workspace.database_id()); + + let dragged_tab = DraggedTab { + pane: pane.clone(), + item: Box::new(item.clone()), + ix: 0, + detail: 0, + is_active: true, + }; + let drag = AnyDrag { + value: Arc::new(dragged_tab), + view: cx.new(|_| Empty).into(), + cursor_offset: point(px(0.), px(0.)), + cursor_style: None, + external_payload_source: None, + }; + cx.update(|_, cx| crate::handle_tab_dropped_outside(&drag, point(px(400.), px(300.)), cx)); + cx.run_until_parked(); + + let mut transferred_workspace_id = None; + for window in cx.windows() { + let Some(handle) = window.downcast::() else { + continue; + }; + let workspace = handle + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + }) + .unwrap(); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + if let Some(item) = pane.read_with(cx, |pane, _| pane.items().next().cloned()) { + transferred_workspace_id = item + .downcast::() + .map(|item| item.read_with(cx, |item, _| item.workspace_id)); + } + } + assert_eq!(transferred_workspace_id, Some(source_workspace_id)); + assert_eq!(pane.read_with(cx, |pane, _| pane.items_len()), 0); + } + + #[allow(clippy::arc_with_non_send_sync)] + #[gpui::test] + async fn test_dirty_tab_dropped_outside_keeps_item(cx: &mut TestAppContext) { + init_test(cx); + cx.update(crate::register_serializable_item::); + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs.clone(), None, cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + let item = cx.new(|cx| TestItem::new(cx).with_dirty(true)); + pane.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(item.clone()), true, false, None, window, cx) + }); + + let dragged_tab = DraggedTab { + pane: pane.clone(), + item: Box::new(item.clone()), + ix: 0, + detail: 0, + is_active: true, + }; + let drag = AnyDrag { + value: Arc::new(dragged_tab), + view: cx.new(|_| Empty).into(), + cursor_offset: point(px(0.), px(0.)), + cursor_style: None, + external_payload_source: None, + }; + cx.update(|_, cx| crate::handle_tab_dropped_outside(&drag, point(px(400.), px(300.)), cx)); + cx.run_until_parked(); + + assert_eq!(cx.windows().len(), 1); + assert_eq!(pane.read_with(cx, |pane, _| pane.items_len()), 1); + } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 111768aaef..a015e65eb3 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -62,13 +62,13 @@ use futures::{ future::{Shared, try_join_all}, }; use gpui::{ - Action, AnyEntity, AnyView, AnyWeakView, App, AppContext, AsyncApp, AsyncWindowContext, Axis, - Bounds, ClipboardItem, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, - EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, - ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, - Stateful, Subscription, SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity, - WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas, point, relative, size, - transparent_black, + Action, AnyDrag, AnyEntity, AnyView, AnyWeakView, App, AppContext, AsyncApp, + AsyncWindowContext, Axis, Bounds, ClipboardItem, Context, CursorStyle, Decorations, + DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, + Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, Pixels, Point, + PromptLevel, Render, ResizeEdge, Size, Stateful, Subscription, SystemWindowTabController, Task, + TaskExt, Tiling, WeakEntity, WindowBounds, WindowHandle, WindowId, WindowOptions, actions, + canvas, point, px, relative, size, transparent_black, }; pub use history_manager::*; pub use item::{ @@ -166,8 +166,8 @@ pub use workspace_settings::{ }; use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode}; -use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId}; use crate::background_image::BackgroundImage; +use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId}; use crate::{ persistence::{ SerializedAxis, @@ -999,6 +999,10 @@ pub fn init(app_state: Arc, cx: &mut App) { cx.on_app_quit(flush_windows_serialization_on_quit).detach(); + cx.set_drag_dropped_outside_handler(|drag, position, cx| { + handle_tab_dropped_outside(drag, position, cx); + }); + cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) .on_action(|_: &Reload, cx| reload(cx)) .on_action(|action: &Open, cx: &mut App| { @@ -2178,6 +2182,30 @@ impl Workspace { init: Option) + Send>>, open_mode: OpenMode, cx: &mut App, + ) -> Task> { + Self::new_local_with_bounds( + abs_paths, + app_state, + requesting_window, + env, + init, + open_mode, + None, + cx, + ) + } + + /// Same as [`Workspace::new_local`], but places a newly created window at + /// `window_bounds_override` instead of the persisted or cascaded defaults. + pub fn new_local_with_bounds( + abs_paths: Vec, + app_state: Arc, + requesting_window: Option>, + env: Option>, + init: Option) + Send>>, + open_mode: OpenMode, + window_bounds_override: Option>, + cx: &mut App, ) -> Task> { let project_handle = Project::local( app_state.client.clone(), @@ -2315,7 +2343,8 @@ impl Workspace { })?; (window, workspace) } else { - let window_bounds_override = window_bounds_env_override(); + let window_bounds_override = + window_bounds_override.or_else(window_bounds_env_override); let (window_bounds, display) = if let Some(bounds) = window_bounds_override { (Some(WindowBounds::Windowed(bounds)), None) @@ -12290,6 +12319,82 @@ pub fn clone_active_item( .detach(); } +/// Opens a new window holding the dragged tab's item, for drags released +/// outside of every application window. +fn handle_tab_dropped_outside(drag: &AnyDrag, position: Point, cx: &mut App) { + let Some(dragged_tab) = drag.value.downcast_ref::() else { + return; + }; + let source_pane = dragged_tab.pane.clone(); + let Some(transfer) = prepare_item_transfer(&*dragged_tab.item, &source_pane, cx) else { + return; + }; + let Some(source_workspace) = source_pane.read(cx).workspace.upgrade() else { + return; + }; + let paths = source_workspace + .read(cx) + .root_paths(cx) + .into_iter() + .map(|path| path.to_path_buf()) + .collect(); + let app_state = source_workspace.read(cx).app_state.clone(); + let window_bounds = tear_off_window_bounds(position, cx); + let open_task = Workspace::new_local_with_bounds( + paths, + app_state, + None, + None, + None, + OpenMode::NewWindow, + Some(window_bounds), + cx, + ); + cx.spawn(async move |cx| { + let opened = open_task.await?; + let to_pane = opened + .workspace + .update(cx, |workspace, _| workspace.active_pane().downgrade()); + opened.window.update(cx, |_, window, cx| { + window + .spawn(cx, async move |cx| { + complete_item_transfer(transfer, to_pane, 0, cx) + .await + .log_err(); + }) + .detach(); + })?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); +} + +/// Bounds for a window torn off at `position`: the active window's size with +/// its origin at the cursor, clamped inside the display's visible area. +fn tear_off_window_bounds(position: Point, cx: &mut App) -> Bounds { + let size = cx + .active_window() + .and_then(|window| { + window + .update(cx, |_, window, _| window.window_bounds().get_bounds().size) + .ok() + }) + .unwrap_or_else(|| size(px(1024.), px(768.))); + let Some(display) = cx.primary_display() else { + return Bounds::new(position, size); + }; + let visible = display.visible_bounds(); + let max_origin = point( + (visible.origin.x + visible.size.width - size.width).max(visible.origin.x), + (visible.origin.y + visible.size.height - size.height).max(visible.origin.y), + ); + let origin = point( + position.x.clamp(visible.origin.x, max_origin.x), + position.y.clamp(visible.origin.y, max_origin.y), + ); + Bounds::new(origin, size) +} + #[derive(Debug)] pub struct WorkspacePosition { pub window_bounds: Option,