project_panel: Add file comparison function, supports selecting files for comparison (#35255)

Closes https://github.com/zed-industries/zed/discussions/35010
Closes https://github.com/zed-industries/zed/issues/17100
Closes https://github.com/zed-industries/zed/issues/4523

Release Notes:

- Added file comparison function in project panel

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
mcwindy 2025-08-08 02:34:12 +08:00 committed by GitHub
parent 53b69d29c5
commit e8db429d24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 295 additions and 45 deletions

1
Cargo.lock generated
View file

@ -12636,6 +12636,7 @@ dependencies = [
"editor",
"file_icons",
"git",
"git_ui",
"gpui",
"indexmap",
"language",

View file

@ -848,6 +848,7 @@
"ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-ctrl-r": "project_panel::RevealInFileManager",
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"shift-find": "project_panel::NewSearchInDirectory",
"ctrl-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",

View file

@ -907,6 +907,7 @@
"cmd-delete": ["project_panel::Delete", { "skip_prompt": false }],
"alt-cmd-r": "project_panel::RevealInFileManager",
"ctrl-shift-enter": "project_panel::OpenWithSystem",
"alt-d": "project_panel::CompareMarkedFiles",
"cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }],
"cmd-alt-shift-f": "project_panel::NewSearchInDirectory",
"shift-down": "menu::SelectNext",

View file

@ -813,6 +813,7 @@
"p": "project_panel::Open",
"x": "project_panel::RevealInFileManager",
"s": "project_panel::OpenWithSystem",
"z d": "project_panel::CompareMarkedFiles",
"] c": "project_panel::SelectNextGitEntry",
"[ c": "project_panel::SelectPrevGitEntry",
"] d": "project_panel::SelectNextDiagnostic",

View file

@ -19,6 +19,7 @@ command_palette_hooks.workspace = true
db.workspace = true
editor.workspace = true
file_icons.workspace = true
git_ui.workspace = true
indexmap.workspace = true
git.workspace = true
gpui.workspace = true

View file

@ -16,6 +16,7 @@ use editor::{
};
use file_icons::FileIcons;
use git::status::GitSummary;
use git_ui::file_diff_view::FileDiffView;
use gpui::{
Action, AnyElement, App, ArcCow, AsyncWindowContext, Bounds, ClipboardItem, Context,
CursorStyle, DismissEvent, Div, DragMoveEvent, Entity, EventEmitter, ExternalPaths,
@ -93,7 +94,7 @@ pub struct ProjectPanel {
unfolded_dir_ids: HashSet<ProjectEntryId>,
// Currently selected leaf entry (see auto-folding for a definition of that) in a file tree
selection: Option<SelectedEntry>,
marked_entries: BTreeSet<SelectedEntry>,
marked_entries: Vec<SelectedEntry>,
context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
edit_state: Option<EditState>,
filename_editor: Entity<Editor>,
@ -280,6 +281,8 @@ actions!(
SelectNextDirectory,
/// Selects the previous directory.
SelectPrevDirectory,
/// Opens a diff view to compare two marked files.
CompareMarkedFiles,
]
);
@ -376,7 +379,7 @@ struct DraggedProjectEntryView {
selection: SelectedEntry,
details: EntryDetails,
click_offset: Point<Pixels>,
selections: Arc<BTreeSet<SelectedEntry>>,
selections: Arc<[SelectedEntry]>,
}
struct ItemColors {
@ -442,7 +445,15 @@ impl ProjectPanel {
}
}
project::Event::ActiveEntryChanged(None) => {
this.marked_entries.clear();
let is_active_item_file_diff_view = this
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_item(cx))
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
.unwrap_or(false);
if !is_active_item_file_diff_view {
this.marked_entries.clear();
}
}
project::Event::RevealInProjectPanel(entry_id) => {
if let Some(()) = this
@ -676,7 +687,7 @@ impl ProjectPanel {
project_panel.update(cx, |project_panel, _| {
let entry = SelectedEntry { worktree_id, entry_id };
project_panel.marked_entries.clear();
project_panel.marked_entries.insert(entry);
project_panel.marked_entries.push(entry);
project_panel.selection = Some(entry);
});
if !focus_opened_item {
@ -887,6 +898,7 @@ impl ProjectPanel {
let should_hide_rename = is_root
&& (cfg!(target_os = "windows")
|| (settings.hide_root && visible_worktrees_count == 1));
let should_show_compare = !is_dir && self.file_abs_paths_to_diff(cx).is_some();
let context_menu = ContextMenu::build(window, cx, |menu, _, _| {
menu.context(self.focus_handle.clone()).map(|menu| {
@ -918,6 +930,10 @@ impl ProjectPanel {
.when(is_foldable, |menu| {
menu.action("Fold Directory", Box::new(FoldDirectory))
})
.when(should_show_compare, |menu| {
menu.separator()
.action("Compare marked files", Box::new(CompareMarkedFiles))
})
.separator()
.action("Cut", Box::new(Cut))
.action("Copy", Box::new(Copy))
@ -1262,7 +1278,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
cx.notify();
@ -2007,7 +2023,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
@ -2244,7 +2260,7 @@ impl ProjectPanel {
};
self.selection = Some(selection);
if window.modifiers().shift {
self.marked_entries.insert(selection);
self.marked_entries.push(selection);
}
self.autoscroll(cx);
cx.notify();
@ -2572,6 +2588,43 @@ impl ProjectPanel {
}
}
fn file_abs_paths_to_diff(&self, cx: &Context<Self>) -> Option<(PathBuf, PathBuf)> {
let mut selections_abs_path = self
.marked_entries
.iter()
.filter_map(|entry| {
let project = self.project.read(cx);
let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
let entry = worktree.read(cx).entry_for_id(entry.entry_id)?;
if !entry.is_file() {
return None;
}
worktree.read(cx).absolutize(&entry.path).ok()
})
.rev();
let last_path = selections_abs_path.next()?;
let previous_to_last = selections_abs_path.next()?;
Some((previous_to_last, last_path))
}
fn compare_marked_files(
&mut self,
_: &CompareMarkedFiles,
window: &mut Window,
cx: &mut Context<Self>,
) {
let selected_files = self.file_abs_paths_to_diff(cx);
if let Some((file_path1, file_path2)) = selected_files {
self.workspace
.update(cx, |workspace, cx| {
FileDiffView::open(file_path1, file_path2, workspace, window, cx)
.detach_and_log_err(cx);
})
.ok();
}
}
fn open_system(&mut self, _: &OpenWithSystem, _: &mut Window, cx: &mut Context<Self>) {
if let Some((worktree, entry)) = self.selected_entry(cx) {
let abs_path = worktree.abs_path().join(&entry.path);
@ -3914,11 +3967,9 @@ impl ProjectPanel {
let depth = details.depth;
let worktree_id = details.worktree_id;
let selections = Arc::new(self.marked_entries.clone());
let dragged_selection = DraggedSelection {
active_selection: selection,
marked_selections: selections,
marked_selections: Arc::from(self.marked_entries.clone()),
};
let bg_color = if is_marked {
@ -4089,7 +4140,7 @@ impl ProjectPanel {
});
if drag_state.items().count() == 1 {
this.marked_entries.clear();
this.marked_entries.insert(drag_state.active_selection);
this.marked_entries.push(drag_state.active_selection);
}
this.hover_expand_task.take();
@ -4156,65 +4207,69 @@ impl ProjectPanel {
}),
)
.on_click(
cx.listener(move |this, event: &gpui::ClickEvent, window, cx| {
cx.listener(move |project_panel, event: &gpui::ClickEvent, window, cx| {
if event.is_right_click() || event.first_focus()
|| show_editor
{
return;
}
if event.standard_click() {
this.mouse_down = false;
project_panel.mouse_down = false;
}
cx.stop_propagation();
if let Some(selection) = this.selection.filter(|_| event.modifiers().shift) {
let current_selection = this.index_for_selection(selection);
if let Some(selection) = project_panel.selection.filter(|_| event.modifiers().shift) {
let current_selection = project_panel.index_for_selection(selection);
let clicked_entry = SelectedEntry {
entry_id,
worktree_id,
};
let target_selection = this.index_for_selection(clicked_entry);
let target_selection = project_panel.index_for_selection(clicked_entry);
if let Some(((_, _, source_index), (_, _, target_index))) =
current_selection.zip(target_selection)
{
let range_start = source_index.min(target_index);
let range_end = source_index.max(target_index) + 1;
let mut new_selections = BTreeSet::new();
this.for_each_visible_entry(
let mut new_selections = Vec::new();
project_panel.for_each_visible_entry(
range_start..range_end,
window,
cx,
|entry_id, details, _, _| {
new_selections.insert(SelectedEntry {
new_selections.push(SelectedEntry {
entry_id,
worktree_id: details.worktree_id,
});
},
);
this.marked_entries = this
.marked_entries
.union(&new_selections)
.cloned()
.collect();
for selection in &new_selections {
if !project_panel.marked_entries.contains(selection) {
project_panel.marked_entries.push(*selection);
}
}
this.selection = Some(clicked_entry);
this.marked_entries.insert(clicked_entry);
project_panel.selection = Some(clicked_entry);
if !project_panel.marked_entries.contains(&clicked_entry) {
project_panel.marked_entries.push(clicked_entry);
}
}
} else if event.modifiers().secondary() {
if event.click_count() > 1 {
this.split_entry(entry_id, cx);
project_panel.split_entry(entry_id, cx);
} else {
this.selection = Some(selection);
if !this.marked_entries.insert(selection) {
this.marked_entries.remove(&selection);
project_panel.selection = Some(selection);
if let Some(position) = project_panel.marked_entries.iter().position(|e| *e == selection) {
project_panel.marked_entries.remove(position);
} else {
project_panel.marked_entries.push(selection);
}
}
} else if kind.is_dir() {
this.marked_entries.clear();
project_panel.marked_entries.clear();
if is_sticky {
if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) {
this.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
if let Some((_, _, index)) = project_panel.index_for_entry(entry_id, worktree_id) {
project_panel.scroll_handle.scroll_to_item_with_offset(index, ScrollStrategy::Top, sticky_index.unwrap_or(0));
cx.notify();
// move down by 1px so that clicked item
// don't count as sticky anymore
@ -4230,16 +4285,16 @@ impl ProjectPanel {
}
}
if event.modifiers().alt {
this.toggle_expand_all(entry_id, window, cx);
project_panel.toggle_expand_all(entry_id, window, cx);
} else {
this.toggle_expanded(entry_id, window, cx);
project_panel.toggle_expanded(entry_id, window, cx);
}
} else {
let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
let click_count = event.click_count();
let focus_opened_item = !preview_tabs_enabled || click_count > 1;
let allow_preview = preview_tabs_enabled && click_count == 1;
this.open_entry(entry_id, focus_opened_item, allow_preview, cx);
project_panel.open_entry(entry_id, focus_opened_item, allow_preview, cx);
}
}),
)
@ -4810,12 +4865,21 @@ impl ProjectPanel {
{
anyhow::bail!("can't reveal an ignored entry in the project panel");
}
let is_active_item_file_diff_view = self
.workspace
.upgrade()
.and_then(|ws| ws.read(cx).active_item(cx))
.map(|item| item.act_as_type(TypeId::of::<FileDiffView>(), cx).is_some())
.unwrap_or(false);
if is_active_item_file_diff_view {
return Ok(());
}
let worktree_id = worktree.id();
self.expand_entry(worktree_id, entry_id, cx);
self.update_visible_entries(Some((worktree_id, entry_id)), cx);
self.marked_entries.clear();
self.marked_entries.insert(SelectedEntry {
self.marked_entries.push(SelectedEntry {
worktree_id,
entry_id,
});
@ -5170,6 +5234,7 @@ impl Render for ProjectPanel {
.on_action(cx.listener(Self::unfold_directory))
.on_action(cx.listener(Self::fold_directory))
.on_action(cx.listener(Self::remove_from_project))
.on_action(cx.listener(Self::compare_marked_files))
.when(!project.is_read_only(cx), |el| {
el.on_action(cx.listener(Self::new_file))
.on_action(cx.listener(Self::new_directory))

View file

@ -8,7 +8,7 @@ use settings::SettingsStore;
use std::path::{Path, PathBuf};
use util::path;
use workspace::{
AppState, Pane,
AppState, ItemHandle, Pane,
item::{Item, ProjectItem},
register_project_item,
};
@ -3068,7 +3068,7 @@ async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
panel.update(cx, |this, cx| {
let drag = DraggedSelection {
active_selection: this.selection.unwrap(),
marked_selections: Arc::new(this.marked_entries.clone()),
marked_selections: this.marked_entries.clone().into(),
};
let target_entry = this
.project
@ -5562,10 +5562,10 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([SelectedEntry {
marked_selections: Arc::new([SelectedEntry {
worktree_id,
entry_id: child_file.id,
}])),
}]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
@ -5604,7 +5604,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: child_file.id,
},
marked_selections: Arc::new(BTreeSet::from([
marked_selections: Arc::new([
SelectedEntry {
worktree_id,
entry_id: child_file.id,
@ -5613,7 +5613,7 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext)
worktree_id,
entry_id: sibling_file.id,
},
])),
]),
};
let result =
panel.highlight_entry_for_selection_drag(parent_dir, worktree, &dragged_selection, cx);
@ -5821,6 +5821,186 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) {
}
}
#[gpui::test]
async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"file1.txt": "content of file1",
"file2.txt": "content of file2",
"dir1": {
"file3.txt": "content of file3"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
let file1_path = path!("root/file1.txt");
let file2_path = path!("root/file2.txt");
select_path_with_mark(&panel, file1_path, cx);
select_path_with_mark(&panel, file2_path, cx);
panel.update_in(cx, |panel, window, cx| {
panel.compare_marked_files(&CompareMarkedFiles, window, cx);
});
cx.executor().run_until_parked();
workspace
.update(cx, |workspace, _, cx| {
let active_items = workspace
.panes()
.iter()
.filter_map(|pane| pane.read(cx).active_item())
.collect::<Vec<_>>();
assert_eq!(active_items.len(), 1);
let diff_view = active_items
.into_iter()
.next()
.unwrap()
.downcast::<FileDiffView>()
.expect("Open item should be an FileDiffView");
assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt");
assert_eq!(
diff_view.tab_tooltip_text(cx).unwrap(),
format!("{}{}", file1_path, file2_path)
);
})
.unwrap();
let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap();
let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap();
let worktree_id = panel.update(cx, |panel, cx| {
panel
.project
.read(cx)
.worktrees(cx)
.next()
.unwrap()
.read(cx)
.id()
});
let expected_entries = [
SelectedEntry {
worktree_id,
entry_id: file1_entry_id,
},
SelectedEntry {
worktree_id,
entry_id: file2_entry_id,
},
];
panel.update(cx, |panel, _cx| {
assert_eq!(
&panel.marked_entries, &expected_entries,
"Should keep marked entries after comparison"
);
});
panel.update(cx, |panel, cx| {
panel.project.update(cx, |_, cx| {
cx.emit(project::Event::RevealInProjectPanel(file2_entry_id))
})
});
panel.update(cx, |panel, _cx| {
assert_eq!(
&panel.marked_entries, &expected_entries,
"Marked entries should persist after focusing back on the project panel"
);
});
}
#[gpui::test]
async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx);
let fs = FakeFs::new(cx.executor().clone());
fs.insert_tree(
"/root",
json!({
"file1.txt": "content of file1",
"file2.txt": "content of file2",
"dir1": {},
"dir2": {
"file3.txt": "content of file3"
}
}),
)
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*workspace, cx);
let panel = workspace.update(cx, ProjectPanel::new).unwrap();
// Test 1: When only one file is selected, there should be no compare option
select_path(&panel, "root/file1.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert_eq!(
selected_files, None,
"Should not have compare option when only one file is selected"
);
// Test 2: When multiple files are selected, there should be a compare option
select_path_with_mark(&panel, "root/file1.txt", cx);
select_path_with_mark(&panel, "root/file2.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Should have files selected for comparison"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file1.txt")
&& file2.to_string_lossy().ends_with("file2.txt"),
"Should have file1.txt and file2.txt as the selected files when multi-selecting"
);
}
// Test 3: Selecting a directory shouldn't count as a comparable file
select_path_with_mark(&panel, "root/dir1", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Directory selection should not affect comparable files"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file1.txt")
&& file2.to_string_lossy().ends_with("file2.txt"),
"Selecting a directory should not affect the number of comparable files"
);
}
// Test 4: Selecting one more file
select_path_with_mark(&panel, "root/dir2/file3.txt", cx);
let selected_files = panel.update(cx, |panel, cx| panel.file_abs_paths_to_diff(cx));
assert!(
selected_files.is_some(),
"Directory selection should not affect comparable files"
);
if let Some((file1, file2)) = selected_files {
assert!(
file1.to_string_lossy().ends_with("file2.txt")
&& file2.to_string_lossy().ends_with("file3.txt"),
"Selecting a directory should not affect the number of comparable files"
);
}
}
fn select_path(panel: &Entity<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
let path = path.as_ref();
panel.update(cx, |panel, cx| {
@ -5855,7 +6035,7 @@ fn select_path_with_mark(
entry_id,
};
if !panel.marked_entries.contains(&entry) {
panel.marked_entries.insert(entry);
panel.marked_entries.push(entry);
}
panel.selection = Some(entry);
return;

View file

@ -62,7 +62,7 @@ pub struct SelectedEntry {
#[derive(Debug)]
pub struct DraggedSelection {
pub active_selection: SelectedEntry,
pub marked_selections: Arc<BTreeSet<SelectedEntry>>,
pub marked_selections: Arc<[SelectedEntry]>,
}
impl DraggedSelection {