Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions crates/project_panel/src/project_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -392,6 +399,8 @@ actions!(
NewFile,
/// Copies the selected file or directory.
Copy,
/// Copies the selected file's contents to the clipboard.
CopyFileContents,
/// Duplicates the selected file or directory.
Duplicate,
/// Reveals the selected item in the system file manager.
Expand Down Expand Up @@ -633,6 +642,13 @@ pub enum Event {
split_direction: Option<SplitDirection>,
},
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<ProjectPath>,
},
Comment on lines +649 to +651
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

struct DraggedProjectEntryView {
Expand Down Expand Up @@ -1230,6 +1246,10 @@ impl ProjectPanel {
"Copy Relative Path",
Box::new(zed_actions::workspace::CopyRelativePath),
)
.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| {
Expand Down Expand Up @@ -3606,6 +3626,27 @@ impl ProjectPanel {
}
}

fn copy_file_contents(&mut self, _: &CopyFileContents, _: &mut Window, cx: &mut Context<Self>) {
let paths = self.file_content_paths_for_copy(cx);
if !paths.is_empty() {
cx.emit(Event::CopyFileContents { paths });
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}

fn file_content_paths_for_copy(&self, cx: &App) -> Vec<ProjectPath> {
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;
}
project.path_for_entry(entry.entry_id, cx)
})
.collect()
}

fn reveal_in_finder(
&mut self,
_: &RevealInFileManager,
Expand Down Expand Up @@ -6890,6 +6931,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))
Expand Down
116 changes: 116 additions & 0 deletions crates/project_panel/src/project_panel_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -187,6 +189,120 @@ 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);
// 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);
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);
panel.update(cx, |panel, cx| {
assert!(
panel.file_content_paths_for_copy(cx).is_empty(),
"directories must not offer copyable file contents"
);
});
Comment on lines +192 to +237

// 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<RefCell<Vec<Vec<ProjectPath>>>> = 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new action test can pass even if mixed selections still emit directory paths. Select a file and directory together with select_path_with_mark, then assert the event contains only the file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/project_panel/src/project_panel_tests.rs, line 254:

<comment>The new action test can pass even if mixed selections still emit directory paths. Select a file and directory together with `select_path_with_mark`, then assert the event contains only the file.</comment>

<file context>
@@ -227,6 +235,72 @@ async fn test_copy_file_contents_skips_directories(cx: &mut gpui::TestAppContext
+        .detach();
+    });
+
+    select_path(&panel, "src/test/first.rs", cx);
+    cx.update(|window, cx| {
+        panel.update(cx, |panel, cx| {
</file context>

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]
async fn test_file_history_action_uses_focused_project_panel_selection(
cx: &mut gpui::TestAppContext,
Expand Down
74 changes: 74 additions & 0 deletions crates/worktree/tests/integration/worktree_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading