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.
This commit is contained in:
yenru0 committed 2026-10-14 05:10:05 +09:00
1 parent a7b8190c24
commit b3cca826c8
3 files changed
+395 -37

No files matched your search

+19 -21
View File
@@ -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<FakeFs>,
) -> gpui::Entity<BackgroundImage> {
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"));
+262 -7
View File
@@ -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<str>,
source_workspace_id: WorkspaceId,
item_id: EntityId,
source_pane: Entity<Pane>,
serialize_task: Task<Result<()>>,
}
/// 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<Pane>,
cx: &mut App,
) -> Option<ItemTransfer> {
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<Pane>,
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::<TestItem>);
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::<TestItem>()
.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::<TestItem>);
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::<MultiWorkspace>() 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::<TestItem>()
.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::<TestItem>);
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);
}
}
+114 -9
View File
@@ -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<AppState>, 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<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
open_mode: OpenMode,
cx: &mut App,
) -> Task<anyhow::Result<OpenResult>> {
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<PathBuf>,
app_state: Arc<AppState>,
requesting_window: Option<WindowHandle<MultiWorkspace>>,
env: Option<HashMap<String, String>>,
init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
open_mode: OpenMode,
window_bounds_override: Option<Bounds<Pixels>>,
cx: &mut App,
) -> Task<anyhow::Result<OpenResult>> {
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<Pixels>, cx: &mut App) {
let Some(dragged_tab) = drag.value.downcast_ref::<DraggedTab>() 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<Pixels>, cx: &mut App) -> Bounds<Pixels> {
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<WindowBounds>,