diff --git a/crates/flint_actions/src/lib.rs b/crates/flint_actions/src/lib.rs index 0b6de9fe7e..b1548297e7 100644 --- a/crates/flint_actions/src/lib.rs +++ b/crates/flint_actions/src/lib.rs @@ -313,6 +313,10 @@ pub mod git { Branch, /// Opens the git stash selector. ViewStash, + /// Shows unstaged changes across the project. + ViewUnstagedChanges, + /// Shows staged changes across the project. + ViewStagedChanges, /// Opens the git worktree selector. Worktree, /// Creates a pull request for the current branch. diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 103b9f0398..53ff8dbabd 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -212,6 +212,16 @@ fn git_panel_context_menu( .action("View Stash", flint_actions::git::ViewStash.boxed_clone()) .separator() .action("Open Diff", project_diff::Diff.boxed_clone()) + .action_disabled_when( + !state.has_unstaged_changes, + "View Unstaged Changes", + flint_actions::git::ViewUnstagedChanges.boxed_clone(), + ) + .action_disabled_when( + !state.has_staged_changes, + "View Staged Changes", + flint_actions::git::ViewStagedChanges.boxed_clone(), + ) .separator() .action_disabled_when( !state.has_tracked_changes, @@ -6388,6 +6398,16 @@ impl GitPanel { .separator() .action("Open Diff", menu::Confirm.boxed_clone()) .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) + .action_disabled_when( + !entry.status.staging().has_unstaged(), + "View Unstaged Changes", + flint_actions::git::ViewUnstagedChanges.boxed_clone(), + ) + .action_disabled_when( + !entry.status.staging().has_staged(), + "View Staged Changes", + flint_actions::git::ViewStagedChanges.boxed_clone(), + ) .when(!is_created, |context_menu| { context_menu .separator() diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 647c7c5f23..831ebce289 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -12,6 +12,7 @@ use editor::{ multibuffer_context_lines, scroll::Autoscroll, }; +use flint_actions::git::{ViewStagedChanges, ViewUnstagedChanges}; use futures_lite::future::yield_now; use git::{ @@ -67,6 +68,7 @@ actions!( ); struct BufferSubscriptions { + _main_buffer: Entity, _diff: Entity, _diff_subscription: Subscription, _conflict_set: Entity, @@ -94,6 +96,8 @@ const NEW_SORT_PREFIX: u64 = 3; impl ProjectDiff { pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context) { workspace.register_action(Self::deploy); + workspace.register_action(Self::deploy_staged); + workspace.register_action(Self::deploy_unstaged); workspace.register_action(Self::deploy_branch_diff); workspace.register_action(Self::compare_with_branch); workspace.register_action(|workspace, _: &Add, window, cx| { @@ -111,6 +115,24 @@ impl ProjectDiff { Self::deploy_at(workspace, None, window, cx) } + fn deploy_staged( + workspace: &mut Workspace, + _: &ViewStagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + Self::deploy_diff_base(workspace, DiffBase::Staged, None, window, cx); + } + + fn deploy_unstaged( + workspace: &mut Workspace, + _: &ViewUnstagedChanges, + window: &mut Window, + cx: &mut Context, + ) { + Self::deploy_diff_base(workspace, DiffBase::Index, None, window, cx); + } + fn deploy_branch_diff( workspace: &mut Workspace, _: &BranchDiff, @@ -174,7 +196,7 @@ impl ProjectDiff { let selected_branch = workspace.active_item_as::(cx).and_then(|item| { match item.read(cx).diff_base(cx) { DiffBase::Merge { base_ref } => Some(base_ref.clone()), - DiffBase::Head => None, + DiffBase::Head | DiffBase::Index | DiffBase::Staged => None, } }); let workspace_handle = workspace.weak_handle(); @@ -280,11 +302,20 @@ impl ProjectDiff { window: &mut Window, cx: &mut Context, ) { - let intended_repo = workspace.project().read(cx).active_repository(cx); + Self::deploy_diff_base(workspace, DiffBase::Head, entry, window, cx); + } + fn deploy_diff_base( + workspace: &mut Workspace, + diff_base: DiffBase, + entry: Option, + window: &mut Window, + cx: &mut Context, + ) { + 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)); + .find(|item| item.read(cx).diff_base(cx) == &diff_base); let project_diff = if let Some(existing) = existing { existing.update(cx, |project_diff, cx| { project_diff.move_to_beginning(window, cx); @@ -294,8 +325,10 @@ 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 = workspace.project().clone(); + let project_diff = cx.new(|cx| { + Self::new_with_diff_base(diff_base, project, workspace_handle, window, cx) + }); workspace.add_item_to_active_pane( Box::new(project_diff.clone()), None, @@ -432,9 +465,19 @@ impl ProjectDiff { workspace: Entity, window: &mut Window, cx: &mut Context, + ) -> Self { + Self::new_with_diff_base(DiffBase::Head, project, workspace, window, cx) + } + + fn new_with_diff_base( + diff_base: DiffBase, + project: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, ) -> Self { let branch_diff = - cx.new(|cx| branch_diff::BranchDiff::new(DiffBase::Head, project.clone(), window, cx)); + cx.new(|cx| branch_diff::BranchDiff::new(diff_base, project.clone(), window, cx)); Self::new_impl(branch_diff, project, workspace, window, cx) } @@ -447,7 +490,11 @@ impl ProjectDiff { ) -> Self { let focus_handle = cx.focus_handle(); let multibuffer = cx.new(|cx| { - let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); + let capability = match branch_diff.read(cx).diff_base() { + DiffBase::Head => Capability::ReadWrite, + DiffBase::Index | DiffBase::Staged | DiffBase::Merge { .. } => Capability::ReadOnly, + }; + let mut multibuffer = MultiBuffer::new(capability); multibuffer.set_all_diff_hunks_expanded(cx); multibuffer }); @@ -463,10 +510,18 @@ impl ProjectDiff { ); match branch_diff.read(cx).diff_base() { DiffBase::Head => {} - DiffBase::Merge { .. } => diff_display_editor.disable_diff_hunk_controls(cx), + DiffBase::Index | DiffBase::Staged | DiffBase::Merge { .. } => { + diff_display_editor.disable_diff_hunk_controls(cx) + } } diff_display_editor.rhs_editor().update(cx, |editor, cx| { - editor.set_show_diff_review_button(true, cx); + editor.set_show_diff_review_button( + matches!( + branch_diff.read(cx).diff_base(), + DiffBase::Head | DiffBase::Merge { .. } + ), + cx, + ); match branch_diff.read(cx).diff_base() { DiffBase::Head => { @@ -474,6 +529,7 @@ impl ProjectDiff { workspace: workspace.downgrade(), }); } + DiffBase::Index | DiffBase::Staged => {} DiffBase::Merge { .. } => { editor.register_addon(BranchDiffAddon { branch_diff: branch_diff.clone(), @@ -752,14 +808,17 @@ impl ProjectDiff { path_key: PathKey, file_status: FileStatus, buffer: Entity, + main_buffer: Entity, diff: Entity, conflict_set: Entity, window: &mut Window, cx: &mut Context, ) -> Option { + let show_conflicts = matches!(self.diff_base(cx), DiffBase::Head); let diff_subscription = cx.subscribe_in(&diff, window, { let path_key = path_key.clone(); let buffer = buffer.clone(); + let main_buffer = main_buffer.clone(); let diff = diff.clone(); let conflict_set = conflict_set.clone(); move |this, _, event, window, cx| match event { @@ -768,6 +827,7 @@ impl ProjectDiff { path_key.clone(), file_status, buffer.clone(), + main_buffer.clone(), diff.clone(), conflict_set.clone(), window, @@ -782,6 +842,7 @@ impl ProjectDiff { let conflict_set_subscription = cx.subscribe_in(&conflict_set, window, { let path_key = path_key.clone(); let buffer = buffer.clone(); + let main_buffer = main_buffer.clone(); let diff = diff.clone(); let conflict_set = conflict_set.clone(); move |this, _, _, window, cx| { @@ -789,6 +850,7 @@ impl ProjectDiff { path_key.clone(), file_status, buffer.clone(), + main_buffer.clone(), diff.clone(), conflict_set.clone(), window, @@ -799,6 +861,7 @@ impl ProjectDiff { self.buffer_subscriptions.insert( path_key.path.clone(), BufferSubscriptions { + _main_buffer: main_buffer, _diff: diff.clone(), _diff_subscription: diff_subscription, _conflict_set: conflict_set.clone(), @@ -823,7 +886,7 @@ impl ProjectDiff { .map(|conflict| conflict.range.to_point(&snapshot)) .peekable(); - if conflicts.peek().is_some() { + if show_conflicts && conflicts.peek().is_some() { conflicts.collect::>() } else { diff_hunk_ranges.collect() @@ -842,9 +905,11 @@ impl ProjectDiff { diff, cx, ); - editor.rhs_editor().update(cx, |editor, cx| { - conflict_view::buffer_ranges_updated(editor, conflict_set, cx); - }); + if show_conflicts { + editor.rhs_editor().update(cx, |editor, cx| { + conflict_view::buffer_ranges_updated(editor, conflict_set, cx); + }); + } (was_empty, is_newly_added) }); @@ -897,6 +962,7 @@ impl ProjectDiff { path_key: PathKey, file_status: FileStatus, buffer: Entity, + main_buffer: Entity, diff: Entity, conflict_set: Entity, window: &mut Window, @@ -909,6 +975,7 @@ impl ProjectDiff { path_key, file_status, buffer, + main_buffer, diff, conflict_set, window, @@ -947,12 +1014,15 @@ impl ProjectDiff { } } + let show_conflicts = matches!(this.diff_base(cx), DiffBase::Head); 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 show_conflicts { + 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); @@ -965,7 +1035,7 @@ impl ProjectDiff { let mut buffers_to_fold = Vec::new(); for (path_key, entry) in entries { - if let Some((buffer, diff, conflict_set)) = entry.load.await.log_err() { + if let Some((buffer, main_buffer, diff, conflict_set)) = entry.load.await.log_err() { // We might be lagging behind enough that all future entry.load futures are no longer pending. // If that is the case, this task will never yield, starving the foreground thread of execution time. yield_now().await; @@ -975,6 +1045,7 @@ impl ProjectDiff { path_key, entry.file_status, buffer, + main_buffer, diff, conflict_set, window, @@ -1086,6 +1157,8 @@ impl Item for ProjectDiff { fn tab_tooltip_text(&self, cx: &App) -> Option { match self.diff_base(cx) { DiffBase::Head => Some("Project Diff".into()), + DiffBase::Index => Some("Unstaged Changes".into()), + DiffBase::Staged => Some("Staged Changes".into()), DiffBase::Merge { .. } => Some("Branch Diff".into()), } } @@ -1103,6 +1176,8 @@ impl Item for ProjectDiff { fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { match self.branch_diff.read(cx).diff_base() { DiffBase::Head => "Uncommitted Changes".into(), + DiffBase::Index => "Unstaged Changes".into(), + DiffBase::Staged => "Staged Changes".into(), DiffBase::Merge { base_ref } => format!("Changes since {}", base_ref).into(), } } @@ -1850,6 +1925,7 @@ mod tests { use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff}; use git::status::{TrackedStatus, UnmergedStatus, UnmergedStatusCode}; use gpui::TestAppContext; + use language::ToOffset as _; use project::FakeFs; use serde_json::json; use settings::{DiffViewStyle, SettingsStore}; @@ -1943,6 +2019,305 @@ mod tests { assert_eq!(text, "foo\n"); } + #[gpui::test] + async fn test_staged_and_unstaged_actions_open_separate_partial_change_views( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.txt": "staged\nworktree\n", + }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("file.txt", "head\nbase\n".into())], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("file.txt", "staged\nbase\n".into())], + ); + + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + ProjectDiff::deploy_staged(workspace, &ViewStagedChanges, window, cx); + }); + cx.run_until_parked(); + + let staged_diff = workspace.update(cx, |workspace, cx| { + let item = workspace.active_item_as::(cx).unwrap(); + assert_eq!(item.read(cx).diff_base(cx), &DiffBase::Staged); + assert_eq!(item.read(cx).tab_content_text(0, cx), "Staged Changes"); + assert!(item.read(cx).multibuffer.read(cx).read_only()); + item + }); + let staged_editor = + staged_diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + assert_state_with_diff( + &staged_editor, + cx, + &" + - ˇhead + + staged + base + " + .unindent(), + ); + + workspace.update_in(cx, |workspace, window, cx| { + ProjectDiff::deploy_unstaged(workspace, &ViewUnstagedChanges, window, cx); + }); + cx.run_until_parked(); + + let unstaged_diff = workspace.update(cx, |workspace, cx| { + let item = workspace.active_item_as::(cx).unwrap(); + assert_ne!(item.entity_id(), staged_diff.entity_id()); + assert_eq!(item.read(cx).diff_base(cx), &DiffBase::Index); + assert_eq!(item.read(cx).tab_content_text(0, cx), "Unstaged Changes"); + assert!(item.read(cx).multibuffer.read(cx).read_only()); + item + }); + let unstaged_editor = + unstaged_diff.read_with(cx, |diff, cx| diff.editor.read(cx).rhs_editor().clone()); + assert_state_with_diff( + &unstaged_editor, + cx, + &" + ˇstaged + - base + + worktree + " + .unindent(), + ); + } + + #[gpui::test] + async fn test_staged_view_refreshes_for_deletion_and_rename(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "new.txt": "renamed\n", + }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[ + ("deleted.txt", "deleted\n".into()), + ("old.txt", "renamed\n".into()), + ], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("new.txt", "renamed\n".into())], + ); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + ProjectDiff::deploy_staged(workspace, &ViewStagedChanges, window, cx); + }); + cx.run_until_parked(); + + let staged_diff = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + let mut paths = staged_diff.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + paths.sort(); + assert_eq!( + paths, + vec![ + rel_path("deleted.txt").into_arc(), + rel_path("new.txt").into_arc(), + rel_path("old.txt").into_arc(), + ] + ); + + fs.set_head_and_index_for_repo( + Path::new(path!("/project/.git")), + &[("new.txt", "renamed\n".into())], + ); + cx.run_until_parked(); + + let paths = staged_diff.read_with(cx, |diff, cx| diff.excerpt_paths(cx)); + assert!(paths.is_empty()); + } + + #[gpui::test] + async fn test_staged_view_preserves_selection_across_refresh(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "file.txt": "staged\nworktree\n", + }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project/.git")), + &[("file.txt", "head\nbase\n".into())], + "deadbeef", + ); + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("file.txt", "staged\nbase\n".into())], + ); + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + cx.run_until_parked(); + + workspace.update_in(cx, |workspace, window, cx| { + ProjectDiff::deploy_diff_base(workspace, DiffBase::Staged, None, window, cx); + }); + cx.run_until_parked(); + + let editor = workspace.update(cx, |workspace, cx| { + workspace + .active_item_as::(cx) + .unwrap() + .read(cx) + .editor + .read(cx) + .rhs_editor() + .clone() + }); + let mut editor_context = EditorTestContext::for_editor_in(editor, cx).await; + editor_context.set_selections_state("head\nstaˇged\nbase\n"); + + fs.set_index_for_repo( + Path::new(path!("/project/.git")), + &[("file.txt", "staged\nBASE\n".into())], + ); + editor_context.cx.run_until_parked(); + + let (text, buffer_offset) = + editor_context + .editor + .read_with(&editor_context.cx, |editor, cx| { + let selection = editor.selections.newest_anchor().head(); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let (buffer_anchor, buffer_snapshot) = + snapshot.anchor_to_buffer_anchor(selection).unwrap(); + (editor.text(cx), buffer_anchor.to_offset(buffer_snapshot)) + }); + assert_eq!(text, "head\nbase\nstaged\nBASE\n"); + assert_eq!(buffer_offset, 3); + } + + #[gpui::test] + async fn test_staged_action_reuses_view_and_switches_repository(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/project_a"), + json!({ ".git": {}, "a.txt": "staged_a\n" }), + ) + .await; + fs.insert_tree( + path!("/project_b"), + json!({ ".git": {}, "b.txt": "staged_b\n" }), + ) + .await; + fs.set_head_for_repo( + Path::new(path!("/project_a/.git")), + &[("a.txt", "original_a\n".into())], + "a", + ); + fs.set_index_for_repo( + Path::new(path!("/project_a/.git")), + &[("a.txt", "staged_a\n".into())], + ); + fs.set_head_for_repo( + Path::new(path!("/project_b/.git")), + &[("b.txt", "original_b\n".into())], + "b", + ); + fs.set_index_for_repo( + Path::new(path!("/project_b/.git")), + &[("b.txt", "staged_b\n".into())], + ); + + let project = Project::test( + fs, + [ + Path::new(path!("/project_a")), + Path::new(path!("/project_b")), + ], + cx, + ) + .await; + let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + worktrees.sort_by_key(|worktree| worktree.read(cx).abs_path()); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = + multi_workspace.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()); + cx.run_until_parked(); + + for (worktree_id, expected_path) in [(worktree_a_id, "a.txt"), (worktree_b_id, "b.txt")] { + workspace.update(cx, |workspace, cx| { + let git_store = workspace.project().read(cx).git_store().clone(); + git_store.update(cx, |git_store, cx| { + git_store.set_active_repo_for_worktree(worktree_id, cx); + }); + }); + workspace.update_in(cx, |workspace, window, cx| { + ProjectDiff::deploy_diff_base(workspace, DiffBase::Staged, None, window, cx); + }); + cx.run_until_parked(); + + let staged_diff = workspace.update(cx, |workspace, cx| { + workspace.active_item_as::(cx).unwrap() + }); + assert_eq!( + staged_diff.read_with(cx, |diff, cx| diff.excerpt_paths(cx)), + vec![rel_path(expected_path).into_arc()] + ); + assert_eq!( + workspace.update(cx, |workspace, cx| { + workspace + .items_of_type::(cx) + .filter(|item| item.read(cx).diff_base(cx) == &DiffBase::Staged) + .count() + }), + 1 + ); + } + } + #[gpui::test] async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) { init_test(cx); @@ -2734,13 +3109,15 @@ mod tests { let active_item = workspace.active_item_as::(cx).unwrap(); let active_base_ref = match active_item.read(cx).diff_base(cx) { DiffBase::Merge { base_ref } => base_ref.to_string(), - DiffBase::Head => panic!("expected active item to be a branch diff"), + DiffBase::Head | DiffBase::Index | DiffBase::Staged => { + panic!("expected active item to be a branch diff") + } }; let base_refs = workspace .items_of_type::(cx) .filter_map(|item| match item.read(cx).diff_base(cx) { DiffBase::Merge { base_ref } => Some(base_ref.to_string()), - DiffBase::Head => None, + DiffBase::Head | DiffBase::Index | DiffBase::Staged => None, }) .collect::>(); (active_base_ref, base_refs) diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 9342b76d45..aacc832520 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -117,6 +117,7 @@ struct SharedDiffs { struct BufferGitState { unstaged_diff: Option>, + staged_diff: Option<(WeakEntity, Entity)>, uncommitted_diff: Option>, oid_diffs: HashMap, WeakEntity>, conflict_set: Option>, @@ -160,6 +161,7 @@ enum DiffBasesChange { #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum DiffKind { Unstaged, + Staged, Uncommitted, SinceOid(Option), } @@ -926,6 +928,64 @@ impl GitStore { cx.background_spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + pub fn open_staged_diff( + &mut self, + buffer: Entity, + cx: &mut Context, + ) -> Task, Entity)>> { + let buffer_id = buffer.read(cx).remote_id(); + if let Some(diff_state) = self.diffs.get(&buffer_id) + && let Some(staged_diff) = diff_state.read(cx).staged_diff() + { + if let Some(task) = + diff_state.update(cx, |diff_state, _| diff_state.wait_for_recalculation()) + { + return cx.background_executor().spawn(async move { + task.await; + Ok(staged_diff) + }); + } + return Task::ready(Ok(staged_diff)); + } + + let Some((repo, repo_path)) = self.repository_and_path_for_buffer_id(buffer_id, cx) else { + return Task::ready(Err(anyhow!("failed to find git repository for buffer"))); + }; + let task = self + .loading_diffs + .entry((buffer_id, DiffKind::Staged)) + .or_insert_with(|| { + let changes = repo.update(cx, |repo, cx| { + repo.load_committed_text(buffer_id, repo_path, cx) + }); + cx.spawn(async move |this, cx| { + Self::open_diff_internal(this, DiffKind::Staged, changes.await, buffer, cx) + .await + .map_err(Arc::new) + }) + .shared() + }) + .clone(); + + cx.spawn(async move |this, cx| { + let diff = task.await.map_err(|error| anyhow!("{error}"))?; + this.update(cx, |this, cx| { + let index_buffer = this + .diffs + .get(&buffer_id) + .and_then(|diff_state| { + diff_state + .read(cx) + .staged_diff + .as_ref() + .map(|(_, index_buffer)| index_buffer.clone()) + }) + .context("index buffer missing after opening staged diff")?; + Ok((diff, index_buffer)) + })? + }) + } + pub fn open_diff_since( &mut self, oid: Option, @@ -1119,7 +1179,29 @@ impl GitStore { .entry(buffer_id) .or_insert_with(|| cx.new(|_| BufferGitState::new(git_store))); - let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); + let mut staged_index_buffer = None; + let diff = if kind == DiffKind::Staged { + let index_text = match &diff_bases_change { + DiffBasesChange::SetIndex(index) => index.clone(), + DiffBasesChange::SetHead(_) => None, + DiffBasesChange::SetEach { index, .. } => index.clone(), + DiffBasesChange::SetBoth(text) => text.clone(), + } + .unwrap_or_default(); + let index_buffer = cx.new(|cx| { + let mut index_buffer = Buffer::local(index_text, cx); + if let Some(language_registry) = language_registry.clone() { + index_buffer.set_language_registry(language_registry); + } + index_buffer.set_language_async(language.clone(), cx); + index_buffer + }); + let index_snapshot = index_buffer.read(cx).text_snapshot(); + staged_index_buffer = Some(index_buffer); + cx.new(|cx| BufferDiff::new(&index_snapshot, cx)) + } else { + cx.new(|cx| BufferDiff::new(&text_snapshot, cx)) + }; cx.subscribe(&diff, Self::on_buffer_diff_event).detach(); diff_state.update(cx, |diff_state, cx| { @@ -1131,6 +1213,12 @@ impl GitStore { DiffKind::Unstaged => { diff_state.unstaged_diff.get_or_insert(diff.downgrade()); } + DiffKind::Staged => { + let index_buffer = staged_index_buffer + .take() + .context("index buffer was not created for staged diff")?; + diff_state.staged_diff = Some((diff.downgrade(), index_buffer)); + } DiffKind::Uncommitted => { let unstaged_diff = if let Some(diff) = diff_state.unstaged_diff() { diff @@ -3792,6 +3880,7 @@ impl BufferGitState { fn new(_git_store: WeakEntity) -> Self { Self { unstaged_diff: Default::default(), + staged_diff: Default::default(), uncommitted_diff: Default::default(), oid_diffs: Default::default(), recalculate_diff_task: Default::default(), @@ -3871,6 +3960,11 @@ impl BufferGitState { self.unstaged_diff.as_ref().and_then(|set| set.upgrade()) } + fn staged_diff(&self) -> Option<(Entity, Entity)> { + let (diff, index_buffer) = self.staged_diff.as_ref()?; + Some((diff.upgrade()?, index_buffer.clone())) + } + fn uncommitted_diff(&self) -> Option> { self.uncommitted_diff.as_ref().and_then(|set| set.upgrade()) } @@ -3976,6 +4070,7 @@ impl BufferGitState { let language = self.language.clone(); let language_registry = self.language_registry.clone(); let unstaged_diff = self.unstaged_diff(); + let staged_diff = self.staged_diff(); let uncommitted_diff = self.uncommitted_diff(); let head = self.head_text.clone(); let index = self.index_text.clone(); @@ -4019,7 +4114,7 @@ impl BufferGitState { cx.update(|cx| { unstaged_diff.read(cx).update_diff( buffer.clone(), - index, + index.clone(), index_changed.then_some(false), language.clone(), cx, @@ -4033,6 +4128,36 @@ impl BufferGitState { // for a bit yield_now().await; + let mut staged_diff_update = None; + if let Some((staged_diff, index_buffer)) = staged_diff.as_ref() { + let index_snapshot = if index_changed || language_changed { + let new_index_text = index.clone().unwrap_or_default(); + let index_text_diff = index_buffer + .update(cx, |index_buffer, cx| index_buffer.diff(new_index_text, cx)) + .await; + index_buffer.update(cx, |index_buffer, cx| { + index_buffer.edit(index_text_diff.edits, None, cx); + index_buffer.text_snapshot() + }) + } else { + index_buffer.read_with(cx, |index_buffer, _| index_buffer.text_snapshot()) + }; + let update = cx + .update(|cx| { + staged_diff.read(cx).update_diff( + index_snapshot.clone(), + head.clone(), + head_changed.then_some(true), + language.clone(), + cx, + ) + }) + .await; + staged_diff_update = Some((staged_diff.clone(), index_snapshot, update)); + } + + yield_now().await; + let mut new_uncommitted_diff = None; if let Some(uncommitted_diff) = &uncommitted_diff { new_uncommitted_diff = if index_matches_head { @@ -4082,6 +4207,17 @@ impl BufferGitState { return Ok(()); } + if let Some((staged_diff, index_snapshot, staged_update)) = staged_diff_update { + staged_diff + .update(cx, |diff, cx| { + if language_changed { + diff.language_changed(language.clone(), language_registry.clone(), cx); + } + diff.set_snapshot(staged_update, &index_snapshot, cx) + }) + .await; + } + let unstaged_changed_range = if let Some((unstaged_diff, new_unstaged_diff)) = unstaged_diff.as_ref().zip(new_unstaged_diff.clone()) { @@ -4800,6 +4936,10 @@ impl Repository { .unstaged_diff .as_ref() .is_some_and(|diff| diff.is_upgradable()); + let has_staged_diff = diff_state + .staged_diff + .as_ref() + .is_some_and(|(diff, _)| diff.is_upgradable()); let has_uncommitted_diff = diff_state .uncommitted_diff .as_ref() @@ -4809,8 +4949,10 @@ impl Repository { buffer, repo_path, is_symlink, - has_unstaged_diff.then(|| diff_state.index_text.clone()), - has_uncommitted_diff.then(|| diff_state.head_text.clone()), + (has_unstaged_diff || has_staged_diff) + .then(|| diff_state.index_text.clone()), + (has_uncommitted_diff || has_staged_diff) + .then(|| diff_state.head_text.clone()), )) }) }) diff --git a/crates/project/src/git_store/branch_diff.rs b/crates/project/src/git_store/branch_diff.rs index 67aa198945..bee724b0d8 100644 --- a/crates/project/src/git_store/branch_diff.rs +++ b/crates/project/src/git_store/branch_diff.rs @@ -24,6 +24,8 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum DiffBase { Head, + Index, + Staged, Merge { base_ref: SharedString }, } @@ -354,7 +356,10 @@ impl BranchDiff { else { continue; }; - if !status.has_changes() { + if !status.has_changes() + || matches!(self.diff_base, DiffBase::Index) && !status.staging().has_unstaged() + || matches!(self.diff_base, DiffBase::Staged) && !status.staging().has_staged() + { continue; } @@ -363,7 +368,13 @@ impl BranchDiff { else { continue; }; - let task = Self::load_buffer(branch_diff, project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + branch_diff, + project_path, + repo.clone(), + cx, + ); output.push(DiffBuffer { repo_path: item.repo_path.clone(), @@ -383,8 +394,13 @@ impl BranchDiff { let Some(project_path) = repo.read(cx).repo_path_to_project_path(&path, cx) else { continue; }; - let task = - Self::load_buffer(Some(branch_diff.clone()), project_path, repo.clone(), cx); + let task = Self::load_buffer( + self.diff_base.clone(), + Some(branch_diff.clone()), + project_path, + repo.clone(), + cx, + ); let file_status = diff_status_to_file_status(branch_diff); @@ -400,35 +416,64 @@ impl BranchDiff { #[instrument(skip_all)] fn load_buffer( + diff_base: DiffBase, branch_diff: Option, project_path: crate::ProjectPath, repo: Entity, cx: &Context<'_, Project>, - ) -> Task, Entity, Entity)>> { + ) -> Task< + Result<( + Entity, + Entity, + Entity, + Entity, + )>, + > { let task = cx.spawn(async move |project, cx| { let buffer = project .update(cx, |project, cx| project.open_buffer(project_path, cx))? .await?; - let changes = if let Some(entry) = branch_diff { - let oid = match entry { - git::status::TreeDiffStatus::Added { .. } => None, - git::status::TreeDiffStatus::Modified { old, .. } - | git::status::TreeDiffStatus::Deleted { old } => Some(old), - }; - project - .update(cx, |project, cx| { - project.git_store().update(cx, |git_store, cx| { - git_store.open_diff_since(oid, buffer.clone(), repo, cx) - }) - })? - .await? - } else { - project - .update(cx, |project, cx| { - project.open_uncommitted_diff(buffer.clone(), cx) - })? - .await? + let (display_buffer, changes) = match diff_base { + DiffBase::Index => { + let changes = project + .update(cx, |project, cx| { + project.open_unstaged_diff(buffer.clone(), cx) + })? + .await?; + (buffer.clone(), changes) + } + DiffBase::Staged => { + let (changes, index_buffer) = project + .update(cx, |project, cx| { + project.open_staged_diff(buffer.clone(), cx) + })? + .await?; + (index_buffer, changes) + } + DiffBase::Head | DiffBase::Merge { .. } => { + let changes = if let Some(entry) = branch_diff { + let oid = match entry { + git::status::TreeDiffStatus::Added { .. } => None, + git::status::TreeDiffStatus::Modified { old, .. } + | git::status::TreeDiffStatus::Deleted { old } => Some(old), + }; + project + .update(cx, |project, cx| { + project.git_store().update(cx, |git_store, cx| { + git_store.open_diff_since(oid, buffer.clone(), repo, cx) + }) + })? + .await? + } else { + project + .update(cx, |project, cx| { + project.open_uncommitted_diff(buffer.clone(), cx) + })? + .await? + }; + (buffer.clone(), changes) + } }; let conflict_set = project .update(cx, |project, cx| { @@ -437,7 +482,7 @@ impl BranchDiff { }) })? .await; - Ok((buffer, changes, conflict_set)) + Ok((display_buffer, buffer, changes, conflict_set)) }); task } @@ -465,5 +510,12 @@ fn diff_status_to_file_status(branch_diff: &git::status::TreeDiffStatus) -> File pub struct DiffBuffer { pub repo_path: RepoPath, pub file_status: FileStatus, - pub load: Task, Entity, Entity)>>, + pub load: Task< + Result<( + Entity, + Entity, + Entity, + Entity, + )>, + >, } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 474062c4ca..cc4694d3aa 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -2424,6 +2424,18 @@ impl Project { .update(cx, |git_store, cx| git_store.open_unstaged_diff(buffer, cx)) } + pub fn open_staged_diff( + &mut self, + buffer: Entity, + cx: &mut Context, + ) -> Task, Entity)>> { + if self.is_disconnected(cx) { + return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); + } + self.git_store + .update(cx, |git_store, cx| git_store.open_staged_diff(buffer, cx)) + } + #[ztracing::instrument(skip_all)] pub fn open_uncommitted_diff( &mut self,