project_panel: Add support for pasting external files on macOS (#49367)

David and Smit Barmase created

Part of #29026

## Summary

https://github.com/user-attachments/assets/35b4f969-1fcf-45f4-88cd-cbc27ad9696e

macOS Finder places file paths on the system pasteboard using
`NSFilenamesPboardType` when files are copied. Previously, the project
panel only supported its own internal clipboard for copy/cut/paste
operations and ignored system clipboard content entirely. This meant
that copying files in Finder and pasting them in Zed's project panel did
nothing.

This PR adds support for reading file paths from the macOS system
pasteboard, enabling a natural workflow where users can copy files in
Finder (or other file managers) and paste them directly into Zed's
project panel.

> **Note:** Pasting files from a system file manager currently only
works on macOS. The project panel changes are cross-platform, but the
clipboard reading of file paths (`ExternalPaths`) is only implemented in
the macOS pasteboard. Windows and Linux would need equivalent changes in
their respective platform clipboard implementations to support this.
Copying/cutting files from the project panel to the system clipboard as
plain text works on all platforms.

### Changes

**`crates/gpui/src/platform/mac/pasteboard.rs`**
- Read `NSFilenamesPboardType` from the system pasteboard and surface
file paths as `ClipboardEntry::ExternalPaths`
- Check for file paths before plain text, since Finder puts both types
on the pasteboard (without this priority, file paths would be returned
as plain text strings)
- Extract the string-reading logic into `read_string_from_pasteboard()`
to allow reuse

**`crates/project_panel/src/project_panel.rs`**
- On paste, check the system clipboard for external file paths and use
the existing `drop_external_files` mechanism to copy them into the
project
- On copy/cut, write the selected entries' absolute paths to the system
clipboard so other apps can consume them
- Update the "Paste" context menu item to also be enabled when the
system clipboard contains file paths, not just when the internal
clipboard has entries

## Test plan

- [ ] Copy one or more files in Finder, paste in the project panel —
files should be copied into the selected directory
- [ ] Copy files within the project panel, paste — existing internal
copy/paste behavior is preserved
- [ ] Cut files within the project panel, paste — existing internal
cut/paste behavior is preserved
- [ ] Copy files in the project panel, paste in Finder or another app —
paths are available as plain text
- [ ] Right-click context menu shows "Paste" enabled when system
clipboard has file paths
- [ ] Right-click context menu shows "Paste" disabled when both internal
and system clipboards are empty

Release Notes:

- Added support for pasting files from Finder (and other file managers)
into the project panel via the system clipboard (macOS only). Copying or
cutting files in the project panel now also writes their paths to the
system clipboard for use in other apps.

---------

Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

crates/gpui_macos/src/pasteboard.rs             | 239 ++++++++++++++++--
crates/project_panel/src/project_panel.rs       | 103 ++++++-
crates/project_panel/src/project_panel_tests.rs | 111 ++++++++
3 files changed, 409 insertions(+), 44 deletions(-)

Detailed changes

crates/gpui_macos/src/pasteboard.rs 🔗

@@ -1,16 +1,23 @@
 use core::slice;
-use std::ffi::c_void;
+use std::ffi::{CStr, c_void};
+use std::path::PathBuf;
 
 use cocoa::{
-    appkit::{NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString, NSPasteboardTypeTIFF},
+    appkit::{
+        NSFilenamesPboardType, NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeString,
+        NSPasteboardTypeTIFF,
+    },
     base::{id, nil},
-    foundation::NSData,
+    foundation::{NSArray, NSData, NSFastEnumeration, NSString},
 };
 use objc::{msg_send, runtime::Object, sel, sel_impl};
+use smallvec::SmallVec;
 use strum::IntoEnumIterator as _;
 
 use crate::ns_string;
-use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, Image, ImageFormat, hash};
+use gpui::{
+    ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash,
+};
 
 pub struct Pasteboard {
     inner: id,
@@ -41,28 +48,37 @@ impl Pasteboard {
     }
 
     pub fn read(&self) -> Option<ClipboardItem> {
-        // First, see if it's a string.
         unsafe {
-            let pasteboard_types: id = self.inner.types();
-            let string_type: id = ns_string("public.utf8-plain-text");
+            // Check for file paths first
+            let filenames = NSPasteboard::propertyListForType(self.inner, NSFilenamesPboardType);
+            if filenames != nil && NSArray::count(filenames) > 0 {
+                let mut paths = SmallVec::new();
+                for file in filenames.iter() {
+                    let f = NSString::UTF8String(file);
+                    let path = CStr::from_ptr(f).to_string_lossy().into_owned();
+                    paths.push(PathBuf::from(path));
+                }
+                if !paths.is_empty() {
+                    let mut entries = vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths))];
+
+                    // Also include the string representation so text editors can
+                    // paste the path as text.
+                    if let Some(string_item) = self.read_string_from_pasteboard() {
+                        entries.push(string_item);
+                    }
 
-            if msg_send![pasteboard_types, containsObject: string_type] {
-                let data = self.inner.dataForType(string_type);
-                if data == nil {
-                    return None;
-                } else if data.bytes().is_null() {
-                    // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
-                    // "If the length of the NSData object is 0, this property returns nil."
-                    return Some(self.read_string(&[]));
-                } else {
-                    let bytes =
-                        slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize);
-
-                    return Some(self.read_string(bytes));
+                    return Some(ClipboardItem { entries });
                 }
             }
 
-            // If it wasn't a string, try the various supported image types.
+            // Next, check for a plain string.
+            if let Some(string_entry) = self.read_string_from_pasteboard() {
+                return Some(ClipboardItem {
+                    entries: vec![string_entry],
+                });
+            }
+
+            // Finally, try the various supported image types.
             for format in ImageFormat::iter() {
                 if let Some(item) = self.read_image(format) {
                     return Some(item);
@@ -70,7 +86,6 @@ impl Pasteboard {
             }
         }
 
-        // If it wasn't a string or a supported image type, give up.
         None
     }
 
@@ -94,8 +109,26 @@ impl Pasteboard {
         }
     }
 
