diff --git a/Cargo.lock b/Cargo.lock index 8b4bc8752d..fe0c7a1b23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12636,6 +12636,7 @@ dependencies = [ "editor", "file_icons", "git", + "git_ui", "gpui", "indexmap", "language", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2a4c095124..567580a9c6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -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", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 1a6cda4b64..1c2ad3a006 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -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", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 6458ac1510..57edb1e4c1 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -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", diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index ce5fec0b13..b9d43d9873 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -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 diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 048b9e73d0..45581a97c4 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -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, // Currently selected leaf entry (see auto-folding for a definition of that) in a file tree selection: Option, - marked_entries: BTreeSet, + marked_entries: Vec, context_menu: Option<(Entity, Point, Subscription)>, edit_state: Option, filename_editor: Entity, @@ -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, - selections: Arc>, + 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::(), 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) -> 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, + ) { + 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) { 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::(), 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)) diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 7699256bc9..6c62c8db93 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -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::>(); + assert_eq!(active_items.len(), 1); + let diff_view = active_items + .into_iter() + .next() + .unwrap() + .downcast::() + .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, path: impl AsRef, 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; diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index fff15d2b52..a9e7304e47 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -62,7 +62,7 @@ pub struct SelectedEntry { #[derive(Debug)] pub struct DraggedSelection { pub active_selection: SelectedEntry, - pub marked_selections: Arc>, + pub marked_selections: Arc<[SelectedEntry]>, } impl DraggedSelection {