Skip to content

Commit fe92848

Browse files
mxvshclaude
andcommitted
Add cross-window file/folder copy-paste support via system clipboard
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1277f32 commit fe92848

File tree

1 file changed

+99
-8
lines changed

1 file changed

+99
-8
lines changed

crates/project_panel/src/project_panel.rs

Lines changed: 99 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,6 +1008,7 @@ impl ProjectPanel {
10081008
&& (cfg!(target_os = "windows")
10091009
|| (settings.hide_root && visible_worktrees_count == 1));
10101010
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
1011+
let has_clipboard = self.has_clipboard_content(cx);
10111012

10121013
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
10131014
menu.context(self.focus_handle.clone()).map(|menu| {
@@ -1047,9 +1048,8 @@ impl ProjectPanel {
10471048
.action("Cut", Box::new(Cut))
10481049
.action("Copy", Box::new(Copy))
10491050
.action("Duplicate", Box::new(Duplicate))
1050-
// TODO: Paste should always be visible, cbut disabled when clipboard is empty
10511051
.action_disabled_when(
1052-
self.clipboard.as_ref().is_none(),
1052+
!has_clipboard,
10531053
"Paste",
10541054
Box::new(Paste),
10551055
)
@@ -2590,19 +2590,68 @@ impl ProjectPanel {
25902590
fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
25912591
let entries = self.disjoint_entries(cx);
25922592
if !entries.is_empty() {
2593-
self.clipboard = Some(ClipboardEntry::Cut(entries));
2593+
self.clipboard = Some(ClipboardEntry::Cut(entries.clone()));
2594+
self.write_entries_to_system_clipboard(&entries, true, cx);
25942595
cx.notify();
25952596
}
25962597
}
25972598

25982599
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
25992600
let entries = self.disjoint_entries(cx);
26002601
if !entries.is_empty() {
2601-
self.clipboard = Some(ClipboardEntry::Copied(entries));
2602+
self.clipboard = Some(ClipboardEntry::Copied(entries.clone()));
2603+
self.write_entries_to_system_clipboard(&entries, false, cx);
26022604
cx.notify();
26032605
}
26042606
}
26052607

2608+
fn write_entries_to_system_clipboard(
2609+
&self,
2610+
entries: &BTreeSet<SelectedEntry>,
2611+
is_cut: bool,
2612+
cx: &Context<Self>,
2613+
) {
2614+
let project = self.project.read(cx);
2615+
let file_paths = entries
2616+
.iter()
2617+
.filter_map(|entry| {
2618+
let entry_path = project.path_for_entry(entry.entry_id, cx)?.path;
2619+
let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
2620+
Some(worktree.read(cx).absolutize(&entry_path))
2621+
})
2622+
.map(|path| path.to_string_lossy().to_string())
2623+
.collect::<Vec<_>>();
2624+
2625+
if !file_paths.is_empty() {
2626+
let operation = if is_cut { "CUT" } else { "COPY" };
2627+
let clipboard_text = format!("ZED_CLIPBOARD:{}\n{}", operation, file_paths.join("\n"));
2628+
cx.write_to_clipboard(ClipboardItem::new_string(clipboard_text));
2629+
}
2630+
}
2631+
2632+
fn has_clipboard_content(&self, cx: &Context<Self>) -> bool {
2633+
// Check local clipboard first (for performance)
2634+
if self.clipboard.is_some() {
2635+
return true;
2636+
}
2637+
2638+
// Check system clipboard for Zed clipboard data or file paths
2639+
if let Some(clipboard_item) = cx.read_from_clipboard() {
2640+
if let Some(text) = clipboard_item.text() {
2641+
// Check if it's Zed clipboard data
2642+
if text.starts_with("ZED_CLIPBOARD:") {
2643+
return true;
2644+
}
2645+
// Check if it contains file paths (newline-separated paths)
2646+
if text.lines().any(|line| Path::new(line).exists()) {
2647+
return true;
2648+
}
2649+
}
2650+
}
2651+
2652+
false
2653+
}
2654+
26062655
fn create_paste_path(
26072656
&self,
26082657
source: &SelectedEntry,
@@ -2659,14 +2708,56 @@ impl ProjectPanel {
26592708
}
26602709

26612710
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
2711+
// Try local clipboard first, then system clipboard
2712+
let clipboard_clone = self.clipboard.clone();
2713+
2714+
if let Some(clipboard_entries) = clipboard_clone.as_ref().filter(|clipboard| !clipboard.items().is_empty()) {
2715+
self.paste_from_local_clipboard(clipboard_entries, window, cx);
2716+
} else if let Some((file_paths, _is_cut)) = self.parse_system_clipboard(cx) {
2717+
if let Some(entry_id) = self.selected_entry_handle(cx).map(|(_, e)| e.id) {
2718+
self.drop_external_files(&file_paths.iter().map(PathBuf::from).collect::<Vec<_>>(), entry_id, window, cx);
2719+
}
2720+
}
2721+
}
2722+
2723+
fn parse_system_clipboard(&self, cx: &Context<Self>) -> Option<(Vec<String>, bool)> {
2724+
let clipboard_item = cx.read_from_clipboard()?;
2725+
let text = clipboard_item.text()?;
2726+
2727+
// Check if it's Zed clipboard data
2728+
if let Some(content) = text.strip_prefix("ZED_CLIPBOARD:") {
2729+
let mut lines = content.lines();
2730+
let operation = lines.next()?;
2731+
let is_cut = operation == "CUT";
2732+
let file_paths: Vec<String> = lines.map(|s| s.to_string()).collect();
2733+
if !file_paths.is_empty() {
2734+
return Some((file_paths, is_cut));
2735+
}
2736+
} else {
2737+
// Try to parse as plain file paths
2738+
let file_paths: Vec<String> = text
2739+
.lines()
2740+
.filter(|line| !line.trim().is_empty() && Path::new(line).exists())
2741+
.map(|s| s.to_string())
2742+
.collect();
2743+
if !file_paths.is_empty() {
2744+
return Some((file_paths, false));
2745+
}
2746+
}
2747+
2748+
None
2749+
}
2750+
2751+
fn paste_from_local_clipboard(
2752+
&mut self,
2753+
clipboard_entries: &ClipboardEntry,
2754+
window: &mut Window,
2755+
cx: &mut Context<Self>,
2756+
) {
26622757
maybe!({
26632758
let (worktree, entry) = self.selected_entry_handle(cx)?;
26642759
let entry = entry.clone();
26652760
let worktree_id = worktree.read(cx).id();
2666-
let clipboard_entries = self
2667-
.clipboard
2668-
.as_ref()
2669-
.filter(|clipboard| !clipboard.items().is_empty())?;
26702761

26712762
enum PasteTask {
26722763
Rename(Task<Result<CreatedEntry>>),

0 commit comments

Comments
 (0)