gpui: Restore file drags when re-entering the source window on macOS (#61940)

Follow-up to #58161, which handed file drags to AppKit once the pointer
left the window but left the handoff one-way. Re-entering the source
window and dropping was not possible.

GPUI now suspends the original drag when handing it to the platform and
restores it when the drag re-enters the source window, so drop targets
receive the original typed payload instead of the synthetic
external-paths payload.

No release notes this hasn't shipped yet.

Release Notes:

- N/A
This commit is contained in:
Smit Barmase authored and GitHub committed 2026-07-30 16:11:24 +00:00
1 parent 4c1f737b8c
commit a8b25c3131
4 files changed
+287 -28

No files matched your search

+78
View File
@@ -674,6 +674,18 @@ impl GpuiMode {
}
}
struct PlatformOwnedDrag {
source_window: WindowId,
state: PlatformOwnedDragState,
}
enum PlatformOwnedDragState {
Suspended(AnyDrag),
// A source-window drop consumes `active_drag` before AppKit ends the dragging session, so this
// marker can outlive the active drag and is cleaned up by `FileDropEvent::Ended`.
RestoredInSourceWindow,
}
/// Contains the state of the full application, and passed as a reference to a variety of callbacks.
/// Other [Context] derefs to this type.
/// You need a reference to an `App` to access the state of a [Entity].
@@ -684,6 +696,7 @@ pub struct App {
pub(crate) actions: Rc<ActionRegistry>,
pub(crate) active_drag: Option<AnyDrag>,
platform_owned_drag: Option<PlatformOwnedDrag>,
pub(crate) background_executor: BackgroundExecutor,
pub(crate) foreground_executor: ForegroundExecutor,
pub(crate) entities: EntityMap,
@@ -798,6 +811,7 @@ impl App {
flushing_effects: false,
pending_updates: 0,
active_drag: None,
platform_owned_drag: None,
background_executor,
foreground_executor,
svg_renderer: SvgRenderer::new(asset_source.clone()),
@@ -1799,6 +1813,7 @@ impl App {
cx.window_update_stack.pop();
if window.removed {
cx.end_platform_drag(id);
cx.window_handles.remove(&id);
cx.windows.remove(id);
if let Some(tracked) = cx.tracked_entities.remove(&id) {
@@ -2440,6 +2455,12 @@ impl App {
pub fn stop_active_drag(&mut self, window: &mut Window) -> bool {
if self.active_drag.is_some() {
self.active_drag = None;
if self.platform_owned_drag.as_ref().is_some_and(|drag| {
drag.source_window == window.window_handle().window_id()
&& matches!(&drag.state, PlatformOwnedDragState::RestoredInSourceWindow)
}) {
self.platform_owned_drag = None;
}
window.refresh();
true
} else {
@@ -2447,6 +2468,63 @@ impl App {
}
}
pub(crate) fn hand_active_drag_to_platform(&mut self, source_window: WindowId) -> bool {
let Some(drag) = self.active_drag.take() else {
return false;
};
self.platform_owned_drag = Some(PlatformOwnedDrag {
source_window,
state: PlatformOwnedDragState::Suspended(drag),
});
true
}
pub(crate) fn restore_platform_drag(&mut self, source_window: WindowId) -> bool {
let Some(platform_drag) = self
.platform_owned_drag
.as_mut()
.filter(|drag| drag.source_window == source_window)
else {
return false;
};
let state = std::mem::replace(
&mut platform_drag.state,
PlatformOwnedDragState::RestoredInSourceWindow,
);
let PlatformOwnedDragState::Suspended(drag) = state else {
return false;
};
self.active_drag = Some(drag);
true
}
pub(crate) fn hand_restored_drag_to_platform(&mut self, source_window: WindowId) -> bool {
let Some(platform_drag) = self.platform_owned_drag.as_mut().filter(|drag| {
drag.source_window == source_window
&& matches!(&drag.state, PlatformOwnedDragState::RestoredInSourceWindow)
}) else {
return false;
};
let Some(drag) = self.active_drag.take() else {
return false;
};
platform_drag.state = PlatformOwnedDragState::Suspended(drag);
true
}
pub(crate) fn end_platform_drag(&mut self, source_window: WindowId) -> bool {
if !self
.platform_owned_drag
.as_ref()
.is_some_and(|drag| drag.source_window == source_window)
{
return false;
}
self.platform_owned_drag = None;
self.active_drag = None;
true
}
/// Sets the cursor style for the currently active drag operation.
pub fn set_active_drag_cursor_style(
&mut self,
+2
View File
@@ -745,6 +745,8 @@ pub enum FileDropEvent {
},
/// The user has stopped dragging the files over the window.
Exited,
/// The platform-owned drag session has ended.
Ended,
}
impl Sealed for FileDropEvent {}
+189 -10
View File
@@ -4956,7 +4956,8 @@ impl Window {
PlatformInput::FileDrop(file_drop) => match file_drop {
FileDropEvent::Entered { position, paths } => {
self.mouse_position = position;
if cx.active_drag.is_none() {
let source_window = self.handle.window_id();
if !cx.restore_platform_drag(source_window) && cx.active_drag.is_none() {
cx.active_drag = Some(AnyDrag {
value: Arc::new(paths.clone()),
view: cx.new(|_| paths).into(),
@@ -4990,9 +4991,17 @@ impl Window {
})
}
FileDropEvent::Exited => {
cx.active_drag.take();
if !cx.hand_restored_drag_to_platform(self.handle.window_id()) {
cx.active_drag.take();
}
self.refresh();
PlatformInput::FileDrop(FileDropEvent::Exited)
}
FileDropEvent::Ended => {
cx.end_platform_drag(self.handle.window_id());
self.refresh();
PlatformInput::FileDrop(FileDropEvent::Ended)
}
},
PlatformInput::Touch(touch) => PlatformInput::Touch(touch),
PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event,
@@ -5047,8 +5056,10 @@ impl Window {
let Some(payload) = payload_source(self, cx) else {
return;
};
if self.platform_window.start_external_drag(&payload) {
cx.stop_active_drag(self);
if self.platform_window.start_external_drag(&payload)
&& cx.hand_active_drag_to_platform(self.handle.window_id())
{
self.refresh();
}
}
@@ -6741,10 +6752,11 @@ mod tests {
use crate::{
AnyWindowHandle, AppContext as _, Bounds, Context, DragMoveEvent, Empty,
ExternalDragPayload, FileDragPaths, FocusHandle, InputEvent as _, InteractiveElement as _,
IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point,
Render, StatefulInteractiveElement as _, Styled, TestAppContext, Window, WindowAppearance,
WindowOptions, canvas, div, point, px, size,
ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle,
InputEvent as _, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent,
MouseMoveEvent, ParentElement, Pixels, Point, Render, StatefulInteractiveElement as _,
Styled, TestAppContext, Window, WindowAppearance, WindowOptions, canvas, div, point, px,
size,
};
struct EmptyView;
@@ -6898,6 +6910,7 @@ mod tests {
struct FileDragView {
path: PathBuf,
observed_drag_moves: Rc<RefCell<Vec<Point<Pixels>>>>,
observed_drops: Rc<RefCell<Vec<PathBuf>>>,
}
impl Render for FileDragView {
@@ -6918,24 +6931,32 @@ mod tests {
observed_drag_moves.borrow_mut().push(event.event.position);
}
})
.on_drop({
let observed_drops = self.observed_drops.clone();
move |path: &PathBuf, _, _| observed_drops.borrow_mut().push(path.clone())
})
}
}
#[gpui::test]
fn file_drag_is_promoted_once_after_leaving_the_viewport(cx: &mut TestAppContext) {
fn file_drag_is_promoted_once_and_restored_in_source_window(cx: &mut TestAppContext) {
struct Drag {
window: AnyWindowHandle,
observed_drag_moves: Rc<RefCell<Vec<Point<Pixels>>>>,
observed_drops: Rc<RefCell<Vec<PathBuf>>>,
}
fn start_drag(cx: &mut TestAppContext, path: PathBuf, platform_result: bool) -> Drag {
let observed_drag_moves = Rc::new(RefCell::new(Vec::new()));
let observed_drops = Rc::new(RefCell::new(Vec::new()));
let window: AnyWindowHandle = cx
.add_window({
let observed_drag_moves = observed_drag_moves.clone();
let observed_drops = observed_drops.clone();
move |_, _| FileDragView {
path,
observed_drag_moves,
observed_drops,
}
})
.into();
@@ -6975,6 +6996,7 @@ mod tests {
Drag {
window,
observed_drag_moves,
observed_drops,
}
}
@@ -6999,7 +7021,7 @@ mod tests {
);
assert_eq!(
cx.test_window(successful.window).external_drag_files(),
[(successful_path, true)]
[(successful_path.clone(), true)]
);
// Views must still see the move that leaves the window, otherwise they never learn to tear
// down the drag state they built up while the pointer was inside.
@@ -7008,6 +7030,163 @@ mod tests {
Some(&outside_position)
);
let destination: AnyWindowHandle = cx.add_window(|_, _| EmptyView).into();
let reentry_position = point(px(30.), px(30.));
let external_paths = || ExternalPaths([successful_path.clone()].into_iter().collect());
let update_result = cx.update_window(destination, |_, window, cx| {
window.dispatch_event(
FileDropEvent::Entered {
position: reentry_position,
paths: external_paths(),
}
.to_platform_input(),
cx,
);
assert!(
cx.active_drag
.as_ref()
.is_some_and(|drag| drag.value.downcast_ref::<ExternalPaths>().is_some())
);
window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx);
assert!(cx.active_drag.is_none());
});
assert!(
update_result.is_ok(),
"failed to handle drag in destination window: {update_result:?}"
);
let update_result = cx.update_window(successful.window, |_, window, cx| {
window.dispatch_event(
FileDropEvent::Entered {
position: reentry_position,
paths: external_paths(),
}
.to_platform_input(),
cx,
);
assert!(
cx.active_drag
.as_ref()
.is_some_and(|drag| drag.value.downcast_ref::<PathBuf>().is_some())
);
assert_eq!(
successful.observed_drag_moves.borrow().last(),
Some(&reentry_position)
);
window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx);
assert!(cx.active_drag.is_none());
window.dispatch_event(
FileDropEvent::Entered {
position: reentry_position,
paths: external_paths(),
}
.to_platform_input(),
cx,
);
assert!(
cx.active_drag
.as_ref()
.is_some_and(|drag| drag.value.downcast_ref::<PathBuf>().is_some())
);
window.dispatch_event(
FileDropEvent::Submit {
position: reentry_position,
}
.to_platform_input(),
cx,
);
assert_eq!(
successful.observed_drops.borrow().as_slice(),
std::slice::from_ref(&successful_path)
);
assert!(cx.active_drag.is_none());
window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx);
assert!(cx.active_drag.is_none());
window.dispatch_event(FileDropEvent::Ended.to_platform_input(), cx);
assert!(cx.active_drag.is_none());
window.dispatch_event(
FileDropEvent::Entered {
position: reentry_position,
paths: external_paths(),
}
.to_platform_input(),
cx,
);
assert!(
cx.active_drag
.as_ref()
.is_some_and(|drag| drag.value.downcast_ref::<ExternalPaths>().is_some())
);
window.dispatch_event(FileDropEvent::Exited.to_platform_input(), cx);
});
assert!(
update_result.is_ok(),
"failed to restore drag in source window: {update_result:?}"
);
let cancelled_path = PathBuf::from("/tmp/cancelled-drag");
let cancelled = start_drag(cx, cancelled_path.clone(), true);
let update_result = cx.update_window(cancelled.window, |_, window, cx| {
window.dispatch_event(
MouseMoveEvent {
position: outside_position,
pressed_button: Some(MouseButton::Left),
modifiers: Default::default(),
}
.to_platform_input(),
cx,
);
assert!(cx.active_drag.is_none());
window.dispatch_event(
FileDropEvent::Entered {
position: reentry_position,
paths: ExternalPaths([cancelled_path].into_iter().collect()),
}
.to_platform_input(),
cx,
);
assert!(
cx.active_drag
.as_ref()
.is_some_and(|drag| drag.value.downcast_ref::<PathBuf>().is_some())
);
assert!(cx.stop_active_drag(window));
assert!(cx.active_drag.is_none());
});
assert!(
update_result.is_ok(),
"failed to cancel restored drag: {update_result:?}"
);
assert!(!cx.update(|cx| cx.end_platform_drag(cancelled.window.window_id())));
let removed_path = PathBuf::from("/tmp/removed-window-drag");
let removed = start_drag(cx, removed_path, true);
let removed_window_id = removed.window.window_id();
let update_result = cx.update_window(removed.window, |_, window, cx| {
window.dispatch_event(
MouseMoveEvent {
position: outside_position,
pressed_button: Some(MouseButton::Left),
modifiers: Default::default(),
}
.to_platform_input(),
cx,
);
assert!(cx.active_drag.is_none());
window.remove_window();
});
assert!(
update_result.is_ok(),
"failed to remove drag source window: {update_result:?}"
);
assert!(!cx.update(|cx| cx.end_platform_drag(removed_window_id)));
let failed_path = PathBuf::from("/tmp/failed-drag");
let failed = start_drag(cx, failed_path.clone(), false);
let update_result = cx.update_window(failed.window, |_, window, cx| {
+18 -18
View File
@@ -3070,45 +3070,42 @@ fn is_drag_from_this_window(this: &Object, dragging_info: id) -> bool {
}
extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation {
if is_drag_from_this_window(this, dragging_info) {
return NSDragOperationNone;
}
let is_source_window = is_drag_from_this_window(this, dragging_info);
let window_state = unsafe { get_window_state(this) };
let position = drag_event_position(&window_state, dragging_info);
let paths = external_paths_from_event(dragging_info);
if let Some(event) = paths.map(|paths| FileDropEvent::Entered { position, paths })
&& send_file_drop_event(window_state, event)
{
if is_source_window {
return NSDragOperationMove;
}
return NSDragOperationCopy;
}
NSDragOperationNone
}
extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation {
if is_drag_from_this_window(this, dragging_info) {
return NSDragOperationNone;
}
let is_source_window = is_drag_from_this_window(this, dragging_info);
let window_state = unsafe { get_window_state(this) };
let position = drag_event_position(&window_state, dragging_info);
if send_file_drop_event(window_state, FileDropEvent::Pending { position }) {
NSDragOperationCopy
if is_source_window {
NSDragOperationMove
} else {
NSDragOperationCopy
}
} else {
NSDragOperationNone
}
}
extern "C" fn dragging_exited(this: &Object, _: Sel, dragging_info: id) {
if is_drag_from_this_window(this, dragging_info) {
return;
}
extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) {
let window_state = unsafe { get_window_state(this) };
send_file_drop_event(window_state, FileDropEvent::Exited);
}
extern "C" fn perform_drag_operation(this: &Object, _: Sel, dragging_info: id) -> BOOL {
if is_drag_from_this_window(this, dragging_info) {
return NO;
}
let window_state = unsafe { get_window_state(this) };
let position = drag_event_position(&window_state, dragging_info);
send_file_drop_event(window_state, FileDropEvent::Submit { position }).to_objc()
@@ -3166,9 +3163,12 @@ extern "C" fn dragging_session_ended(
// SAFETY: AppKit invokes this selector on the GPUIWindow instance registered in build_classes,
// which always has WINDOW_STATE_IVAR initialized to the owning MacWindowState.
let window_state = unsafe { get_window_state(this) };
let mut lock = window_state.lock();
lock.synthetic_drag_counter += 1;
lock.last_left_mouse_down_event = None;
{
let mut lock = window_state.lock();
lock.synthetic_drag_counter += 1;
lock.last_left_mouse_down_event = None;
}
send_file_drop_event(window_state, FileDropEvent::Ended);
}
async fn synthetic_drag(
@@ -3202,7 +3202,7 @@ fn send_file_drop_event(
) -> bool {
let external_files_dragged = match file_drop_event {
FileDropEvent::Entered { .. } => Some(true),
FileDropEvent::Exited => Some(false),
FileDropEvent::Exited | FileDropEvent::Ended => Some(false),
_ => None,
};