From d57f5937d451dc1d5c771a8ade63ccb47e73add6 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 12 Feb 2025 22:26:34 -0700 Subject: [PATCH] Git panel: Right click menu (#24787) Release Notes: - N/A --- crates/editor/src/editor.rs | 41 ++-- crates/git_ui/src/git_panel.rs | 390 ++++++++++++++++++++------------- crates/project/src/project.rs | 10 + 3 files changed, 278 insertions(+), 163 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f77a32a92bbcff..29220f292b2330 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -67,7 +67,10 @@ use element::{AcceptEditPredictionBinding, LineWithInvisibles, PositionMap}; pub use element::{ CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition, }; -use futures::{future, FutureExt}; +use futures::{ + future::{self, Shared}, + FutureExt, +}; use fuzzy::StringMatchCandidate; use code_context_menus::{ @@ -761,6 +764,7 @@ pub struct Editor { next_scroll_position: NextScrollCursorCenterTopBottom, addons: HashMap>, registered_buffers: HashMap, + load_diff_task: Option>>, selection_mark_mode: bool, toggle_fold_multiple_buffers: Task<()>, _scroll_cursor_center_top_bottom_task: Task<()>, @@ -1318,12 +1322,16 @@ impl Editor { }; let mut code_action_providers = Vec::new(); + let mut load_uncommitted_diff = None; if let Some(project) = project.clone() { - get_uncommitted_diff_for_buffer( - &project, - buffer.read(cx).all_buffers(), - buffer.clone(), - cx, + load_uncommitted_diff = Some( + get_uncommitted_diff_for_buffer( + &project, + buffer.read(cx).all_buffers(), + buffer.clone(), + cx, + ) + .shared(), ); code_action_providers.push(Rc::new(project) as Rc<_>); } @@ -1471,6 +1479,7 @@ impl Editor { selection_mark_mode: false, toggle_fold_multiple_buffers: Task::ready(()), text_style_refinement: None, + load_diff_task: load_uncommitted_diff, }; this.tasks_update_task = Some(this.refresh_runnables(window, cx)); this._subscriptions.extend(project_subscriptions); @@ -14120,11 +14129,14 @@ impl Editor { let buffer_id = buffer.read(cx).remote_id(); if self.buffer.read(cx).diff_for(buffer_id).is_none() { if let Some(project) = &self.project { - get_uncommitted_diff_for_buffer( - project, - [buffer.clone()], - self.buffer.clone(), - cx, + self.load_diff_task = Some( + get_uncommitted_diff_for_buffer( + project, + [buffer.clone()], + self.buffer.clone(), + cx, + ) + .shared(), ); } } @@ -14879,6 +14891,10 @@ impl Editor { gpui::Size::new(em_width, line_height) } + + pub fn wait_for_diff_to_load(&self) -> Option>> { + self.load_diff_task.clone() + } } fn get_uncommitted_diff_for_buffer( @@ -14886,7 +14902,7 @@ fn get_uncommitted_diff_for_buffer( buffers: impl IntoIterator>, buffer: Entity, cx: &mut App, -) { +) -> Task<()> { let mut tasks = Vec::new(); project.update(cx, |project, cx| { for buffer in buffers { @@ -14903,7 +14919,6 @@ fn get_uncommitted_diff_for_buffer( }) .ok(); }) - .detach(); } fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 7aa62062da5e4f..e7e57a3cb312b6 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -16,7 +16,7 @@ use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged}; use gpui::*; use itertools::Itertools; use language::{markdown, Buffer, File, ParsedMarkdown}; -use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; +use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; use multi_buffer::ExcerptInfo; use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader}; use project::{ @@ -28,10 +28,11 @@ use settings::Settings as _; use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize}; use time::OffsetDateTime; use ui::{ - prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, IndentGuideColors, - ListItem, ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, + prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem, + ListItemSpacing, Scrollbar, ScrollbarState, Tooltip, }; use util::{maybe, ResultExt, TryFutureExt}; +use workspace::SaveIntent; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotificationId}, @@ -79,7 +80,6 @@ pub fn init(cx: &mut App) { #[derive(Debug, Clone)] pub enum Event { Focus, - OpenedEntry { path: ProjectPath }, } #[derive(Serialize, Deserialize)] @@ -112,7 +112,7 @@ impl GitHeaderEntry { pub fn title(&self) -> &'static str { match self.header { Section::Conflict => "Conflicts", - Section::Tracked => "Changed", + Section::Tracked => "Changes", Section::New => "New", } } @@ -177,6 +177,7 @@ pub struct GitPanel { update_visible_entries_task: Task<()>, width: Option, workspace: WeakEntity, + context_menu: Option<(Entity, Point, Subscription)>, } fn commit_message_editor( @@ -215,7 +216,7 @@ impl GitPanel { let active_repository = project.read(cx).active_repository(cx); let workspace = cx.entity().downgrade(); - let git_panel = cx.new(|cx| { + cx.new(|cx| { let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, window, Self::focus_in).detach(); cx.on_focus_out(&focus_handle, window, |this, _, window, cx| { @@ -282,30 +283,13 @@ impl GitPanel { tracked_staged_count: 0, update_visible_entries_task: Task::ready(()), width: Some(px(360.)), + context_menu: None, workspace, }; git_panel.schedule_update(false, window, cx); git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx); git_panel - }); - - cx.subscribe_in( - &git_panel, - window, - move |workspace, _, event: &Event, window, cx| match event.clone() { - Event::OpenedEntry { path } => { - workspace - .open_path_preview(path, None, false, false, window, cx) - .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| { - Some(format!("{e}")) - }); - } - Event::Focus => { /* TODO */ } - }, - ) - .detach(); - - git_panel + }) } pub fn select_entry_by_path( @@ -468,7 +452,7 @@ impl GitPanel { fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context) { if self.entries.first().is_some() { - self.selected_entry = Some(0); + self.selected_entry = Some(1); self.scroll_to_selected_entry(cx); } } @@ -486,7 +470,16 @@ impl GitPanel { selected_entry }; - self.selected_entry = Some(new_selected_entry); + if matches!( + self.entries.get(new_selected_entry), + Some(GitListEntry::Header(..)) + ) { + if new_selected_entry > 0 { + self.selected_entry = Some(new_selected_entry - 1) + } + } else { + self.selected_entry = Some(new_selected_entry); + } self.scroll_to_selected_entry(cx); } @@ -506,8 +499,14 @@ impl GitPanel { } else { selected_entry }; - - self.selected_entry = Some(new_selected_entry); + if matches!( + self.entries.get(new_selected_entry), + Some(GitListEntry::Header(..)) + ) { + self.selected_entry = Some(new_selected_entry + 1); + } else { + self.selected_entry = Some(new_selected_entry); + } self.scroll_to_selected_entry(cx); } @@ -537,7 +536,7 @@ impl GitPanel { active_repository.read(cx).entry_count() > 0 }); if have_entries && self.selected_entry.is_none() { - self.selected_entry = Some(0); + self.selected_entry = Some(1); self.scroll_to_selected_entry(cx); cx.notify(); } @@ -559,7 +558,7 @@ impl GitPanel { self.selected_entry.and_then(|i| self.entries.get(i)) } - fn open_selected(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { maybe!({ let entry = self.entries.get(self.selected_entry?)?.status_entry()?; @@ -572,6 +571,121 @@ impl GitPanel { self.focus_handle.focus(window); } + fn open_file( + &mut self, + _: &menu::SecondaryConfirm, + window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let active_repo = self.active_repository.as_ref()?; + let path = active_repo + .read(cx) + .repo_path_to_project_path(&entry.repo_path)?; + if entry.status.is_deleted() { + return None; + } + + self.workspace + .update(cx, |workspace, cx| { + workspace + .open_path_preview(path, None, false, false, window, cx) + .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| { + Some(format!("{e}")) + }); + }) + .ok() + }); + } + + fn revert( + &mut self, + _: &editor::actions::RevertFile, + window: &mut Window, + cx: &mut Context, + ) { + maybe!({ + let list_entry = self.entries.get(self.selected_entry?)?.clone(); + let entry = list_entry.status_entry()?; + let active_repo = self.active_repository.as_ref()?; + let path = active_repo + .read(cx) + .repo_path_to_project_path(&entry.repo_path)?; + let workspace = self.workspace.clone(); + + if entry.status.is_staged() != Some(false) { + self.update_staging_area_for_entries(false, vec![entry.repo_path.clone()], cx); + } + + if entry.status.is_created() { + let prompt = window.prompt( + PromptLevel::Info, + "Do you want to trash this file?", + None, + &["Trash", "Cancel"], + cx, + ); + cx.spawn_in(window, |_, mut cx| async move { + match prompt.await { + Ok(0) => {} + _ => return Ok(()), + } + let task = workspace.update(&mut cx, |workspace, cx| { + workspace + .project() + .update(cx, |project, cx| project.delete_file(path, true, cx)) + })?; + if let Some(task) = task { + task.await?; + } + Ok(()) + }) + .detach_and_prompt_err( + "Failed to trash file", + window, + cx, + |e, _, _| Some(format!("{e}")), + ); + return Some(()); + } + + let open_path = workspace.update(cx, |workspace, cx| { + workspace.open_path_preview(path, None, true, false, window, cx) + }); + + cx.spawn_in(window, |_, mut cx| async move { + let item = open_path?.await?; + let editor = cx.update(|_, cx| { + item.act_as::(cx) + .ok_or_else(|| anyhow::anyhow!("didn't open editor")) + })??; + + if let Some(task) = + editor.update(&mut cx, |editor, _| editor.wait_for_diff_to_load())? + { + task.await + }; + + editor.update_in(&mut cx, |editor, window, cx| { + editor.revert_file(&Default::default(), window, cx); + })?; + + workspace + .update_in(&mut cx, |workspace, window, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + })? + .await?; + Ok(()) + }) + .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| { + Some(format!("{e}")) + }); + + Some(()) + }); + } + fn toggle_staged_for_entry( &mut self, entry: &GitListEntry, @@ -606,7 +720,18 @@ impl GitPanel { (goal_staged_state, entries) } }; + self.update_staging_area_for_entries(stage, repo_paths, cx); + } + fn update_staging_area_for_entries( + &mut self, + stage: bool, + repo_paths: Vec, + cx: &mut Context, + ) { + let Some(active_repository) = self.active_repository.clone() else { + return; + }; let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1; self.pending.push(PendingOperation { op_id, @@ -615,7 +740,6 @@ impl GitPanel { finished: false, }); let repo_paths = repo_paths.clone(); - let active_repository = active_repository.clone(); let repository = active_repository.read(cx); self.update_counts(repository); cx.notify(); @@ -1530,7 +1654,7 @@ impl GitPanel { fn render_entries( &self, has_write_access: bool, - window: &Window, + _: &Window, cx: &mut Context, ) -> impl IntoElement { let entry_count = self.entries.len(); @@ -1571,61 +1695,6 @@ impl GitPanel { items } }) - .with_decoration( - ui::indent_guides( - cx.entity().clone(), - self.indent_size(window, cx), - IndentGuideColors::panel(cx), - |this, range, _windows, _cx| { - this.entries - .iter() - .skip(range.start) - .map(|entry| match entry { - GitListEntry::GitStatusEntry(_) => 1, - GitListEntry::Header(_) => 0, - }) - .collect() - }, - ) - .with_render_fn( - cx.entity().clone(), - move |_, params, _, _| { - let indent_size = params.indent_size; - let left_offset = indent_size - px(3.0); - let item_height = params.item_height; - - params - .indent_guides - .into_iter() - .enumerate() - .map(|(_, layout)| { - let offset = if layout.continues_offscreen { - px(0.) - } else { - px(4.0) - }; - let bounds = Bounds::new( - point( - px(layout.offset.x as f32) * indent_size + left_offset, - px(layout.offset.y as f32) * item_height + offset, - ), - size( - px(1.), - px(layout.length as f32) * item_height - - px(offset.0 * 2.), - ), - ); - ui::RenderedIndentGuide { - bounds, - layout, - is_active: false, - hitbox: None, - } - }) - .collect() - }, - ), - ) .size_full() .with_sizing_behavior(ListSizingBehavior::Infer) .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) @@ -1642,59 +1711,22 @@ impl GitPanel { &self, ix: usize, header: &GitHeaderEntry, - has_write_access: bool, - window: &Window, - cx: &Context, + _: bool, + _: &Window, + _: &Context, ) -> AnyElement { - let selected = self.selected_entry == Some(ix); - let header_state = if self.has_staged_changes() { - self.header_state(header.header) - } else { - match header.header { - Section::Tracked | Section::Conflict => ToggleState::Selected, - Section::New => ToggleState::Unselected, - } - }; - - let checkbox = Checkbox::new(("checkbox", ix), header_state) - .disabled(!has_write_access) - .fill() - .placeholder(!self.has_staged_changes()) - .elevation(ElevationIndex::Surface) - .on_click({ - let header = header.clone(); - cx.listener(move |this, _, window, cx| { - this.toggle_staged_for_entry(&GitListEntry::Header(header.clone()), window, cx); - cx.stop_propagation(); - }) - }); - - let start_slot = h_flex() - .id(("start-slot", ix)) - .gap(DynamicSpacing::Base04.rems(cx)) - .child(checkbox) - .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx)) - .on_mouse_down(MouseButton::Left, |_, _, cx| { - // prevent the list item active state triggering when toggling checkbox - cx.stop_propagation(); - }); - div() .w_full() .child( ListItem::new(ix) .spacing(ListItemSpacing::Sparse) - .start_slot(start_slot) - .toggle_state(selected) - .focused(selected && self.focus_handle(cx).is_focused(window)) - .disabled(!has_write_access) - .on_click({ - cx.listener(move |this, _, _, cx| { - this.selected_entry = Some(ix); - cx.notify(); - }) - }) - .child(h_flex().child(self.entry_label(header.title(), Color::Muted))), + .disabled(true) + .child( + Label::new(header.title()) + .color(Color::Muted) + .size(LabelSize::Small) + .single_line(), + ), ) .into_any_element() } @@ -1710,6 +1742,50 @@ impl GitPanel { repo.update(cx, |repo, cx| repo.show(sha, cx)) } + fn deploy_context_menu( + &mut self, + position: Point, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else { + return; + }; + let revert_title = if entry.status.is_deleted() { + "Restore file" + } else if entry.status.is_created() { + "Trash file" + } else { + "Discard changes" + }; + let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| { + context_menu + .action("Stage File", ToggleStaged.boxed_clone()) + .action(revert_title, editor::actions::RevertFile.boxed_clone()) + .separator() + .action("Open Diff", Confirm.boxed_clone()) + .action("Open File", SecondaryConfirm.boxed_clone()) + }); + + let subscription = cx.subscribe_in( + &context_menu, + window, + |this, _, _: &DismissEvent, window, cx| { + if this.context_menu.as_ref().is_some_and(|context_menu| { + context_menu.0.focus_handle(cx).contains_focused(window, cx) + }) { + cx.focus_self(window); + } + this.context_menu.take(); + cx.notify(); + }, + ); + self.selected_entry = Some(ix); + self.context_menu = Some((context_menu, position, subscription)); + cx.notify(); + } + fn render_entry( &self, ix: usize, @@ -1789,26 +1865,31 @@ impl GitPanel { cx.stop_propagation(); }); - let id = ElementId::Name(format!("entry_{}", display_name).into()); - div() .w_full() .child( - ListItem::new(id) - .indent_level(1) - .indent_step_size(Checkbox::container_size(cx).to_pixels(window.rem_size())) + ListItem::new(ix) .spacing(ListItemSpacing::Sparse) .start_slot(start_slot) .toggle_state(selected) .focused(selected && self.focus_handle(cx).is_focused(window)) .disabled(!has_write_access) .on_click({ - cx.listener(move |this, _, window, cx| { + cx.listener(move |this, event: &ClickEvent, window, cx| { this.selected_entry = Some(ix); cx.notify(); - this.open_selected(&Default::default(), window, cx); + if event.modifiers().secondary() { + this.open_file(&Default::default(), window, cx) + } else { + this.open_diff(&Default::default(), window, cx); + } }) }) + .on_secondary_mouse_down(cx.listener( + move |this, event: &MouseDownEvent, window, cx| { + this.deploy_context_menu(event.position, ix, window, cx) + }, + )) .child( h_flex() .when_some(repo_path.parent(), |this, parent| { @@ -1870,14 +1951,14 @@ impl Render for GitPanel { })) .on_action(cx.listener(GitPanel::commit)) }) - .when(self.is_focused(window, cx), |this| { - this.on_action(cx.listener(Self::select_first)) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_prev)) - .on_action(cx.listener(Self::select_last)) - .on_action(cx.listener(Self::close_panel)) - }) - .on_action(cx.listener(Self::open_selected)) + .on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::close_panel)) + .on_action(cx.listener(Self::open_diff)) + .on_action(cx.listener(Self::open_file)) + .on_action(cx.listener(Self::revert)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::toggle_staged_for_selected)) @@ -1906,6 +1987,15 @@ impl Render for GitPanel { }) .children(self.render_previous_commit(cx)) .child(self.render_commit_editor(window, cx)) + .children(self.context_menu.as_ref().map(|(menu, position, _)| { + deferred( + anchored() + .position(*position) + .anchor(gpui::Corner::TopLeft) + .child(menu.clone()), + ) + .with_priority(1) + })) } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8cc0481a5a6981..e369c040ccdfb6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1612,6 +1612,16 @@ impl Project { }) } + pub fn delete_file( + &mut self, + path: ProjectPath, + trash: bool, + cx: &mut Context, + ) -> Option>> { + let entry = self.entry_for_path(&path, cx)?; + self.delete_entry(entry.id, trash, cx) + } + pub fn delete_entry( &mut self, entry_id: ProjectEntryId,