-    fn read_string(&self, text_bytes: &[u8]) -> ClipboardItem {
+    unsafe fn read_string_from_pasteboard(&self) -> Option<ClipboardEntry> {
         unsafe {
+            let pasteboard_types: id = self.inner.types();
+            let string_type: id = ns_string("public.utf8-plain-text");
+
+            if !msg_send![pasteboard_types, containsObject: string_type] {
+                return None;
+            }
+
+            let data = self.inner.dataForType(string_type);
+            let text_bytes: &[u8] = if data == nil {
+                return None;
+            } else if data.bytes().is_null() {
+                // https://developer.apple.com/documentation/foundation/nsdata/1410616-bytes?language=objc
+                // "If the length of the NSData object is 0, this property returns nil."
+                &[]
+            } else {
+                slice::from_raw_parts(data.bytes() as *mut u8, data.length() as usize)
+            };
+
             let text = String::from_utf8_lossy(text_bytes).to_string();
             let metadata = self
                 .data_for_type(self.text_hash_type)
@@ -111,9 +144,7 @@ impl Pasteboard {
                     }
                 });
 
-            ClipboardItem {
-                entries: vec![ClipboardEntry::String(ClipboardString { text, metadata })],
-            }
+            Some(ClipboardEntry::String(ClipboardString { text, metadata }))
         }
     }
 
