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:
1 parent
fcde9d2384
commit
9a6ed13221
9 files changed
+853
-52
No files matched your search
+18
-8
@@ -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)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user