Add macOS file drag-out from project panel (#58161)

## Description

Adds native macOS file drag out support for Project Panel entries. Local
file paths are resolved only when a drag gesture starts, remote or
missing paths are skipped, and unsupported platforms keep the default
no-op behavior.

The macOS implementation starts an AppKit drag session with file URL
pasteboard items, allows Finder and other external apps to choose copy
or move, keeps in-app GPUI drag/drop behavior in control for internal
drags, and clears the active drag state when the native session ends.


https://github.com/user-attachments/assets/42909389-1a32-4574-a200-9a14b913f55f

Self-Review Checklist:

- [X] I've reviewed my own diff for quality, security, and reliability
- [X] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [X] Tests cover the new/changed behavior
- [ ] Performance impact has been considered and is acceptable

Closes #55370

Release Notes:

- Added support for dragging files from the Project Panel to external
macOS apps.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
This commit is contained in:
Lee ByeongJunandSmit Barmase authored and GitHub committed 2026-07-29 18:55:25 +00:00
1 parent fcde9d2384
commit 9a6ed13221
9 files changed
+853 -52

No files matched your search

+18 -8
View File
@@ -45,14 +45,15 @@ use crate::InspectorElementRegistry;
use crate::{
Action, ActionBuildError, ActionRegistry, Any, AnyView, AnyWindowHandle, AppContext, Arena,
ArenaBox, Asset, AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle,
DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global,
KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu,
PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout,
PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle,
PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource,
SharedString, SubscriberSet, Subscription, SvgRenderer, SystemNotification,
SystemNotificationResponse, Task, TextRenderingMode, TextSystem, ThermalState, Window,
WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, WindowInvalidator,
DispatchPhase, DisplayId, EventEmitter, ExternalDragPayload, FocusHandle, FocusMap,
ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu,
MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay,
PlatformKeyboardLayout, PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton,
PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation,
ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer,
SystemNotification, SystemNotificationResponse, Task, TextRenderingMode, TextSystem,
ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId,
WindowInvalidator,
colors::{Colors, GlobalColors},
hash, init_app_menus,
};
@@ -2814,8 +2815,17 @@ pub struct AnyDrag {
/// The cursor style to use while dragging
pub cursor_style: Option<CursorStyle>,
/// Resolves the payload to offer the platform if the drag leaves the window.
/// Invoked at most once per drag gesture, at promotion time.
pub external_payload_source: Option<ExternalDragPayloadSource>,
}
/// Lazily resolves the payload handed to the platform when an internal drag is
/// promoted to a native drag session.
pub type ExternalDragPayloadSource =
Box<dyn FnOnce(&mut Window, &mut App) -> Option<ExternalDragPayload> + 'static>;
/// Contains state associated with a tooltip. You'll only need this struct if you're implementing
/// tooltip behavior on a custom element. Otherwise, use [Div::tooltip](crate::Interactivity::tooltip).
#[derive(Clone)]
+83 -18
View File
@@ -18,13 +18,13 @@
use crate::PinchEvent;
use crate::{
Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, DispatchPhase,
Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, GlobalElementId, Hitbox,
HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent,
KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton,
MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent,
MouseUpEvent, Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString,
Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea,
point, px, size,
Display, Element, ElementId, Entity, EntityId, ExternalDragPayload, ExternalDragPayloadSource,
FocusHandle, Global, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId,
IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent,
LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseExitEvent,
MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow, ParentElement, Pixels, Point,
Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId,
Visibility, Window, WindowControlArea, point, px, size,
};
use collections::HashMap;
use gpui_util::ResultExt;
@@ -605,12 +605,41 @@ impl Interactivity {
self.drag_listener.is_none(),
"calling on_drag more than once on the same element is not supported"
);
self.drag_listener = Some((
Arc::new(value),
Box::new(move |value, offset, window, cx| {
self.drag_listener = Some(DragListener {
value: Arc::new(value),
render: Box::new(move |value, offset, window, cx| {
constructor(value.downcast_ref().unwrap(), offset, window, cx).into()
}),
));
external_payload: None,
});
}
/// Registers a callback resolving a payload to offer the platform if a drag started by this
/// element leaves the window. It is invoked at most once per drag gesture, when the pointer
/// exits the viewport. Must be called after [`Self::on_drag`], with the same dragged value
/// type `T`.
pub fn external_drag_payload<T>(
&mut self,
resolver: impl Fn(&T, &mut Window, &mut App) -> Option<ExternalDragPayload> + 'static,
) where
Self: Sized,
T: 'static,
{
let Some(drag_listener) = self.drag_listener.as_mut() else {
debug_assert!(false, "external_drag_payload must be called after on_drag");
return;
};
debug_assert!(
drag_listener.value.as_ref().type_id() == TypeId::of::<T>(),
"external_drag_payload must use the same dragged value type as on_drag"
);
debug_assert!(
drag_listener.external_payload.is_none(),
"calling external_drag_payload more than once on the same element is not supported"
);
drag_listener.external_payload = Some(Box::new(move |value, window, cx| {
resolver(value.downcast_ref::<T>()?, window, cx)
}));
}
/// Bind the given callback on the hover start and end events of this element. Note that the boolean
@@ -1519,6 +1548,23 @@ pub trait StatefulInteractiveElement: InteractiveElement {
self
}
/// Registers a callback resolving a payload to offer the platform if a drag started by this
/// element leaves the window. It is invoked at most once per drag gesture, when the pointer
/// exits the viewport. Must be called after [`Self::on_drag`], with the same dragged value
/// type `T`.
/// The fluent API equivalent to [`Interactivity::external_drag_payload`].
fn external_drag_payload<T>(
mut self,
resolver: impl Fn(&T, &mut Window, &mut App) -> Option<ExternalDragPayload> + 'static,
) -> Self
where
Self: Sized,
T: 'static,
{
self.interactivity().external_drag_payload(resolver);
self
}
/// Bind the given callback on the hover start and end events of this element. Note that the boolean
/// passed to the callback is true when the hover starts and false when it ends.
/// The fluent API equivalent to [`Interactivity::on_hover`].
@@ -1586,8 +1632,14 @@ pub(crate) type PinchListener =
pub(crate) type ClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
pub(crate) type DragListener =
Box<dyn Fn(&dyn Any, Point<Pixels>, &mut Window, &mut App) -> AnyView + 'static>;
pub(crate) struct DragListener {
value: Arc<dyn Any>,
render: Box<dyn Fn(&dyn Any, Point<Pixels>, &mut Window, &mut App) -> AnyView + 'static>,
external_payload: Option<ExternalDragPayloadResolver>,
}
type ExternalDragPayloadResolver =
Box<dyn Fn(&dyn Any, &mut Window, &mut App) -> Option<ExternalDragPayload> + 'static>;
type DropListener = Box<dyn Fn(&dyn Any, &mut Window, &mut App) + 'static>;
@@ -1994,7 +2046,7 @@ pub struct Interactivity {
pub(crate) can_drop_predicate: Option<CanDropPredicate>,
pub(crate) click_listeners: Vec<ClickListener>,
pub(crate) aux_click_listeners: Vec<ClickListener>,
pub(crate) drag_listener: Option<(Arc<dyn Any>, DragListener)>,
pub(crate) drag_listener: Option<DragListener>,
pub(crate) hover_listener: Option<Box<dyn Fn(&bool, &mut Window, &mut App)>>,
pub(crate) tooltip_builder: Option<TooltipBuilder>,
pub(crate) tooltip_show_delay: Option<Duration>,
@@ -2767,18 +2819,31 @@ impl Interactivity {
if let Some(mouse_down) = pending_mouse_down.clone()
&& !cx.has_active_drag()
&& (event.position - mouse_down.position).magnitude() > DRAG_THRESHOLD
&& let Some((drag_value, drag_listener)) = drag_listener.take()
&& let Some(listener) = drag_listener.take()
&& mouse_down.button == MouseButton::Left
{
*clicked_state.borrow_mut() = ElementClickedState::default();
let cursor_offset = event.position - hitbox.origin;
let drag =
(drag_listener)(drag_value.as_ref(), cursor_offset, window, cx);
let drag = (listener.render)(
listener.value.as_ref(),
cursor_offset,
window,
cx,
);
let external_payload_source =
listener.external_payload.map(|external_payload| {
let value = listener.value.clone();
Box::new(move |window: &mut Window, cx: &mut App| {
external_payload(value.as_ref(), window, cx)
})
as ExternalDragPayloadSource
});
cx.active_drag = Some(AnyDrag {
view: drag,
value: drag_value,
value: listener.value,
cursor_offset,
cursor_style: drag_cursor_style,
external_payload_source,
});
pending_mouse_down.take();
window.refresh();
+25
View File
@@ -691,6 +691,31 @@ impl ExternalPaths {
}
}
/// Data offered to the platform when an internal drag leaves the window and is
/// promoted to a native drag session.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ExternalDragPayload {
/// Real on-disk paths, handed to the platform as an outbound file drag.
Files(FileDragPaths),
}
/// Paths handed to the platform for a native file drag. Directory metadata is
/// provided by the caller to avoid querying it when the platform drag starts.
#[derive(Debug, Clone, Default, Eq, PartialEq)]
pub struct FileDragPaths(SmallVec<[(PathBuf, bool); 2]>);
impl FileDragPaths {
/// Creates a native file-drag payload from paths paired with whether each path is a directory.
pub fn new(entries: impl IntoIterator<Item = (PathBuf, bool)>) -> Self {
Self(entries.into_iter().collect())
}
/// The dragged paths, each paired with whether it is a directory.
pub fn entries(&self) -> &[(PathBuf, bool)] {
&self.0
}
}
impl Render for ExternalPaths {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
// the platform will render icons for the dragged files
+11 -5
View File
@@ -36,11 +36,11 @@ pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBu
use crate::{
Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds,
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, Font, FontId, FontMetrics,
FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels,
PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams, RenderImage,
RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size,
SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size,
DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Edges, ExternalDragPayload, Font,
FontId, FontMetrics, FontRun, ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap,
LineLayout, Pixels, PlatformGestures, PlatformInput, Point, Priority, RenderGlyphParams,
RenderImage, RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString,
Size, SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size,
};
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use anyhow::bail;
@@ -886,6 +886,12 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
fn request_decorations(&self, _decorations: WindowDecorations) {}
fn show_window_menu(&self, _position: Point<Pixels>) {}
fn start_window_move(&self) {}
fn can_start_external_drag(&self) -> bool {
false
}
fn start_external_drag(&self, _payload: &ExternalDragPayload) -> bool {
false
}
fn start_window_resize(&self, _edge: ResizeEdge) {}
fn set_exclusive_zone(&self, _zone: Pixels) {}
#[cfg(all(target_os = "linux", feature = "wayland"))]
+27
View File
@@ -11,6 +11,7 @@ use image::RgbaImage;
use parking_lot::Mutex;
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::{
path::PathBuf,
rc::{Rc, Weak},
sync::{self, Arc},
};
@@ -37,6 +38,8 @@ pub(crate) struct TestWindowState {
input_handler: Option<PlatformInputHandler>,
is_fullscreen: bool,
appearance: WindowAppearance,
external_drag_files: Vec<(PathBuf, bool)>,
start_external_drag_result: bool,
}
#[derive(Clone)]
@@ -91,6 +94,8 @@ impl TestWindow {
input_handler: None,
is_fullscreen: false,
appearance: WindowAppearance::Light,
external_drag_files: Vec::new(),
start_external_drag_result: false,
})))
}
@@ -138,6 +143,14 @@ impl TestWindow {
self.0.lock().input_callback = Some(callback);
!result.propagate
}
pub fn external_drag_files(&self) -> Vec<(PathBuf, bool)> {
self.0.lock().external_drag_files.clone()
}
pub fn set_start_external_drag_result(&self, result: bool) {
self.0.lock().start_external_drag_result = result;
}
}
impl PlatformWindow for TestWindow {
@@ -352,6 +365,20 @@ impl PlatformWindow for TestWindow {
unimplemented!()
}
fn can_start_external_drag(&self) -> bool {
true
}
fn start_external_drag(&self, payload: &crate::ExternalDragPayload) -> bool {
let mut state = self.0.lock();
match payload {
crate::ExternalDragPayload::Files(paths) => {
state.external_drag_files.extend_from_slice(paths.entries());
}
}
state.start_external_drag_result
}
fn update_ime_position(&self, _bounds: Bounds<Pixels>) {}
fn gpu_specs(&self) -> Option<GpuSpecs> {
+182 -4
View File
@@ -4947,6 +4947,7 @@ impl Window {
view: cx.new(|_| paths).into(),
cursor_offset: position,
cursor_style: None,
external_payload_source: None,
});
}
PlatformInput::MouseMove(MouseMoveEvent {
@@ -4988,6 +4989,10 @@ impl Window {
self.dispatch_key_event(any_key_event, cx);
}
// Must run after the move is dispatched: the platform owns the gesture afterwards, so this
// is the last chance for drag listeners to see the pointer leave and reset their state.
self.promote_external_drag_to_platform(&event, cx);
if self.invalidator.update_count() > update_count_before {
self.input_rate_tracker.borrow_mut().record_input();
#[cfg(feature = "input-latency-histogram")]
@@ -5004,6 +5009,34 @@ impl Window {
}
}
fn promote_external_drag_to_platform(&mut self, event: &PlatformInput, cx: &mut App) {
let PlatformInput::MouseMove(mouse_move) = event else {
return;
};
if mouse_move.pressed_button != Some(MouseButton::Left) {
return;
}
if Bounds::new(Point::default(), self.viewport_size).contains(&mouse_move.position) {
return;
}
if !self.platform_window.can_start_external_drag() {
return;
}
let Some(payload_source) = cx
.active_drag
.as_mut()
.and_then(|drag| drag.external_payload_source.take())
else {
return;
};
let Some(payload) = payload_source(self, cx) else {
return;
};
if self.platform_window.start_external_drag(&payload) {
cx.stop_active_drag(self);
}
}
fn dispatch_mouse_event(&mut self, event: &dyn Any, cx: &mut App) {
let hit_test = self.rendered_frame.hit_test(self.mouse_position());
if hit_test != self.mouse_hit_test {
@@ -6685,12 +6718,18 @@ pub fn outline(
#[cfg(test)]
mod tests {
use std::{cell::Cell, rc::Rc};
use std::{
cell::{Cell, RefCell},
path::PathBuf,
rc::Rc,
};
use crate::{
AppContext as _, Bounds, Context, FocusHandle, InteractiveElement as _, IntoElement,
ParentElement, Pixels, Render, Styled, TestAppContext, Window, WindowAppearance,
WindowOptions, canvas, div, px, size,
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,
};
struct EmptyView;
@@ -6841,6 +6880,145 @@ mod tests {
assert_eq!(child_bounds.get().size, size(px(300.), px(200.)));
}
struct FileDragView {
path: PathBuf,
observed_drag_moves: Rc<RefCell<Vec<Point<Pixels>>>>,
}
impl Render for FileDragView {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.id("file-drag")
.size_full()
.on_drag(self.path.clone(), |_, _, _, cx| cx.new(|_| Empty))
.external_drag_payload(|path: &PathBuf, _, _| {
Some(ExternalDragPayload::Files(FileDragPaths::new([(
path.clone(),
true,
)])))
})
.on_drag_move({
let observed_drag_moves = self.observed_drag_moves.clone();
move |event: &DragMoveEvent<PathBuf>, _, _| {
observed_drag_moves.borrow_mut().push(event.event.position);
}
})
}
}
#[gpui::test]
fn file_drag_is_promoted_once_after_leaving_the_viewport(cx: &mut TestAppContext) {
struct Drag {
window: AnyWindowHandle,
observed_drag_moves: Rc<RefCell<Vec<Point<Pixels>>>>,
}
fn start_drag(cx: &mut TestAppContext, path: PathBuf, platform_result: bool) -> Drag {
let observed_drag_moves = Rc::new(RefCell::new(Vec::new()));
let window: AnyWindowHandle = cx
.add_window({
let observed_drag_moves = observed_drag_moves.clone();
move |_, _| FileDragView {
path,
observed_drag_moves,
}
})
.into();
cx.test_window(window)
.set_start_external_drag_result(platform_result);
let update_result = cx.update_window(window, |_, window, cx| {
window.draw(cx).clear(cx);
window.dispatch_event(
MouseDownEvent {
position: point(px(10.), px(10.)),
button: MouseButton::Left,
modifiers: Default::default(),
click_count: 1,
first_mouse: false,
}
.to_platform_input(),
cx,
);
window.dispatch_event(
MouseMoveEvent {
position: point(px(20.), px(20.)),
pressed_button: Some(MouseButton::Left),
modifiers: Default::default(),
}
.to_platform_input(),
cx,
);
assert!(cx.active_drag.is_some());
});
assert!(
update_result.is_ok(),
"failed to start drag: {update_result:?}"
);
assert!(cx.test_window(window).external_drag_files().is_empty());
Drag {
window,
observed_drag_moves,
}
}
let successful_path = PathBuf::from("/tmp/successful-drag");
let successful = start_drag(cx, successful_path.clone(), true);
let outside_position = point(px(-1.), px(20.));
let update_result = cx.update_window(successful.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());
});
assert!(
update_result.is_ok(),
"failed to promote drag: {update_result:?}"
);
assert_eq!(
cx.test_window(successful.window).external_drag_files(),
[(successful_path, 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.
assert_eq!(
successful.observed_drag_moves.borrow().last(),
Some(&outside_position)
);
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| {
for x_position in [-1., -2.] {
window.dispatch_event(
MouseMoveEvent {
position: point(px(x_position), px(20.)),
pressed_button: Some(MouseButton::Left),
modifiers: Default::default(),
}
.to_platform_input(),
cx,
);
}
assert!(cx.active_drag.is_some());
});
assert!(
update_result.is_ok(),
"failed to retain drag after platform failure: {update_result:?}"
);
assert_eq!(
cx.test_window(failed.window).external_drag_files(),
[(failed_path, true)]
);
}
struct FocusForwarder {
a: FocusHandle,
b: FocusHandle,
+229 -11
View File
@@ -9,7 +9,7 @@ use anyhow::Result;
use block::ConcreteBlock;
use cocoa::{
appkit::{
NSApplication, NSBackingStoreBuffered, NSColor, NSEventModifierFlags,
NSApplication, NSBackingStoreBuffered, NSColor, NSEvent, NSEventModifierFlags, NSEventType,
NSFilenamesPboardType, NSPasteboard, NSRequestUserAttentionType, NSScreen, NSView,
NSViewHeightSizable, NSViewWidthSizable, NSVisualEffectMaterial, NSVisualEffectState,
NSVisualEffectView, NSWindow, NSWindowCollectionBehavior, NSWindowOcclusionState,
@@ -24,13 +24,13 @@ use cocoa::{
};
use dispatch2::DispatchQueue;
use gpui::{
AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalPaths,
FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent,
MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas,
PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton,
PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance,
WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, point,
px, size,
AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalDragPayload,
ExternalPaths, FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab,
WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind,
WindowParams, point, px, size,
};
#[cfg(any(test, feature = "test-support"))]
use image::RgbaImage;
@@ -49,7 +49,7 @@ use objc::{
runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES},
sel, sel_impl,
};
use objc2::rc::Retained;
use objc2::{rc::Retained, runtime::AnyObject as Objc2Object};
use objc2_app_kit::{
NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, NSWindow as Objc2NSWindow,
NSWindowButton as Objc2NSWindowButton,
@@ -60,9 +60,10 @@ use raw_window_handle as rwh;
use smallvec::SmallVec;
use std::{
cell::Cell,
ffi::{CStr, c_void},
ffi::{CStr, CString, c_void},
mem,
ops::Range,
os::unix::ffi::OsStrExt,
path::PathBuf,
ptr::{self, NonNull},
rc::Rc,
@@ -108,6 +109,10 @@ type NSDragOperation = NSUInteger;
const NSDragOperationNone: NSDragOperation = 0;
#[allow(non_upper_case_globals)]
const NSDragOperationCopy: NSDragOperation = 1;
#[allow(non_upper_case_globals)]
const NSDragOperationMove: NSDragOperation = 16;
const NSDRAGGING_CONTEXT_OUTSIDE_APPLICATION: NSInteger = 0;
const NSDRAGGING_CONTEXT_WITHIN_APPLICATION: NSInteger = 1;
#[derive(PartialEq)]
pub enum UserTabbingPreference {
Never,
@@ -115,6 +120,12 @@ pub enum UserTabbingPreference {
InFullScreen,
}
#[link(name = "AppKit", kind = "framework")]
unsafe extern "C" {
// AppKit constant naming the icon component of an NSDraggingImageComponent.
#[allow(non_upper_case_globals)]
static NSDraggingImageComponentIconKey: id;
}
#[ctor(unsafe)]
unsafe fn build_classes() {
unsafe {
@@ -428,6 +439,17 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C
conclude_drag_operation as extern "C" fn(&Object, Sel, id),
);
decl.add_protocol(Protocol::get("NSDraggingSource").unwrap());
decl.add_method(
sel!(draggingSession:sourceOperationMaskForDraggingContext:),
dragging_session_source_operation_mask
as extern "C" fn(&Object, Sel, id, NSInteger) -> NSDragOperation,
);
decl.add_method(
sel!(draggingSession:endedAtPoint:operation:),
dragging_session_ended as extern "C" fn(&Object, Sel, id, NSPoint, NSDragOperation),
);
decl.add_method(
sel!(addTitlebarAccessoryViewController:),
add_titlebar_accessory_view_controller as extern "C" fn(&Object, Sel, id),
@@ -497,6 +519,7 @@ struct MacWindowState {
appearance_changed_callback: Option<Box<dyn FnMut()>>,
input_handler: Option<PlatformInputHandler>,
last_key_equivalent: Option<KeyDownEvent>,
last_left_mouse_down_event: Option<Retained<Objc2Object>>,
synthetic_drag_counter: usize,
traffic_light_position: Option<Point<Pixels>>,
traffic_light_frames: Option<TrafficLightFrames>,
@@ -898,6 +921,7 @@ impl MacWindow {
appearance_changed_callback: None,
input_handler: None,
last_key_equivalent: None,
last_left_mouse_down_event: None,
synthetic_drag_counter: 0,
traffic_light_position: titlebar
.as_ref()
@@ -1820,6 +1844,135 @@ impl PlatformWindow for MacWindow {
}
}
fn can_start_external_drag(&self) -> bool {
true
}
fn start_external_drag(&self, payload: &ExternalDragPayload) -> bool {
let ExternalDragPayload::Files(paths) = payload;
if paths.entries().is_empty() {
log::warn!("start_external_drag declined: no paths");
return false;
}
let (native_view, native_window, last_left_mouse_down_event) = {
let state = self.0.lock();
(
state.native_view.as_ptr(),
state.native_window,
state.last_left_mouse_down_event.clone(),
)
};
let Some(last_left_mouse_down_event) = last_left_mouse_down_event else {
log::warn!("start_external_drag declined: no retained left mouse down event");
return false;
};
// SAFETY: This method runs on the AppKit/foreground path during drag initiation. The
// native view/window are retained by MacWindowState, copied out under a short lock above,
// and Objective-C results that may be nil are checked before use.
unsafe {
let event: id = Retained::as_ptr(&last_left_mouse_down_event)
.cast_mut()
.cast();
let dragging_items: id = msg_send![class!(NSMutableArray), array];
// AppKit keeps this frame's distance from the event's location as the drag image's
// offset from the cursor, so it has to stay anchored on `event`.
let location: NSPoint = msg_send![event, locationInWindow];
let frame = NSRect::new(
NSPoint::new(location.x - 16., location.y - 16.),
NSSize::new(32., 32.),
);
for (path, is_directory) in paths.entries() {
// Preserve non-UTF-8 paths
let Ok(path_bytes) = CString::new(path.as_os_str().as_bytes()) else {
log::warn!("start_external_drag skipped path containing an interior nul byte");
continue;
};
let url: id = msg_send![
class!(NSURL),
fileURLWithFileSystemRepresentation: path_bytes.as_ptr()
isDirectory: is_directory.to_objc()
relativeToURL: nil
];
if url.is_null() {
log::warn!("start_external_drag skipped path with nil NSURL");
continue;
}
let item: id = msg_send![class!(NSDraggingItem), alloc];
let item: id = msg_send![item, initWithPasteboardWriter: url];
if item.is_null() {
log::warn!("start_external_drag declined: NSDraggingItem allocation failed");
continue;
}
// Resolve drag images lazily via `imageComponentsProvider` (Apple's
// recommendation for large item counts), and by file *type* rather than
// `iconForFile:`, which can synchronously hit LaunchServices, network
// mounts, or iCloud for every selected path and beachball drag startup.
// `iconForFileType:` is deprecated in favor of `iconForContentType:`,
// but the replacement requires macOS 11 and we target 10.15.
let file_type = if *is_directory {
"public.folder".to_string()
} else {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_string())
.unwrap_or_else(|| "public.data".to_string())
};
let provider = ConcreteBlock::new(move || -> id {
let component: id = msg_send![
class!(NSDraggingImageComponent),
draggingImageComponentWithKey: NSDraggingImageComponentIconKey
];
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
let icon: id = msg_send![workspace, iconForFileType: ns_string(&file_type)];
let _: () = msg_send![component, setContents: icon];
// Component frames are relative to the item's dragging frame.
let _: () = msg_send![
component,
setFrame: NSRect::new(NSPoint::new(0., 0.), NSSize::new(32., 32.))
];
msg_send![class!(NSArray), arrayWithObject: component]
});
let provider = provider.copy();
let _: () = msg_send![item, setDraggingFrame: frame];
let _: () = msg_send![item, setImageComponentsProvider: provider];
let _: () = msg_send![dragging_items, addObject: item];
let _: () = msg_send![item, release];
}
let count: NSUInteger = msg_send![dragging_items, count];
if count == 0 {
log::warn!("start_external_drag declined: no dragging items");
return false;
}
let session: id = msg_send![
native_view,
beginDraggingSessionWithItems: dragging_items
event: event
source: native_window
];
let started = !session.is_null();
if started {
self.0.lock().synthetic_drag_counter += 1;
}
log::debug!(
"start_external_drag completed: started={}, item_count={}",
started,
count
);
started
}
}
fn play_system_bell(&self) {
NSBeep()
}
@@ -2235,6 +2388,19 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) {
let weak_window_state = Arc::downgrade(&window_state);
let mut lock = window_state.as_ref().lock();
let window_height = lock.content_size().height;
let native_event_type = unsafe { native_event.eventType() };
match native_event_type {
NSEventType::NSLeftMouseDown => {
// AppKit owns `native_event` for the callback; retain it so the drag session can still
// be started later, once the pointer leaves the window.
lock.last_left_mouse_down_event =
unsafe { Retained::retain(native_event.cast::<Objc2Object>()) };
}
NSEventType::NSLeftMouseUp => {
lock.last_left_mouse_down_event = None;
}
_ => {}
}
let event = unsafe { platform_input_from_native(native_event, Some(window_height)) };
if let Some(mut event) = event {
@@ -2898,7 +3064,15 @@ fn screen_point_to_gpui_point(this: &Object, position: NSPoint) -> Point<Pixels>
point(px(window_x as f32), px(window_y as f32))
}
fn is_drag_from_this_window(this: &Object, dragging_info: id) -> bool {
let source: id = unsafe { msg_send![dragging_info, draggingSource] };
std::ptr::eq(source as *const Object, this as *const Object)
}
extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation {
if is_drag_from_this_window(this, dragging_info) {
return NSDragOperationNone;
}
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);
@@ -2911,6 +3085,9 @@ extern "C" fn dragging_entered(this: &Object, _: Sel, dragging_info: id) -> NSDr
}
extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDragOperation {
if is_drag_from_this_window(this, dragging_info) {
return NSDragOperationNone;
}
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 }) {
@@ -2920,12 +3097,18 @@ extern "C" fn dragging_updated(this: &Object, _: Sel, dragging_info: id) -> NSDr
}
}
extern "C" fn dragging_exited(this: &Object, _: Sel, _: id) {
extern "C" fn dragging_exited(this: &Object, _: Sel, dragging_info: id) {
if is_drag_from_this_window(this, dragging_info) {
return;
}
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()
@@ -2953,6 +3136,41 @@ extern "C" fn conclude_drag_operation(this: &Object, _: Sel, _: id) {
send_file_drop_event(window_state, FileDropEvent::Exited);
}
extern "C" fn dragging_session_source_operation_mask(
_: &Object,
_: Sel,
_: id,
context: NSInteger,
) -> NSDragOperation {
let operation = match context {
NSDRAGGING_CONTEXT_OUTSIDE_APPLICATION => NSDragOperationCopy,
NSDRAGGING_CONTEXT_WITHIN_APPLICATION => NSDragOperationCopy | NSDragOperationMove,
_ => NSDragOperationCopy | NSDragOperationMove,
};
log::debug!(
"dragging_session_source_operation_mask: context={}, operation={}",
context,
operation
);
operation
}
extern "C" fn dragging_session_ended(
this: &Object,
_: Sel,
_: id,
_: NSPoint,
operation: NSDragOperation,
) {
log::debug!("dragging_session_ended operation={operation}");
// 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;
}
async fn synthetic_drag(
window_state: Weak<Mutex<MacWindowState>>,
drag_id: usize,
+53 -6
View File
@@ -22,12 +22,13 @@ use git_ui::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardEntry as GpuiClipboardEntry,
ClipboardItem, Context, CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter,
ExternalPaths, FocusHandle, Focusable, FontWeight, Hsla, InteractiveElement, KeyContext,
ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers, ModifiersChangedEvent,
MouseButton, MouseDownEvent, MouseExitEvent, ParentElement, PathPromptOptions, Pixels, Point,
PromptLevel, Render, ScrollStrategy, Stateful, Styled, Subscription, Task,
UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred, div, hsla,
linear_color_stop, linear_gradient, point, px, size, transparent_white, uniform_list,
ExternalDragPayload, ExternalPaths, FileDragPaths, FocusHandle, Focusable, FontWeight, Hsla,
InteractiveElement, KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Modifiers,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, ParentElement,
PathPromptOptions, Pixels, Point, PromptLevel, Render, ScrollStrategy, Stateful, Styled,
Subscription, Task, UniformListScrollHandle, WeakEntity, Window, actions, anchored, deferred,
div, hsla, linear_color_stop, linear_gradient, point, px, size, transparent_white,
uniform_list,
};
use language::DiagnosticSeverity;
use markdown_preview::markdown_preview_view::MarkdownPreviewView;
@@ -4760,6 +4761,43 @@ impl ProjectPanel {
|| cfg!(not(target_os = "macos")) && modifiers.control
}
fn file_drag_paths_for_selections(
project: &Entity<Project>,
selections: impl IntoIterator<Item = SelectedEntry>,
cx: &App,
) -> Option<FileDragPaths> {
let project = project.read(cx);
let paths = selections
.into_iter()
.filter_map(|selection| {
let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
if !worktree.is_local() {
return None;
}
let entry = worktree.entry_for_id(selection.entry_id)?;
Some((worktree.absolutize(&entry.path), entry.is_dir()))
})
.collect::<SmallVec<[_; 2]>>();
(!paths.is_empty()).then(|| FileDragPaths::new(paths))
}
fn external_paths_for_dragged_selection(
&self,
selections: &DraggedSelection,
cx: &App,
) -> Option<FileDragPaths> {
let resolved_selections = selections
.items()
.map(|selection| SelectedEntry {
worktree_id: selection.worktree_id,
entry_id: self.resolve_entry(selection.entry_id),
})
.collect::<BTreeSet<SelectedEntry>>();
let entries = self.disjoint_entries(resolved_selections, cx);
Self::file_drag_paths_for_selections(&self.project, entries, cx)
}
fn drag_onto(
&mut self,
selections: &DraggedSelection,
@@ -5944,6 +5982,15 @@ impl ProjectPanel {
})
}
})
.external_drag_payload({
let project_panel = cx.entity();
move |selection: &DraggedSelection, _window, cx| {
project_panel
.read(cx)
.external_paths_for_dragged_selection(selection, cx)
.map(ExternalDragPayload::Files)
}
})
.on_drop(cx.listener(
move |this, selections: &DraggedSelection, window, cx| {
this.clear_drag_state(cx);
@@ -4595,6 +4595,231 @@ async fn test_rename_survives_window_deactivation(cx: &mut gpui::TestAppContext)
);
}
#[gpui::test]
async fn test_file_drag_paths_use_worktree_snapshot(cx: &mut gpui::TestAppContext) {
init_test(cx);
let temp_dir = tempfile::tempdir().unwrap();
let root_path = temp_dir.path();
let existing_a = root_path.join("existing_a.txt");
let existing_b = root_path.join("existing_b.txt");
let deleted_directory = root_path.join("deleted_dir");
std::fs::write(&existing_a, "a").unwrap();
std::fs::write(&existing_b, "b").unwrap();
std::fs::create_dir(&deleted_directory).unwrap();
std::fs::write(deleted_directory.join("nested.txt"), "nested").unwrap();
let fs = FakeFs::new(cx.executor());
fs.insert_tree_from_real_fs(root_path, root_path).await;
std::fs::remove_dir_all(&deleted_directory).unwrap();
let project = Project::test(fs.clone(), [root_path], cx).await;
let (worktree_id, existing_a_id, existing_b_id, deleted_directory_id) = cx.update(|cx| {
let project = project.read(cx);
let worktree = project.worktrees(cx).next().unwrap();
let worktree = worktree.read(cx);
(
worktree.id(),
worktree
.entry_for_path(rel_path("existing_a.txt"))
.unwrap()
.id,
worktree
.entry_for_path(rel_path("existing_b.txt"))
.unwrap()
.id,
worktree.entry_for_path(rel_path("deleted_dir")).unwrap().id,
)
});
let dragged_selection = DraggedSelection {
active_selection: SelectedEntry {
worktree_id,
entry_id: existing_a_id,
},
marked_selections: Arc::from(vec![
SelectedEntry {
worktree_id,
entry_id: existing_a_id,
},
SelectedEntry {
worktree_id,
entry_id: existing_b_id,
},
SelectedEntry {
worktree_id,
entry_id: deleted_directory_id,
},
]),
};
let paths = cx
.update(|cx| {
ProjectPanel::file_drag_paths_for_selections(
&project,
dragged_selection.items().copied(),
cx,
)
})
.unwrap();
assert_eq!(
paths.entries(),
&[
(existing_a, false),
(existing_b, false),
(deleted_directory, true),
]
);
}
#[gpui::test]
async fn test_external_paths_for_dragged_selection_uses_active_selection_unless_marked(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let temp_dir = tempfile::tempdir().unwrap();
let root_path = temp_dir.path();
let active_path = root_path.join("active.txt");
let marked_path = root_path.join("marked.txt");
std::fs::write(&active_path, "active").unwrap();
std::fs::write(&marked_path, "marked").unwrap();
let fs = FakeFs::new(cx.executor());
fs.insert_tree_from_real_fs(root_path, root_path).await;
let project = Project::test(fs.clone(), [root_path], cx).await;
let (worktree_id, active_id, marked_id) = cx.update(|cx| {
let project = project.read(cx);
let worktree = project.worktrees(cx).next().unwrap();
let worktree = worktree.read(cx);
(
worktree.id(),
worktree.entry_for_path(rel_path("active.txt")).unwrap().id,
worktree.entry_for_path(rel_path("marked.txt")).unwrap().id,
)
});
let dragged_selection = DraggedSelection {
active_selection: SelectedEntry {
worktree_id,
entry_id: active_id,
},
marked_selections: Arc::from(vec![SelectedEntry {
worktree_id,
entry_id: marked_id,
}]),
};
let paths = cx
.update(|cx| {
ProjectPanel::file_drag_paths_for_selections(
&project,
dragged_selection.items().copied(),
cx,
)
})
.unwrap();
assert_eq!(paths.entries(), &[(active_path, false)]);
}
#[gpui::test]
async fn test_external_paths_for_dragged_selection_resolves_folded_directory(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/root",
json!({
"a": {
"b": {
"c": {
"d": {}
}
}
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(window.into(), cx);
cx.update(|_, cx| {
let settings = *ProjectPanelSettings::get_global(cx);
ProjectPanelSettings::override_global(
ProjectPanelSettings {
auto_fold_dirs: true,
..settings
},
cx,
);
});
let panel = workspace.update_in(cx, ProjectPanel::new);
cx.run_until_parked();
select_folded_path_with_mark(&panel, "root/a/b/c/d", "root/a/b", cx);
let dragged_selection = panel.read_with(cx, |panel, _| DraggedSelection {
active_selection: panel.selection.unwrap(),
marked_selections: Arc::from(panel.marked_entries.clone()),
});
let paths = cx
.update(|_, cx| {
panel.read_with(cx, |panel, cx| {
panel.external_paths_for_dragged_selection(&dragged_selection, cx)
})
})
.unwrap();
assert_eq!(paths.entries(), &[(PathBuf::from("/root/a/b"), true)]);
}
#[gpui::test]
async fn test_external_paths_for_dragged_selection_skips_remote_worktrees(
cx: &mut gpui::TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/local", json!({})).await;
let project = Project::test(fs.clone(), ["/local".as_ref()], cx).await;
let remote_worktree = project.update(cx, |project, cx| {
project.add_test_remote_worktree("/remote/project", cx)
});
let remote_worktree_id = remote_worktree.read_with(cx, |worktree, _| worktree.id());
let dragged_selection = DraggedSelection {
active_selection: SelectedEntry {
worktree_id: remote_worktree_id,
entry_id: ProjectEntryId::from_usize(1),
},
marked_selections: Arc::from(vec![SelectedEntry {
worktree_id: remote_worktree_id,
entry_id: ProjectEntryId::from_usize(1),
}]),
};
let paths = cx.update(|cx| {
ProjectPanel::file_drag_paths_for_selections(
&project,
dragged_selection.items().copied(),
cx,
)
});
assert!(paths.is_none());
}
#[gpui::test]
async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);