From 1b3f9ac6f7a471a5c0fc33ce53047b31fb362945 Mon Sep 17 00:00:00 2001 From: arookieofc Date: Sat, 7 Nov 2026 03:59:29 +0800 Subject: [PATCH] fix(clipboard): write file names as the copied files' text flavor (#1878) (#1880) * fix(clipboard): write file names as the copied files' text flavor (#1878) Ctrl+C on project-view files now pastes the file names into editors and chat boxes, not the absolute paths. The file list itself (CF_HDROP / file URLs) still carries the full paths, so pasting into Explorer or a terminal that reads files keeps working as before. Paths without a file name (a drive root) keep their whole path. * test(clipboard): exercise the no-file-name fallback portably C:/ is a root only on Windows; the Unix and macOS runners read its file_name as "C:". Use the real filesystem root, which has no file name on any platform. --- crates/ide-clipboard/src/lib.rs | 48 ++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs index 8c6671297..1bf899a17 100644 --- a/crates/ide-clipboard/src/lib.rs +++ b/crates/ide-clipboard/src/lib.rs @@ -1,8 +1,8 @@ //! The file list on the OS clipboard: what Explorer copies when the user //! copies files, so the project view can paste them, and what the project view //! writes so Explorer can paste the files it copied (IDEA does both). The -//! written list comes with the paths as text, so a paste into an editor -//! inserts them instead of doing nothing. +//! written list comes with the file names as text, so a paste into an editor +//! inserts them instead of doing nothing (#1878). //! //! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL //! terminated list of absolute paths, wide when `fWide` is set. The data is set @@ -44,6 +44,21 @@ pub fn set_files(paths: &[PathBuf]) -> bool { imp::set_files(paths) } +/// The text flavor written beside the file list: the files' names, one per +/// line. An editor paste inserts names, not the absolute paths the file list +/// keeps for Explorer (#1878; IDEA's text flavor is names too). A path with no +/// file name (a root like `C:\`) keeps its whole path. +#[cfg(any(windows, target_os = "macos", test))] +fn names_text(paths: &[PathBuf], separator: &str) -> String { + paths + .iter() + .map(|p| { + p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.to_string_lossy().into_owned()) + }) + .collect::>() + .join(separator) +} + /// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code /// page survive. Empty paths are skipped. pub fn encode(paths: &[PathBuf]) -> Vec { @@ -108,7 +123,7 @@ pub fn decode(bytes: &[u8]) -> Vec { #[cfg(windows)] mod imp { - use super::{HEADER, decode, encode}; + use super::{HEADER, decode, encode, names_text}; use std::ffi::c_void; use std::path::PathBuf; use std::ptr; @@ -220,9 +235,9 @@ mod imp { if bytes.len() <= HEADER { return false; } - // The paths as text, newline-separated, so pasting into an editor - // inserts them (IDEA writes the list and the text together). - let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\r\n"); + // The file names as text: the file list keeps the absolute paths for + // Explorer, while an editor paste inserts the names (#1878). + let text = names_text(paths, "\r\n"); // SAFETY: the blocks are allocated, filled, handed to the clipboard and // freed only on the paths where the clipboard did not take ownership. unsafe { @@ -242,7 +257,7 @@ mod imp { return false; } // Replacing the list also drops the text the user copied before; - // the paths' own text is set back with it. + // the file names are set back with it. if EmptyClipboard() == 0 { CloseClipboard(); GlobalFree(memory); @@ -278,6 +293,8 @@ mod imp { use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL}; use std::path::PathBuf; + use super::names_text; + /// `NSURL` as the one class to read, limited to file URLs: a web link the /// user copied is not a file to paste. fn read_file_urls( @@ -338,13 +355,11 @@ mod imp { urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect(); let board = NSPasteboard::generalPasteboard(); // Replacing the list also drops the text the user copied before; - // the paths' own text is set back with it, so an editor paste - // inserts them (IDEA writes the list and the text together). + // the file names are set back as text, so an editor paste inserts + // names, not paths (#1878). board.clearContents(); let written = board.writeObjects(&NSArray::from_slice(&writers)); - let text = NSString::from_str( - &paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\n"), - ); + let text = NSString::from_str(&names_text(paths, "\n")); board.setString_forType(&text, unsafe { NSPasteboardTypeString }); written }) @@ -415,6 +430,15 @@ mod tests { assert_eq!(decode(&far), Vec::::new()); } + #[test] + fn the_text_flavor_is_the_file_names() { + assert_eq!(names_text(&paths(&["C:/work/project/a.txt", "C:/work/project/sub"]), "\r\n"), "a.txt\r\nsub"); + // A path with no file name (the filesystem root) keeps its whole path. + let root = PathBuf::from("/"); + assert!(root.file_name().is_none()); + assert_eq!(names_text(std::slice::from_ref(&root), "\n"), root.to_string_lossy()); + } + #[test] fn an_ansi_list_is_still_read_before_the_terminator() { let mut bytes = vec![0u8; HEADER];