diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index 5d3943dc9b5e29..bd43c5306a0c10 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -413,6 +413,7 @@ pub struct SplittableEditor { /// True when the current width is below the minimum threshold for split /// mode, regardless of the current diff view style setting. too_narrow_for_split: bool, + delegate_stage_and_restore: bool, last_width: Option, _subscriptions: Vec, } @@ -471,6 +472,13 @@ impl SplittableEditor { }); } + pub fn set_delegate_stage_and_restore(&mut self, delegate: bool, cx: &mut Context) { + self.delegate_stage_and_restore = delegate; + self.update_editors(cx, |editor, _cx| { + editor.set_delegate_stage_and_restore(delegate); + }); + } + pub fn set_render_diff_hunks_as_unstaged(&self, cx: &mut Context) { self.update_editors(cx, |editor, cx| { editor.set_render_diff_hunks_as_unstaged(true, cx); @@ -583,6 +591,7 @@ impl SplittableEditor { split_state, searched_side: None, too_narrow_for_split: false, + delegate_stage_and_restore: false, last_width: None, _subscriptions: subscriptions, } @@ -666,6 +675,10 @@ impl SplittableEditor { } } EditorEvent::StageOrUnstageRequested { stage, hunks } => { + if this.delegate_stage_and_restore { + cx.emit(event.clone()); + return; + } if this.lhs.is_some() { let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); if !translated.is_empty() { @@ -680,6 +693,10 @@ impl SplittableEditor { } } EditorEvent::RestoreRequested { hunks } => { + if this.delegate_stage_and_restore { + cx.emit(event.clone()); + return; + } if this.lhs.is_some() { let translated = translate_lhs_hunks_to_rhs(hunks, this, cx); if !translated.is_empty() { diff --git a/crates/git_ui/src/commit_diff_preview.rs b/crates/git_ui/src/commit_diff_preview.rs new file mode 100644 index 00000000000000..67d4f9dd4ab761 --- /dev/null +++ b/crates/git_ui/src/commit_diff_preview.rs @@ -0,0 +1,675 @@ +use anyhow::Result; +use buffer_diff::BufferDiff; +use editor::{Editor, EditorEvent, MultiBuffer}; +use gpui::{ + AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, + Focusable, Font, IntoElement, Render, SharedString, Task, Window, +}; +use language::{ + Buffer, Capability, DiskState, File as LanguageFile, HighlightedText, OffsetRangeExt, Point, + ReplicaId, TextBuffer, +}; +use multi_buffer::PathKey; +use project::{Project, ProjectPath}; +use settings::WorktreeId; +use std::{ + any::{Any, TypeId}, + path::{Path, PathBuf}, + sync::Arc, + time::Instant, +}; +use ui::{Color, Icon, IconName, Label, LabelCommon as _}; +use util::ResultExt as _; +use util::paths::PathStyle; +use util::rel_path::RelPath; +use workspace::{ + Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace, + item::{ItemEvent, SaveOptions, TabContentParams}, + searchable::SearchableItemHandle, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommitDiffPreviewStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + Typechange, +} + +#[derive(Clone, Debug)] +pub struct CommitDiffPreviewFile { + pub display_path: Arc, + pub old_path: Option>, + pub new_path: Option>, + pub status: CommitDiffPreviewStatus, + pub old_text: Option>, + pub new_text: Option>, + pub is_binary: bool, + pub is_truncated: bool, + pub load_error: Option>, +} + +#[derive(Clone, Debug)] +pub struct CommitDiffPreviewOptions { + pub title: SharedString, + pub context_lines: u32, + pub reveal_all_hunks: bool, +} + +impl Default for CommitDiffPreviewOptions { + fn default() -> Self { + Self { + title: "Commit Diff".into(), + context_lines: 3, + reveal_all_hunks: true, + } + } +} + +pub struct CommitDiffPreview { + editor: Entity, + title: SharedString, + file_count: usize, + _populate_task: Task>, +} + +struct PreviewEntry { + index: usize, + display_path: Arc, + metadata_path: Arc, + was_deleted: bool, + old_text: Option>, + new_text: Arc, +} + +struct PreviewBufferFile { + path: Arc, + full_path: PathBuf, + file_name: String, + was_deleted: bool, +} + +impl PreviewBufferFile { + fn new(metadata_path: &str, was_deleted: bool) -> Self { + let path = display_rel_path(metadata_path); + let file_name = path.file_name().unwrap_or("untitled").to_string(); + let full_path = path.as_std_path().to_path_buf(); + + Self { + path, + full_path, + file_name, + was_deleted, + } + } +} + +impl LanguageFile for PreviewBufferFile { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> DiskState { + DiskState::Historic { + was_deleted: self.was_deleted, + } + } + + fn path(&self) -> &Arc { + &self.path + } + + fn full_path(&self, _: &App) -> PathBuf { + self.full_path.clone() + } + + fn path_style(&self, _: &App) -> PathStyle { + PathStyle::Posix + } + + fn file_name<'a>(&'a self, _: &'a App) -> &'a str { + &self.file_name + } + + fn worktree_id(&self, _: &App) -> WorktreeId { + WorktreeId::from_usize(0) + } + + fn to_proto(&self, cx: &App) -> language::proto::File { + language::proto::File { + worktree_id: self.worktree_id(cx).to_proto(), + entry_id: None, + path: self.path.as_ref().to_proto(), + mtime: None, + is_deleted: self.was_deleted, + is_historic: true, + } + } + + fn is_private(&self) -> bool { + false + } + + fn can_open(&self) -> bool { + false + } +} + +impl CommitDiffPreviewFile { + fn into_entry(self, index: usize) -> PreviewEntry { + let CommitDiffPreviewFile { + display_path, + old_path, + new_path, + status, + old_text, + new_text, + is_binary, + is_truncated, + load_error, + } = self; + let metadata_path = new_path + .clone() + .or_else(|| old_path.clone()) + .unwrap_or_else(|| display_path.clone()); + let was_deleted = matches!(status, CommitDiffPreviewStatus::Deleted); + + if let Some(error) = load_error { + return PreviewEntry { + index, + display_path: display_path.clone(), + metadata_path, + was_deleted, + old_text: None, + new_text: format!("Unable to load diff for {display_path}: {error}\n").into(), + }; + } + + if is_binary { + return PreviewEntry { + index, + display_path: display_path.clone(), + metadata_path, + was_deleted, + old_text: None, + new_text: format!("Binary file: {display_path}\n").into(), + }; + } + + if is_truncated { + return PreviewEntry { + index, + display_path: display_path.clone(), + metadata_path, + was_deleted, + old_text: None, + new_text: format!("File too large to preview: {display_path}\n").into(), + }; + } + + match status { + CommitDiffPreviewStatus::Added => PreviewEntry { + index, + display_path, + metadata_path, + was_deleted, + old_text: None, + new_text: new_text.unwrap_or_else(|| "".into()), + }, + CommitDiffPreviewStatus::Deleted => PreviewEntry { + index, + display_path, + metadata_path, + was_deleted, + old_text, + new_text: "".into(), + }, + CommitDiffPreviewStatus::Modified + | CommitDiffPreviewStatus::Renamed + | CommitDiffPreviewStatus::Copied + | CommitDiffPreviewStatus::Typechange => PreviewEntry { + index, + display_path, + metadata_path, + was_deleted, + old_text, + new_text: new_text.unwrap_or_else(|| "".into()), + }, + } + } +} + +impl CommitDiffPreview { + pub fn new( + files: Vec, + options: CommitDiffPreviewOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let started = Instant::now(); + let multibuffer = cx.new(|cx| { + let mut multibuffer = MultiBuffer::new(Capability::ReadOnly); + if options.reveal_all_hunks { + multibuffer.set_all_diff_hunks_expanded(cx); + } + multibuffer + }); + + let file_count = files.len(); + let editor = cx.new(|cx| { + let mut editor = + Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx); + editor.start_temporary_diff_override(); + editor.disable_diagnostics(cx); + if options.reveal_all_hunks { + editor.set_expand_all_diff_hunks(cx); + } + editor.set_render_diff_hunks_as_unstaged(true, cx); + editor.set_render_diff_hunk_controls( + Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()), + cx, + ); + editor + }); + + let language_registry = project.read(cx).languages().clone(); + let entries = files + .into_iter() + .enumerate() + .map(|(index, file)| file.into_entry(index)) + .collect::>(); + let context_lines = options.context_lines; + let populate_task = cx.spawn(async move |_, cx| { + populate_entries(multibuffer, entries, context_lines, language_registry, cx) + .await + .log_err(); + Ok(()) + }); + trace_diff_preview_duration("commit_diff_preview.new", started); + + Self { + editor, + title: options.title, + file_count, + _populate_task: populate_task, + } + } + + fn title(&self) -> SharedString { + if self.file_count == 0 { + return self.title.clone(); + } + let suffix = if self.file_count == 1 { + "1 file".to_string() + } else { + format!("{} files", self.file_count) + }; + format!("{} ({suffix})", self.title).into() + } +} + +async fn populate_entries( + multibuffer: Entity, + entries: Vec, + context_lines: u32, + language_registry: Arc, + cx: &mut AsyncApp, +) -> Result<()> { + let total_started = Instant::now(); + for entry in entries { + let entry_started = Instant::now(); + let trace_path = if diff_preview_trace_enabled() { + Some(entry.display_path.clone()) + } else { + None + }; + let language_started = Instant::now(); + let language = language_registry + .load_language_for_file_path(Path::new(entry.metadata_path.as_ref())) + .await + .ok(); + trace_diff_preview_path_duration( + "commit_diff_preview.language_load", + trace_path.as_deref(), + language_started, + ); + + let buffer_started = Instant::now(); + let buffer = cx.new(|cx| { + let buffer_file: Arc = Arc::new(PreviewBufferFile::new( + entry.metadata_path.as_ref(), + entry.was_deleted, + )); + let mut buffer = Buffer::build( + TextBuffer::new( + ReplicaId::LOCAL, + cx.entity_id().as_non_zero_u64().into(), + entry.new_text.to_string(), + ), + Some(buffer_file), + Capability::ReadWrite, + ); + buffer.set_language_registry(language_registry.clone()); + buffer.set_language(language.clone(), cx); + buffer + }); + trace_diff_preview_path_duration( + "commit_diff_preview.buffer_build", + trace_path.as_deref(), + buffer_started, + ); + + let diff_started = Instant::now(); + let diff = build_snapshot_diff(entry.old_text.clone(), &buffer, cx).await?; + trace_diff_preview_path_duration( + "commit_diff_preview.buffer_diff", + trace_path.as_deref(), + diff_started, + ); + let register_started = Instant::now(); + register_entry( + &multibuffer, + entry.index, + entry.display_path, + buffer, + diff, + context_lines, + cx, + ); + trace_diff_preview_path_duration( + "commit_diff_preview.register_entry", + trace_path.as_deref(), + register_started, + ); + trace_diff_preview_path_duration( + "commit_diff_preview.populate_entry", + trace_path.as_deref(), + entry_started, + ); + } + + trace_diff_preview_duration("commit_diff_preview.populate_entries", total_started); + Ok(()) +} + +async fn build_snapshot_diff( + old_text: Option>, + buffer: &Entity, + cx: &mut AsyncApp, +) -> Result> { + let language = cx.update(|cx| buffer.read(cx).language().cloned()); + let language_registry = cx.update(|cx| buffer.read(cx).language_registry()); + let snapshot = cx.update(|cx| buffer.read(cx).snapshot()); + let diff = cx.new(|cx| BufferDiff::new(&snapshot.text, language, language_registry, cx)); + let new_text = snapshot.text.clone(); + diff.update(cx, |diff, cx| diff.set_base_text(old_text, new_text, cx)) + .await; + Ok(diff) +} + +fn register_entry( + multibuffer: &Entity, + index: usize, + display_path: Arc, + buffer: Entity, + diff: Entity, + context_lines: u32, + cx: &mut AsyncApp, +) { + cx.update(|cx| { + let snapshot = buffer.read(cx).snapshot(); + let diff_snapshot = diff.read(cx).snapshot(cx); + let mut ranges = diff_snapshot + .hunks(&snapshot) + .map(|hunk| hunk.buffer_range.to_point(&snapshot)) + .collect::>(); + if ranges.is_empty() { + ranges.push(Point::new(0, 0)..snapshot.max_point()); + } + + multibuffer.update(cx, |multibuffer, cx| { + multibuffer.set_excerpts_for_path( + display_path_key(index, &display_path), + buffer.clone(), + ranges, + context_lines, + cx, + ); + multibuffer.add_diff(diff.clone(), cx); + }); + }); +} + +fn display_path_key(index: usize, display_path: &str) -> PathKey { + PathKey::with_sort_prefix(index as u64, display_rel_path(display_path)) +} + +fn display_rel_path(display_path: &str) -> Arc { + RelPath::new(Path::new(display_path), PathStyle::Posix) + .unwrap_or_else(|_| { + RelPath::new(Path::new("untitled"), PathStyle::Posix) + .unwrap_or_else(|_| unreachable!("'untitled' is a valid relative path")) + }) + .into_owned() + .into() +} + +fn diff_preview_trace_enabled() -> bool { + std::env::var_os("CHERRYPICK_DIFF_PREVIEW_TRACE").is_some() +} + +fn trace_diff_preview_duration(event: &str, started: Instant) { + if diff_preview_trace_enabled() { + eprintln!( + "diff-preview {event} {:.2}ms", + started.elapsed().as_secs_f64() * 1000.0 + ); + } +} + +fn trace_diff_preview_path_duration(event: &str, path: Option<&str>, started: Instant) { + if diff_preview_trace_enabled() { + if let Some(path) = path { + eprintln!( + "diff-preview {event} path={path} {:.2}ms", + started.elapsed().as_secs_f64() * 1000.0 + ); + } else { + trace_diff_preview_duration(event, started); + } + } +} + +impl EventEmitter for CommitDiffPreview {} + +impl Focusable for CommitDiffPreview { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Item for CommitDiffPreview { + type Event = EditorEvent; + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::Diff).color(Color::Muted)) + } + + fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement { + Label::new(self.title()) + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } + + fn tab_tooltip_text(&self, _cx: &App) -> Option { + Some(self.title()) + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + self.title() + } + + fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("Commit Diff Preview Opened") + } + + fn deactivated(&mut self, window: &mut Window, cx: &mut Context) { + self.editor + .update(cx, |editor, cx| editor.deactivated(window, cx)); + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a Entity, + _: &'a App, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.clone().into()) + } else if type_id == TypeId::of::() { + Some(self.editor.clone().into()) + } else { + None + } + } + + fn as_searchable(&self, _: &Entity, _: &App) -> Option> { + Some(Box::new(self.editor.clone())) + } + + fn active_project_path(&self, cx: &App) -> Option { + self.editor.read(cx).active_project_path(cx) + } + + fn set_nav_history( + &mut self, + nav_history: ItemNavHistory, + _: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn navigate( + &mut self, + data: Arc, + window: &mut Window, + cx: &mut Context, + ) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, window, cx)) + } + + fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, cx: &App) -> Option<(Vec, Option)> { + self.editor.breadcrumbs(cx) + } + + fn added_to_workspace( + &mut self, + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, + ) { + self.editor.update(cx, |editor, cx| { + editor.added_to_workspace(workspace, window, cx) + }); + } + + fn can_save(&self, cx: &App) -> bool { + self.editor.read(cx).can_save(cx) + } + + fn save( + &mut self, + options: SaveOptions, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Task> { + self.editor + .update(cx, |editor, cx| editor.save(options, project, window, cx)) + } +} + +impl Render for CommitDiffPreview { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.editor.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preview_buffer_file_uses_display_path_for_header_metadata() { + let file = PreviewBufferFile::new("src/lib.rs", false); + + assert_eq!( + file.path().display(PathStyle::Posix).to_string(), + "src/lib.rs" + ); + assert_eq!(file.file_name, "lib.rs"); + assert_eq!( + file.disk_state(), + DiskState::Historic { was_deleted: false } + ); + assert!(!file.can_open()); + } + + #[test] + fn preview_buffer_file_marks_deleted_historic_files() { + let file = PreviewBufferFile::new("src/deleted.rs", true); + + assert_eq!(file.disk_state(), DiskState::Historic { was_deleted: true }); + } + + #[test] + fn preview_entry_uses_metadata_path_separately_from_display_path() { + let entry = CommitDiffPreviewFile { + display_path: "old.rs -> new.rs".into(), + old_path: Some("old.rs".into()), + new_path: Some("new.rs".into()), + status: CommitDiffPreviewStatus::Renamed, + old_text: Some("old".into()), + new_text: Some("new".into()), + is_binary: false, + is_truncated: false, + load_error: None, + } + .into_entry(7); + + assert_eq!(entry.index, 7); + assert_eq!(entry.display_path.as_ref(), "old.rs -> new.rs"); + assert_eq!(entry.metadata_path.as_ref(), "new.rs"); + assert!(!entry.was_deleted); + } + + #[test] + fn display_path_key_preserves_path_with_sort_prefix() { + let key = display_path_key(3, "crates/cherrypick-ui/src/app_shell.rs"); + + assert_eq!(key.sort_prefix, Some(3)); + assert_eq!( + key.path.display(PathStyle::Posix).to_string(), + "crates/cherrypick-ui/src/app_shell.rs" + ); + } +} diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 309a118078128a..6310b8a6666c60 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -115,19 +115,17 @@ pub(crate) fn buffer_ranges_updated( return; } - let buffer_conflicts = editor - .addon_mut::() - .unwrap() - .buffers - .entry(buffer_id) - .or_insert_with(|| { - let subscription = cx.subscribe(&conflict_set, conflicts_updated); - BufferConflicts { - block_ids: Vec::new(), - conflict_set: conflict_set.clone(), - _subscription: subscription, - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + let buffer_conflicts = conflict_addon.buffers.entry(buffer_id).or_insert_with(|| { + let subscription = cx.subscribe(&conflict_set, conflicts_updated); + BufferConflicts { + block_ids: Vec::new(), + conflict_set: conflict_set.clone(), + _subscription: subscription, + } + }); let conflict_set = buffer_conflicts.conflict_set.clone(); let conflicts_len = conflict_set.read(cx).snapshot().conflicts.len(); @@ -150,18 +148,17 @@ pub(crate) fn buffers_removed( cx: &mut Context, ) { let mut removed_block_ids = HashSet::default(); - editor - .addon_mut::() - .unwrap() - .buffers - .retain(|buffer_id, buffer| { - if removed_buffer_ids.contains(buffer_id) { - removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); - false - } else { - true - } - }); + let Some(conflict_addon) = editor.addon_mut::() else { + return; + }; + conflict_addon.buffers.retain(|buffer_id, buffer| { + if removed_buffer_ids.contains(buffer_id) { + removed_block_ids.extend(buffer.block_ids.iter().map(|(_, block_id)| *block_id)); + false + } else { + true + } + }); editor.remove_blocks(removed_block_ids, None, cx); } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 7e5b2ade32a56f..679bbfe1050209 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -31,6 +31,9 @@ use crate::{commit_view::CommitView, git_panel::GitPanel, text_diff_view::TextDi mod askpass_modal; pub mod branch_picker; +// Public for downstream embedders that render historical commit diffs outside +// Zed's working-copy ProjectDiff. +pub mod commit_diff_preview; mod commit_modal; pub mod commit_tooltip; pub mod commit_view; diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index cd929c22e670f0..fda03f5358ce5a 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -75,7 +75,7 @@ struct BufferSubscriptions { _diff: Entity, _diff_subscription: Subscription, _conflict_set: Entity, - _conflict_set_subscription: Subscription, + _conflict_set_subscription: Option, } pub struct ProjectDiff { @@ -83,6 +83,7 @@ pub struct ProjectDiff { multibuffer: Entity, branch_diff: Entity, editor: Entity, + options: ProjectDiffOptions, buffer_subscriptions: HashMap, BufferSubscriptions>, workspace: WeakEntity, focus_handle: FocusHandle, @@ -96,6 +97,39 @@ const CONFLICT_SORT_PREFIX: u64 = 1; const TRACKED_SORT_PREFIX: u64 = 2; const NEW_SORT_PREFIX: u64 = 3; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ProjectDiffOptions { + pub enable_hunk_controls: bool, + pub show_review_button: bool, + pub register_git_panel_addon: bool, + pub allow_index_mutations: bool, + pub enable_conflict_view: bool, +} + +impl ProjectDiffOptions { + pub const fn embedded_view_only() -> Self { + Self { + enable_hunk_controls: false, + show_review_button: false, + register_git_panel_addon: false, + allow_index_mutations: false, + enable_conflict_view: false, + } + } +} + +impl Default for ProjectDiffOptions { + fn default() -> Self { + Self { + enable_hunk_controls: true, + show_review_button: true, + register_git_panel_addon: true, + allow_index_mutations: true, + enable_conflict_view: true, + } + } +} + impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); @@ -331,6 +365,16 @@ impl ProjectDiff { entry: Option, window: &mut Window, cx: &mut Context, + ) { + Self::deploy_at_with_options(workspace, entry, ProjectDiffOptions::default(), window, cx); + } + + pub fn deploy_at_with_options( + workspace: &mut Workspace, + entry: Option, + options: ProjectDiffOptions, + window: &mut Window, + cx: &mut Context, ) { telemetry::event!( "Git Diff Opened", @@ -342,9 +386,10 @@ impl ProjectDiff { ); let intended_repo = workspace.project().read(cx).active_repository(cx); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).find(|item| { + let item = item.read(cx); + matches!(item.diff_base(cx), DiffBase::Head) && item.options == options + }); let project_diff = if let Some(existing) = existing { existing.update(cx, |project_diff, cx| { project_diff.move_to_beginning(window, cx); @@ -354,8 +399,15 @@ impl ProjectDiff { existing } else { let workspace_handle = cx.entity(); - let project_diff = - cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + let project_diff = cx.new(|cx| { + Self::new_with_options( + workspace.project().clone(), + workspace_handle, + options, + window, + cx, + ) + }); workspace.add_item_to_active_pane( Box::new(project_diff.clone()), None, @@ -394,18 +446,42 @@ impl ProjectDiff { project_path: ProjectPath, window: &mut Window, cx: &mut Context, + ) { + Self::deploy_at_project_path_with_options( + workspace, + project_path, + ProjectDiffOptions::default(), + window, + cx, + ); + } + + pub fn deploy_at_project_path_with_options( + workspace: &mut Workspace, + project_path: ProjectPath, + options: ProjectDiffOptions, + window: &mut Window, + cx: &mut Context, ) { telemetry::event!("Git Diff Opened", source = "Agent Panel"); - let existing = workspace - .items_of_type::(cx) - .find(|item| matches!(item.read(cx).diff_base(cx), DiffBase::Head)); + let existing = workspace.items_of_type::(cx).find(|item| { + let item = item.read(cx); + matches!(item.diff_base(cx), DiffBase::Head) && item.options == options + }); let project_diff = if let Some(existing) = existing { workspace.activate_item(&existing, true, true, window, cx); existing } else { let workspace_handle = cx.entity(); - let project_diff = - cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx)); + let project_diff = cx.new(|cx| { + Self::new_with_options( + workspace.project().clone(), + workspace_handle, + options, + window, + cx, + ) + }); workspace.add_item_to_active_pane( Box::new(project_diff.clone()), None, @@ -458,7 +534,14 @@ impl ProjectDiff { branch_diff })?; cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) + Self::new_impl( + branch_diff, + project, + workspace, + ProjectDiffOptions::default(), + window, + cx, + ) }) }) } @@ -483,7 +566,14 @@ impl ProjectDiff { branch_diff })?; cx.new_window_entity(|window, cx| { - Self::new_impl(branch_diff, project, workspace, window, cx) + Self::new_impl( + branch_diff, + project, + workspace, + ProjectDiffOptions::default(), + window, + cx, + ) }) }) } @@ -493,16 +583,33 @@ impl ProjectDiff { workspace: Entity, window: &mut Window, cx: &mut Context, + ) -> Self { + Self::new_with_options( + project, + workspace, + ProjectDiffOptions::default(), + window, + cx, + ) + } + + fn new_with_options( + project: Entity, + workspace: Entity, + options: ProjectDiffOptions, + window: &mut Window, + cx: &mut Context, ) -> Self { let branch_diff = cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); - Self::new_impl(branch_diff, project, workspace, window, cx) + Self::new_impl(branch_diff, project, workspace, options, window, cx) } fn new_impl( branch_diff: Entity, project: Entity, workspace: Entity, + options: ProjectDiffOptions, window: &mut Window, cx: &mut Context, ) -> Self { @@ -514,7 +621,7 @@ impl ProjectDiff { }); let editor = cx.new(|cx| { - let diff_display_editor = SplittableEditor::new( + let mut diff_display_editor = SplittableEditor::new( EditorSettings::get_global(cx).diff_view_style, multibuffer.clone(), project.clone(), @@ -522,19 +629,23 @@ impl ProjectDiff { window, cx, ); - match branch_diff.read(cx).diff_base() { - DiffBase::Head => {} - DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx), + let diff_base = branch_diff.read(cx).diff_base().clone(); + if !options.enable_hunk_controls || matches!(&diff_base, DiffBase::Merge { .. }) { + diff_display_editor.disable_diff_hunk_controls(cx); + } + if !options.allow_index_mutations { + diff_display_editor.set_delegate_stage_and_restore(true, cx); } diff_display_editor.rhs_editor().update(cx, |editor, cx| { - editor.set_show_diff_review_button(true, cx); + editor.set_show_diff_review_button(options.show_review_button, cx); - match branch_diff.read(cx).diff_base() { - DiffBase::Head => { + match diff_base { + DiffBase::Head if options.register_git_panel_addon => { editor.register_addon(GitPanelAddon { workspace: workspace.downgrade(), }); } + DiffBase::Head => {} DiffBase::Merge { .. } => { editor.register_addon(BranchDiffAddon { branch_diff: branch_diff.clone(), @@ -608,6 +719,7 @@ impl ProjectDiff { branch_diff, focus_handle, editor, + options, multibuffer, buffer_subscriptions: Default::default(), pending_scroll: None, @@ -794,6 +906,13 @@ impl ProjectDiff { }) .ok(); } + EditorEvent::StageOrUnstageRequested { .. } | EditorEvent::RestoreRequested { .. } + if !self.options.allow_index_mutations => + { + // Embedded Cherrypick diffs are view-only. The editor still + // receives Zed git keybindings, but delegation routes them here + // instead of writing to the index/worktree. + } EditorEvent::Saved => { self._task = cx.spawn_in(window, async move |this, cx| Self::refresh(this, cx).await); @@ -839,23 +958,27 @@ impl ProjectDiff { | buffer_diff::BufferDiffEvent::HunksStagedOrUnstaged(_) => {} } }); - let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, { - let path_key = path_key.clone(); - let buffer = buffer.clone(); - let diff = diff.clone(); - let conflict_set = conflict_set.clone(); - move |this, _, _, window, cx| { - this.buffer_ranges_changed( - path_key.clone(), - file_status, - buffer.clone(), - diff.clone(), - conflict_set.clone(), - window, - cx, - ) - } - }); + let conflict_set_subscription = if self.options.enable_conflict_view { + Some(cx.subscribe_in(&conflict_set, window, { + let path_key = path_key.clone(); + let buffer = buffer.clone(); + let diff = diff.clone(); + let conflict_set = conflict_set.clone(); + move |this, _, _, window, cx| { + this.buffer_ranges_changed( + path_key.clone(), + file_status, + buffer.clone(), + diff.clone(), + conflict_set.clone(), + window, + cx, + ) + } + })) + } else { + None + }; self.buffer_subscriptions.insert( path_key.path.clone(), BufferSubscriptions { @@ -869,6 +992,7 @@ impl ProjectDiff { let snapshot = buffer.read(cx).snapshot(); let diff_snapshot = diff.read(cx).snapshot(cx); + let enable_conflict_view = self.options.enable_conflict_view; let excerpt_ranges = { let diff_hunk_ranges = diff_snapshot .hunks_intersecting_range( @@ -876,15 +1000,19 @@ impl ProjectDiff { &snapshot, ) .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot)); - let conflicts = conflict_set.read(cx).snapshot(); - let mut conflicts = conflicts - .conflicts - .iter() - .map(|conflict| conflict.range.to_point(&snapshot)) - .peekable(); - - if conflicts.peek().is_some() { - conflicts.collect::>() + if enable_conflict_view { + let conflicts = conflict_set.read(cx).snapshot(); + let mut conflicts = conflicts + .conflicts + .iter() + .map(|conflict| conflict.range.to_point(&snapshot)) + .peekable(); + + if conflicts.peek().is_some() { + conflicts.collect::>() + } else { + diff_hunk_ranges.collect() + } } else { diff_hunk_ranges.collect() } @@ -902,9 +1030,11 @@ impl ProjectDiff { diff, cx, ); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffer_ranges_updated(editor, conflict_set, cx); - }); + if enable_conflict_view { + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffer_ranges_updated(editor, conflict_set, cx); + }); + } (was_empty, is_newly_added) }); @@ -1007,12 +1137,15 @@ impl ProjectDiff { } } + let enable_conflict_view = this.options.enable_conflict_view; this.editor.update(cx, |editor, cx| { for (path, buffer_id) in previous_paths { this.buffer_subscriptions.remove(&path.path); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffers_removed(editor, &[buffer_id], cx); - }); + if enable_conflict_view { + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffers_removed(editor, &[buffer_id], cx); + }); + } let _span = ztracing::info_span!("remove_excerpts_for_path"); _span.enter(); editor.remove_excerpts_for_path(path, cx); @@ -1083,6 +1216,10 @@ impl ProjectDiff { }) .collect() } + + pub fn is_embedded_view_only(&self) -> bool { + self.options == ProjectDiffOptions::embedded_view_only() + } } fn sort_prefix(repo: &Repository, repo_path: &RepoPath, status: FileStatus, cx: &App) -> u64 { @@ -1231,8 +1368,18 @@ impl Item for ProjectDiff { let Some(workspace) = self.workspace.upgrade() else { return Task::ready(None); }; + let diff_base = self.branch_diff.read(cx).diff_base().clone(); + let repo = self.branch_diff.read(cx).repo().cloned(); + let project = self.project.clone(); + let options = self.options; Task::ready(Some(cx.new(|cx| { - ProjectDiff::new(self.project.clone(), workspace, window, cx) + let branch_diff = cx.new(|cx| { + let mut branch_diff = + branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx); + branch_diff.set_repo(repo, cx); + branch_diff + }); + ProjectDiff::new_impl(branch_diff, project, workspace, options, window, cx) }))) } @@ -1423,9 +1570,16 @@ impl SerializableItem for ProjectDiff { let branch_diff = cx .new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); let workspace = workspace.upgrade().context("workspace gone")?; - anyhow::Ok( - cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)), - ) + anyhow::Ok(cx.new(|cx| { + ProjectDiff::new_impl( + branch_diff, + project, + workspace, + ProjectDiffOptions::default(), + window, + cx, + ) + })) })??; Ok(diff) @@ -1596,6 +1750,7 @@ impl ToolbarItemView for ProjectDiffToolbar { self.project_diff = active_pane_item .and_then(|item| item.act_as::(cx)) .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Head) + .filter(|item| item.read(cx).options.allow_index_mutations) .map(|entity| entity.downgrade()); if self.project_diff.is_some() { ToolbarItemLocation::PrimaryRight diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 1266f6f87eac1b..f92aff77b193c0 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -1403,6 +1403,55 @@ impl GitStore { Some(repo.read(cx).status_for_path(&repo_path)?.status) } + pub fn refresh_status_for_project_paths( + &mut self, + project_paths: I, + cx: &mut Context, + ) -> usize + where + I: IntoIterator, + { + let GitStoreState::Local { downstream, .. } = &self.state else { + return 0; + }; + let updates_tx = downstream + .as_ref() + .map(|downstream| downstream.updates_tx.clone()); + + let mut paths_by_repo: HashMap, Vec)> = + HashMap::default(); + let mut fallback_active_repo_id = None; + for project_path in project_paths { + let Some((repo, repo_path)) = + self.repository_and_path_for_project_path(&project_path, cx) + else { + continue; + }; + let repo_id = repo.read(cx).id; + if self.active_repo_id.is_none() && fallback_active_repo_id.is_none() { + fallback_active_repo_id = Some(repo_id); + } + paths_by_repo + .entry(repo_id) + .or_insert_with(|| (repo, Vec::new())) + .1 + .push(repo_path); + } + + if let Some(repo_id) = fallback_active_repo_id { + self.set_active_repo_id(repo_id, cx); + } + + let mut refreshed = 0; + for (_, (repo, paths)) in paths_by_repo { + refreshed += paths.len(); + repo.update(cx, |repo, cx| { + repo.paths_changed(paths, updates_tx.clone(), cx); + }); + } + refreshed + } + pub fn checkpoint(&self, cx: &mut App) -> Task> { let mut work_directory_abs_paths = Vec::new(); let mut checkpoints = Vec::new();