@@ -300,12 +331,44 @@ impl UTType {
 
 #[cfg(test)]
 mod tests {
-    use cocoa::{appkit::NSPasteboardTypeString, foundation::NSData};
+    use cocoa::{
+        appkit::{NSFilenamesPboardType, NSPasteboard, NSPasteboardTypeString},
+        base::{id, nil},
+        foundation::{NSArray, NSData},
+    };
+    use std::ffi::c_void;
 
-    use gpui::{ClipboardEntry, ClipboardItem, ClipboardString};
+    use gpui::{ClipboardEntry, ClipboardItem, ClipboardString, ImageFormat};
 
     use super::*;
 
+    unsafe fn simulate_external_file_copy(pasteboard: &Pasteboard, paths: &[&str]) {
+        unsafe {
+            let ns_paths: Vec<id> = paths.iter().map(|p| ns_string(p)).collect();
+            let ns_array = NSArray::arrayWithObjects(nil, &ns_paths);
+
+            let mut types = vec![NSFilenamesPboardType];
+            types.push(NSPasteboardTypeString);
+
+            let types_array = NSArray::arrayWithObjects(nil, &types);
+            pasteboard.inner.declareTypes_owner(types_array, nil);
+
+            pasteboard
+                .inner
+                .setPropertyList_forType(ns_array, NSFilenamesPboardType);
+
+            let joined = paths.join("\n");
+            let bytes = NSData::dataWithBytes_length_(
+                nil,
+                joined.as_ptr() as *const c_void,
+                joined.len() as u64,
+            );
+            pasteboard
+                .inner
+                .setData_forType(bytes, NSPasteboardTypeString);
+        }
+    }
+
     #[test]
     fn test_string() {
         let pasteboard = Pasteboard::unique();
@@ -339,4 +402,124 @@ mod tests {
             Some(ClipboardItem::new_string(text_from_other_app.to_string()))
         );
     }
+
+    #[test]
+    fn test_read_external_path() {
+        let pasteboard = Pasteboard::unique();
+
+        unsafe {
+            simulate_external_file_copy(&pasteboard, &["/test.txt"]);
+        }
+
+        let item = pasteboard.read().expect("should read clipboard item");
+
+        // Test both ExternalPaths and String entries exist
+        assert_eq!(item.entries.len(), 2);
+
+        // Test first entry is ExternalPaths
+        match &item.entries[0] {
+            ClipboardEntry::ExternalPaths(ep) => {
+                assert_eq!(ep.paths(), &[PathBuf::from("/test.txt")]);
+            }
+            other => panic!("expected ExternalPaths, got {:?}", other),
+        }
+
+        // Test second entry is String
+        match &item.entries[1] {
+            ClipboardEntry::String(s) => {
+                assert_eq!(s.text(), "/test.txt");
+            }
+            other => panic!("expected String, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn test_read_external_paths_with_spaces() {
+        let pasteboard = Pasteboard::unique();
+        let paths = ["/some file with spaces.txt"];
+
+        unsafe {
+            simulate_external_file_copy(&pasteboard, &paths);
+        }
+
+        let item = pasteboard.read().expect("should read clipboard item");
+
+        match &item.entries[0] {
+            ClipboardEntry::ExternalPaths(ep) => {
+                assert_eq!(ep.paths(), &[PathBuf::from("/some file with spaces.txt")]);
+            }
+            other => panic!("expected ExternalPaths, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn test_read_multiple_external_paths() {
+        let pasteboard = Pasteboard::unique();
+        let paths = ["/file.txt", "/image.png"];
+
+        unsafe {
+            simulate_external_file_copy(&pasteboard, &paths);
+        }
+
+        let item = pasteboard.read().expect("should read clipboard item");
+        assert_eq!(item.entries.len(), 2);
+
+        // Test both ExternalPaths and String entries exist
+        match &item.entries[0] {
+            ClipboardEntry::ExternalPaths(ep) => {
+                assert_eq!(
+                    ep.paths(),
+                    &[PathBuf::from("/file.txt"), PathBuf::from("/image.png"),]
+                );
+            }
+            other => panic!("expected ExternalPaths, got {:?}", other),
+        }
+
+        match &item.entries[1] {
+            ClipboardEntry::String(s) => {
+                assert_eq!(s.text(), "/file.txt\n/image.png");
+                assert_eq!(s.metadata, None);
+            }
+            other => panic!("expected String, got {:?}", other),
+        }
+    }
+
+    #[test]
+    fn test_read_image() {
+        let pasteboard = Pasteboard::unique();
+
+        // Smallest valid PNG: 1x1 transparent pixel
+        let png_bytes: &[u8] = &[
+            0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
+            0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00,
+            0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78,
+            0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, 0x27, 0xDE, 0xFC, 0x00, 0x00,
+            0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
+        ];
+
+        unsafe {
+            let ns_png_type = NSPasteboardTypePNG;
+            let types_array = NSArray::arrayWithObjects(nil, &[ns_png_type]);
+            pasteboard.inner.declareTypes_owner(types_array, nil);
+
+            let data = NSData::dataWithBytes_length_(
+                nil,
+                png_bytes.as_ptr() as *const c_void,
+                png_bytes.len() as u64,
+            );
+            pasteboard.inner.setData_forType(data, ns_png_type);
+        }
+
+        let item = pasteboard.read().expect("should read PNG image");
+
+        // Test Image entry exists
+        assert_eq!(item.entries.len(), 1);
+        match &item.entries[0] {
+            ClipboardEntry::Image(img) => {
+                assert_eq!(img.format, ImageFormat::Png);
+                assert_eq!(img.bytes, png_bytes);
+            }
+            other => panic!("expected Image, got {:?}", other),
+        }
+    }
 }

crates/project_panel/src/project_panel.rs 🔗

@@ -19,14 +19,14 @@ use git::status::GitSummary;
 use git_ui;
 use git_ui::file_diff_view::FileDiffView;
 use gpui::{
-    Action, AnyElement, App, AsyncWindowContext, Bounds, ClipboardItem, Context, CursorStyle,
-    DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable,
-    FontWeight, Hsla, InteractiveElement, KeyContext, ListHorizontalSizingBehavior,
-    ListSizingBehavior, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent,
-    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,
+    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, 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 menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
@@ -1187,6 +1187,7 @@ impl ProjectPanel {
                     .is_some()
             };
 
+            let has_pasteable_content = self.has_pasteable_content(cx);
             let entity = cx.entity();
             let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
                 menu.context(self.focus_handle.clone()).map(|menu| {
@@ -1233,11 +1234,7 @@ impl ProjectPanel {
                             .action("Copy", Box::new(Copy))
                             .action("Duplicate", Box::new(Duplicate))
                             // TODO: Paste should always be visible, cbut disabled when clipboard is empty
-                            .action_disabled_when(
-                                self.clipboard.as_ref().is_none(),
-                                "Paste",
-                                Box::new(Paste),
-                            )
+                            .action_disabled_when(!has_pasteable_content, "Paste", Box::new(Paste))
                             .when(is_remote, |menu| {
                                 menu.separator()
                                     .action("Download...", Box::new(DownloadFromRemote))
@@ -3000,6 +2997,7 @@ impl ProjectPanel {
     fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
         let entries = self.disjoint_effective_entries(cx);
         if !entries.is_empty() {
+            self.write_entries_to_system_clipboard(&entries, cx);
             self.clipboard = Some(ClipboardEntry::Cut(entries));
             cx.notify();
         }
@@ -3008,6 +3006,7 @@ impl ProjectPanel {
     fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
         let entries = self.disjoint_effective_entries(cx);
         if !entries.is_empty() {
+            self.write_entries_to_system_clipboard(&entries, cx);
             self.clipboard = Some(ClipboardEntry::Copied(entries));
             cx.notify();
         }
@@ -3069,6 +3068,17 @@ impl ProjectPanel {
     }
 
     fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(external_paths) = self.external_paths_from_system_clipboard(cx) {
+            let target_entry_id = self
+                .selection
+                .map(|s| s.entry_id)
+                .or(self.state.last_worktree_root_id);
+            if let Some(entry_id) = target_entry_id {
+                self.drop_external_files(external_paths.paths(), entry_id, window, cx);
+            }
+            return;
+        }
+
         maybe!({
             let (worktree, entry) = self.selected_entry_handle(cx)?;
             let entry = entry.clone();
@@ -3787,6 +3797,51 @@ impl ProjectPanel {
         Some(worktree.absolutize(&root_entry.path))
     }
 
+    fn write_entries_to_system_clipboard(&self, entries: &BTreeSet<SelectedEntry>, cx: &mut App) {
+        let project = self.project.read(cx);
+        let paths: Vec<String> = entries
+            .iter()
+            .filter_map(|entry| {
+                let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
+                let worktree = worktree.read(cx);
+                let worktree_entry = worktree.entry_for_id(entry.entry_id)?;
+                Some(
+                    worktree
+                        .abs_path()
+                        .join(worktree_entry.path.as_std_path())
+                        .to_string_lossy()
+                        .to_string(),
+                )
+            })
+            .collect();
+        if !paths.is_empty() {
+            cx.write_to_clipboard(ClipboardItem::new_string(paths.join("\n")));
+        }
+    }
+
+    fn external_paths_from_system_clipboard(&self, cx: &App) -> Option<ExternalPaths> {
+        let clipboard_item = cx.read_from_clipboard()?;
+        for entry in clipboard_item.entries() {
+            if let GpuiClipboardEntry::ExternalPaths(paths) = entry {
+                if !paths.paths().is_empty() {
+                    return Some(paths.clone());
+                }
+            }
+        }
+        None
+    }
+
+    fn has_pasteable_content(&self, cx: &App) -> bool {
+        if self
+            .clipboard
+            .as_ref()
+            .is_some_and(|c| !c.items().is_empty())
+        {
+            return true;
+        }
+        self.external_paths_from_system_clipboard(cx).is_some()
+    }
+
     fn selected_entry_handle<'a>(
         &self,
         cx: &'a App,
@@ -4273,19 +4328,35 @@ impl ProjectPanel {
                     return Ok(());
                 }
 
-                let task = worktree.update(cx, |worktree, cx| {
-                    worktree.copy_external_entries(target_directory, paths, fs, cx)
+                let (worktree_id, task) = worktree.update(cx, |worktree, cx| {
+                    (
+                        worktree.id(),
+                        worktree.copy_external_entries(target_directory, paths, fs, cx),
+                    )
                 });
 
                 let opened_entries: Vec<_> = task
                     .await
                     .with_context(|| "failed to copy external paths")?;
-                this.update(cx, |this, cx| {
+                this.update_in(cx, |this, window, cx| {
+                    let mut did_open = false;
                     if open_file_after_drop && !opened_entries.is_empty() {
                         let settings = ProjectPanelSettings::get_global(cx);
                         if settings.auto_open.should_open_on_drop() {
                             this.open_entry(opened_entries[0], true, false, cx);
+                            did_open = true;
+                        }
+                    }
+
+                    if !did_open {
+                        let new_selection = opened_entries
+                            .last()
+                            .map(|&entry_id| (worktree_id, entry_id));
+                        for &entry_id in &opened_entries {
+                            this.expand_entry(worktree_id, entry_id, cx);
                         }
+                        this.marked_entries.clear();
+                        this.update_visible_entries(new_selection, false, false, window, cx);
                     }
                 })
             }

crates/project_panel/src/project_panel_tests.rs 🔗

@@ -1956,6 +1956,117 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext)
     );
 }
 
+#[gpui::test]
+async fn test_paste_external_paths(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    set_auto_open_settings(
+        cx,
+        ProjectPanelAutoOpenSettings {
+            on_drop: Some(false),
+            ..Default::default()
+        },
+    );
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "subdir": {}
+        }),
+    )
+    .await;
+
+    fs.insert_tree(
+        path!("/external"),
+        json!({
+            "new_file.rs": "fn main() {}"
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/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, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    cx.write_to_clipboard(ClipboardItem {
+        entries: vec![GpuiClipboardEntry::ExternalPaths(ExternalPaths(
+            smallvec::smallvec![PathBuf::from(path!("/external/new_file.rs"))],
+        ))],
+    });
+
+    select_path(&panel, "root/subdir", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.paste(&Default::default(), window, cx);
+    });
+    cx.executor().run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&panel, 0..50, cx),
+        &[
+            "v root",
+            "    v subdir",
+            "          new_file.rs  <== selected",
+        ],
+    );
+}
+
+#[gpui::test]
+async fn test_copy_and_cut_write_to_system_clipboard(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/root"),
+        json!({
+            "file_a.txt": "",
+            "file_b.txt": ""
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/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, |mw, _| mw.workspace().clone())
+        .unwrap();
+    let cx = &mut VisualTestContext::from_window(window.into(), cx);
+    let panel = workspace.update_in(cx, ProjectPanel::new);
+    cx.run_until_parked();
+
+    select_path(&panel, "root/file_a.txt", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.copy(&Default::default(), window, cx);
+    });
+
+    let clipboard = cx
+        .read_from_clipboard()
+        .expect("clipboard should have content after copy");
+    let text = clipboard.text().expect("clipboard should contain text");
+    assert!(
+        text.contains("file_a.txt"),
+        "System clipboard should contain the copied file path, got: {text}"
+    );
+
+    select_path(&panel, "root/file_b.txt", cx);
+    panel.update_in(cx, |panel, window, cx| {
+        panel.cut(&Default::default(), window, cx);
+    });
+
+    let clipboard = cx
+        .read_from_clipboard()
+        .expect("clipboard should have content after cut");
+    let text = clipboard.text().expect("clipboard should contain text");
+    assert!(
+        text.contains("file_b.txt"),
+        "System clipboard should contain the cut file path, got: {text}"
+    );
+}
+
 #[gpui::test]
 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
     init_test_with_editor(cx);