Cargo.lock 🔗
@@ -12636,6 +12636,7 @@ dependencies = [
"editor",
"file_icons",
"git",
+ "git_ui",
"gpui",
"indexmap",
"language",
mcwindy and Kirill Bulatov created
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>
Cargo.lock | 1
assets/keymaps/default-linux.json | 1
assets/keymaps/default-macos.json | 1
assets/keymaps/vim.json | 1
crates/project_panel/Cargo.toml | 1
crates/project_panel/src/project_panel.rs | 139 +++++++++---
crates/project_panel/src/project_panel_tests.rs | 194 ++++++++++++++++++
crates/workspace/src/pane.rs | 2
8 files changed, 295 insertions(+), 45 deletions(-)
@@ -12636,6 +12636,7 @@ dependencies = [
"editor",
"file_icons",
"git",
+ "git_ui",
"gpui",
"indexmap",
"language",
@@ -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",
@@ -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",
@@ -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",
@@ -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
@@ -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))
@@ -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;
@@ -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 {