From 25c61992194d29e191fb9c132388e3af183f0812 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Thu, 13 Aug 2026 05:55:22 -0400 Subject: [PATCH 1/3] feat(project_panel): emit Copy contents from file menus Co-authored-by: Cursor --- crates/project_panel/src/project_panel.rs | 41 ++++++++++++++++++ .../project_panel/src/project_panel_tests.rs | 42 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index a5702f73b000e5..30ec5545db8de4 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -392,6 +392,8 @@ actions!( NewFile, /// Copies the selected file or directory. Copy, + /// Copies the selected file's UTF-8 contents to the clipboard. + CopyFileContents, /// Duplicates the selected file or directory. Duplicate, /// Reveals the selected item in the system file manager. @@ -633,6 +635,9 @@ pub enum Event { split_direction: Option, }, Focus, + CopyFileContents { + paths: Vec, + }, } struct DraggedProjectEntryView { @@ -1230,6 +1235,9 @@ impl ProjectPanel { "Copy Relative Path", Box::new(zed_actions::workspace::CopyRelativePath), ) + .when(!is_dir, |menu| { + menu.action("Copy contents", Box::new(CopyFileContents)) + }) .when(has_git_repo, |menu| { menu.separator() .when(!is_dir && self.has_git_changes(entry_id), |menu| { @@ -3606,6 +3614,38 @@ impl ProjectPanel { } } + fn copy_file_contents( + &mut self, + _: &CopyFileContents, + _: &mut Window, + cx: &mut Context, + ) { + let paths = self.file_content_paths_for_copy(cx); + if !paths.is_empty() { + cx.emit(Event::CopyFileContents { paths }); + } + } + + fn file_content_paths_for_copy(&self, cx: &App) -> Vec { + let project = self.project.read(cx); + self.effective_entries() + .into_iter() + .filter_map(|entry| { + let worktree = project.worktree_for_id(entry.worktree_id, cx)?.read(cx); + if worktree.entry_for_id(entry.entry_id)?.is_dir() { + return None; + } + Some( + project + .path_for_entry(entry.entry_id, cx)? + .path + .as_std_path() + .to_path_buf(), + ) + }) + .collect() + } + fn reveal_in_finder( &mut self, _: &RevealInFileManager, @@ -6890,6 +6930,7 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::copy_path)) .on_action(cx.listener(Self::copy_relative_path)) + .on_action(cx.listener(Self::copy_file_contents)) .on_action(cx.listener(Self::new_search_in_directory)) .on_action(cx.listener(Self::unfold_directory)) .on_action(cx.listener(Self::fold_directory)) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index edd3ddb57b938e..df3e6600524505 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -187,6 +187,48 @@ async fn test_opening_file(cx: &mut gpui::TestAppContext) { ensure_single_file_is_opened(&workspace, "test/second.rs", cx); } +#[gpui::test] +async fn test_copy_file_contents_skips_directories(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/src"), + json!({ + "test": { + "first.rs": "// First Rust file", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/src").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(); + + toggle_expand_dir(&panel, "src/test", cx); + select_path(&panel, "src/test/first.rs", cx); + panel.update(cx, |panel, cx| { + assert_eq!( + panel.file_content_paths_for_copy(cx), + vec![PathBuf::from("test/first.rs")] + ); + }); + + select_path(&panel, "src/test", cx); + panel.update(cx, |panel, cx| { + assert!( + panel.file_content_paths_for_copy(cx).is_empty(), + "directories must not offer copyable file contents" + ); + }); +} + #[gpui::test] async fn test_file_history_action_uses_focused_project_panel_selection( cx: &mut gpui::TestAppContext, From 0086b7755f56f6f7a2b3fae6497b79acdbc68eee Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Thu, 13 Aug 2026 06:28:13 -0400 Subject: [PATCH 2/3] test(worktree): expand gitignored UnloadedDir without load_file Co-authored-by: Cursor --- .../tests/integration/worktree_tests.rs | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index 6a4370539afc1e..74fa2131d2ba97 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -2739,6 +2739,80 @@ async fn test_create_file_in_expanded_gitignored_dir(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_expand_gitignored_unloaded_dir_without_load_file(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + ".gitignore": "ignored_dir\n", + "ignored_dir": { + "existing_file.txt": "existing content", + "nested": { + "inner.txt": "inner" + } + }, + }), + ) + .await; + + let tree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!(ignored_dir.kind, EntryKind::UnloadedDir); + assert!( + tree.entry_for_path(rel_path("ignored_dir/existing_file.txt")) + .is_none() + ); + }); + + tree.update(cx, |tree, cx| { + let entry_id = tree.entry_for_path(rel_path("ignored_dir")).unwrap().id; + tree.expand_entry(entry_id, cx) + }) + .unwrap() + .await + .unwrap(); + + tree.read_with(cx, |tree, _| { + let ignored_dir = tree.entry_for_path(rel_path("ignored_dir")).unwrap(); + assert!(ignored_dir.is_ignored); + assert_eq!(ignored_dir.kind, EntryKind::Dir); + + let child = tree + .entry_for_path(rel_path("ignored_dir/existing_file.txt")) + .expect("expanding UnloadedDir must list ignored children without load_file"); + assert!(child.is_ignored); + + let nested = tree + .entry_for_path(rel_path("ignored_dir/nested")) + .expect("one-level expand lists nested ignored dirs"); + assert!(nested.is_ignored); + assert_eq!(nested.kind, EntryKind::UnloadedDir); + assert!( + tree.entry_for_path(rel_path("ignored_dir/nested/inner.txt")) + .is_none(), + "nested ignored dirs must stay unloaded until expanded" + ); + }); +} + #[gpui::test] async fn test_fs_event_for_gitignored_dir_does_not_lose_contents(cx: &mut TestAppContext) { // Tests the behavior of our worktree refresh when a directory modification for a gitignored directory From c6f6242602419d6f0a58928fcaf96276272a4f76 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Fri, 14 Aug 2026 10:20:45 -0400 Subject: [PATCH 3/3] project_panel: Carry worktree identity in CopyFileContents Review feedback on the copy-contents action. `Event::CopyFileContents` carried worktree-relative `PathBuf`s, which drop the worktree. In a multi-root workspace two worktrees can both hold `src/lib.rs`, so a consumer resolving the bare relative path could read a different file than the one selected. The event and `file_content_paths_for_copy` now carry `ProjectPath`, matching how `OpenedEntry` hands the consumer an id to resolve rather than a pre-resolved path. The action only emits; the read and clipboard write belong to the embedding host, so standalone Zed had a menu entry that did nothing. `ProjectPanelContextMenuPolicy` gains `show_host_file_content_actions`, off in `full()` and on in `embedded()`, so the entry appears only where something handles it. Also title-case the menu label to match its neighbours, drop "UTF-8" from the action doc since the host owns that guarantee, and apply rustfmt. The panel test now dispatches the action and asserts the emitted payload, including that a directory selection emits nothing. Release Notes: - N/A --- crates/project_panel/src/project_panel.rs | 39 +++++---- .../project_panel/src/project_panel_tests.rs | 86 +++++++++++++++++-- 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 30ec5545db8de4..94e1f29ade560b 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -146,6 +146,11 @@ pub struct ProjectPanelContextMenuPolicy { pub show_compare_actions: bool, pub show_git_actions: bool, pub show_workspace_folder_actions: bool, + /// `CopyFileContents` only emits an event; the read and the clipboard write + /// belong to the embedding host. Standalone Zed has no such host, so the + /// entry stays hidden there rather than presenting an action that does + /// nothing. + pub show_host_file_content_actions: bool, } impl ProjectPanelContextMenuPolicy { @@ -157,6 +162,7 @@ impl ProjectPanelContextMenuPolicy { show_compare_actions: true, show_git_actions: true, show_workspace_folder_actions: true, + show_host_file_content_actions: false, } } @@ -168,6 +174,7 @@ impl ProjectPanelContextMenuPolicy { show_compare_actions: false, show_git_actions: false, show_workspace_folder_actions: false, + show_host_file_content_actions: true, } } } @@ -392,7 +399,7 @@ actions!( NewFile, /// Copies the selected file or directory. Copy, - /// Copies the selected file's UTF-8 contents to the clipboard. + /// Copies the selected file's contents to the clipboard. CopyFileContents, /// Duplicates the selected file or directory. Duplicate, @@ -635,8 +642,12 @@ pub enum Event { split_direction: Option, }, Focus, + /// Emitted for the embedder to read and copy. Carries `ProjectPath` rather + /// than a bare relative path so the worktree stays identifiable: in a + /// multi-root workspace two worktrees can both hold `src/lib.rs`, and a + /// relative path alone would let a consumer resolve the wrong file. CopyFileContents { - paths: Vec, + paths: Vec, }, } @@ -1235,9 +1246,10 @@ impl ProjectPanel { "Copy Relative Path", Box::new(zed_actions::workspace::CopyRelativePath), ) - .when(!is_dir, |menu| { - menu.action("Copy contents", Box::new(CopyFileContents)) - }) + .when( + !is_dir && self.context_menu_policy.show_host_file_content_actions, + |menu| menu.action("Copy Contents", Box::new(CopyFileContents)), + ) .when(has_git_repo, |menu| { menu.separator() .when(!is_dir && self.has_git_changes(entry_id), |menu| { @@ -3614,19 +3626,14 @@ impl ProjectPanel { } } - fn copy_file_contents( - &mut self, - _: &CopyFileContents, - _: &mut Window, - cx: &mut Context, - ) { + fn copy_file_contents(&mut self, _: &CopyFileContents, _: &mut Window, cx: &mut Context) { let paths = self.file_content_paths_for_copy(cx); if !paths.is_empty() { cx.emit(Event::CopyFileContents { paths }); } } - fn file_content_paths_for_copy(&self, cx: &App) -> Vec { + fn file_content_paths_for_copy(&self, cx: &App) -> Vec { let project = self.project.read(cx); self.effective_entries() .into_iter() @@ -3635,13 +3642,7 @@ impl ProjectPanel { if worktree.entry_for_id(entry.entry_id)?.is_dir() { return None; } - Some( - project - .path_for_entry(entry.entry_id, cx)? - .path - .as_std_path() - .to_path_buf(), - ) + project.path_for_entry(entry.entry_id, cx) }) .collect() } diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index df3e6600524505..1ce3fa0f8b435a 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -13,7 +13,9 @@ use project::{FakeFs, ProjectPath}; use serde_json::json; use settings::{ProjectPanelAutoOpenSettings, SettingsStore}; use smallvec::smallvec; +use std::cell::RefCell; use std::path::{Path, PathBuf}; +use std::rc::Rc; use util::{path, paths::PathStyle, rel_path::rel_path}; use workspace::{ AppState, ItemHandle, MultiWorkspace, Pane, Workspace, @@ -208,16 +210,22 @@ async fn test_copy_file_contents_skips_directories(cx: &mut gpui::TestAppContext .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); + // Added to the workspace so the panel renders and its `on_action` handler + // is reachable by a dispatched action, not just by a direct method call. + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); toggle_expand_dir(&panel, "src/test", cx); select_path(&panel, "src/test/first.rs", cx); - panel.update(cx, |panel, cx| { - assert_eq!( - panel.file_content_paths_for_copy(cx), - vec![PathBuf::from("test/first.rs")] - ); + let worktree_id = panel.update(cx, |panel, cx| { + let paths = panel.file_content_paths_for_copy(cx); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].path.as_std_path(), Path::new("test/first.rs")); + paths[0].worktree_id }); select_path(&panel, "src/test", cx); @@ -227,6 +235,72 @@ async fn test_copy_file_contents_skips_directories(cx: &mut gpui::TestAppContext "directories must not offer copyable file contents" ); }); + + // The action is the user-facing surface: it must emit the event, carrying + // the worktree so a consumer cannot resolve a same-named file from another + // root. The panel deliberately does not touch the clipboard itself — the + // embedder reads the files under its own size and sensitive-path guards. + let events: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let observed = events.clone(); + cx.update(|_, cx| { + cx.subscribe(&panel, move |_, event: &Event, _| { + if let Event::CopyFileContents { paths } = event { + observed.borrow_mut().push(paths.clone()); + } + }) + .detach(); + }); + + select_path(&panel, "src/test/first.rs", cx); + cx.update(|window, cx| { + panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + window.dispatch_action(Box::new(CopyFileContents), cx); + }); + cx.run_until_parked(); + + let emitted = events.borrow().clone(); + assert_eq!(emitted.len(), 1, "the action must emit exactly one event"); + assert_eq!(emitted[0].len(), 1); + assert_eq!(emitted[0][0].worktree_id, worktree_id); + assert_eq!(emitted[0][0].path.as_std_path(), Path::new("test/first.rs")); + + // A directory selection must not emit at all. + events.borrow_mut().clear(); + select_path(&panel, "src/test", cx); + cx.update(|window, cx| { + panel.update(cx, |panel, cx| { + panel.focus_handle(cx).focus(window, cx); + }); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + window.dispatch_action(Box::new(CopyFileContents), cx); + }); + cx.run_until_parked(); + assert!( + events.borrow().is_empty(), + "a directory selection must not emit a copy-contents event" + ); +} + +#[test] +fn test_copy_contents_menu_entry_is_host_only() { + // Nothing in standalone Zed consumes `Event::CopyFileContents`, so the menu + // entry must not be offered there; only an embedding host that performs the + // read and the clipboard write shows it. + assert!( + !ProjectPanelContextMenuPolicy::full().show_host_file_content_actions, + "standalone Zed must not offer an action nothing handles" + ); + assert!( + ProjectPanelContextMenuPolicy::embedded().show_host_file_content_actions, + "the embedding host performs the copy, so it offers the entry" + ); } #[gpui::test]