Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:28:02.314Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

project_diff.rs

2047 lines · 68.2 KB · rust
1use crate::{
2    diff_multibuffer::DiffMultibuffer,
3    git_panel::{GitPanel, GitPanelAddon, GitStatusEntry},
4    staged_diff::StagedDiff,
5    unstaged_diff::UnstagedDiff,
6};
7use anyhow::{Context as _, Result};
8use buffer_diff::DiffHunkSecondaryStatus;
9use editor::{
10    Editor, EditorEvent, SplittableEditor, UncommittedDiffHunkDelegate,
11    actions::{GoToHunk, GoToPreviousHunk, SendReviewToAgent},
12};
13use git::{Commit, StageAll, StageAndNext, ToggleStaged, UnstageAll, UnstageAndNext};
14use gpui::{
15    Action, AnyElement, App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Render,
16    Subscription, Task, WeakEntity, actions,
17};
18use language::Capability;
19use multi_buffer::MultiBuffer;
20use project::{
21    Project, ProjectPath,
22    git_store::{
23        Repository,
24        diff_buffer_list::{self, DiffBase},
25    },
26};
27use schemars::JsonSchema;
28use serde::Deserialize;
29use std::any::{Any, TypeId};
30use std::sync::Arc;
31use ui::{DiffStat, Divider, Tooltip, prelude::*};
32use workspace::{
33    ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
34    Workspace,
35    item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
36    searchable::SearchableItemHandle,
37};
38use zed_actions::git as git_actions;
39
40actions!(
41    git,
42    [
43        /// Shows the diff between the working directory and the index.
44        Diff,
45        /// Adds files to the git staging area.
46        Add,
47        /// Opens a new agent thread with the branch diff for review.
48        ReviewDiff,
49        LeaderAndFollower,
50        /// Compare with a specific branch
51        CompareWithBranch,
52    ]
53);
54
55/// Shows the diff between the working directory and your default
56/// branch (typically main or master).
57#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
58#[action(namespace = git, name = "BranchDiff")]
59pub(crate) struct DeployBranchDiff;
60
61pub struct ProjectDiff {
62    project: Entity<Project>,
63    workspace: WeakEntity<Workspace>,
64    diff: Entity<DiffMultibuffer>,
65    _diff_observation: Subscription,
66}
67
68impl ProjectDiff {
69    pub(crate) fn register(workspace: &mut Workspace, cx: &mut Context<Workspace>) {
70        workspace.register_action(Self::deploy);
71        workspace.register_action(
72            |workspace, _: &git_actions::ViewUncommittedChanges, window, cx| {
73                Self::deploy_at(workspace, None, window, cx);
74            },
75        );
76        workspace.register_action(
77            |workspace, _: &git_actions::ViewUnstagedChanges, window, cx| {
78                UnstagedDiff::deploy_at(workspace, None, window, cx);
79            },
80        );
81        workspace.register_action(
82            |workspace, _: &git_actions::ViewStagedChanges, window, cx| {
83                StagedDiff::deploy_at(workspace, None, window, cx);
84            },
85        );
86        workspace.register_action(|workspace, _: &Add, window, cx| {
87            Self::deploy(workspace, &Diff, window, cx);
88        });
89        workspace::register_serializable_item::<ProjectDiff>(cx);
90    }
91
92    fn deploy(
93        workspace: &mut Workspace,
94        _: &Diff,
95        window: &mut Window,
96        cx: &mut Context<Workspace>,
97    ) {
98        Self::deploy_at(workspace, None, window, cx)
99    }
100
101    pub fn deploy_at(
102        workspace: &mut Workspace,
103        entry: Option<GitStatusEntry>,
104        window: &mut Window,
105        cx: &mut Context<Workspace>,
106    ) {
107        telemetry::event!(
108            "Git Diff Opened",
109            source = if entry.is_some() {
110                "Git Panel"
111            } else {
112                "Action"
113            }
114        );
115        let intended_repo = workspace.project().read(cx).active_repository(cx);
116
117        let existing = workspace.items_of_type::<Self>(cx).next();
118        let project_diff = if let Some(existing) = existing {
119            existing.update(cx, |project_diff, cx| {
120                project_diff.move_to_beginning(window, cx);
121            });
122
123            workspace.activate_item(&existing, true, true, window, cx);
124            existing
125        } else {
126            let workspace_handle = cx.entity();
127            let project_diff =
128                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
129            workspace.add_item_to_active_pane(
130                Box::new(project_diff.clone()),
131                None,
132                true,
133                window,
134                cx,
135            );
136            project_diff
137        };
138
139        if let Some(intended) = &intended_repo {
140            let needs_switch = project_diff
141                .read(cx)
142                .repo(cx)
143                .map_or(true, |current| current.read(cx).id != intended.read(cx).id);
144            if needs_switch {
145                project_diff.update(cx, |project_diff, cx| {
146                    project_diff.set_repo(Some(intended.clone()), cx);
147                });
148            }
149        }
150
151        if let Some(entry) = entry {
152            project_diff.update(cx, |project_diff, cx| {
153                project_diff.move_to_entry(entry, window, cx);
154            })
155        }
156    }
157
158    pub fn deploy_at_project_path(
159        workspace: &mut Workspace,
160        project_path: ProjectPath,
161        window: &mut Window,
162        cx: &mut Context<Workspace>,
163    ) {
164        telemetry::event!("Git Diff Opened", source = "Agent Panel");
165        let existing = workspace.items_of_type::<Self>(cx).next();
166        let project_diff = if let Some(existing) = existing {
167            workspace.activate_item(&existing, true, true, window, cx);
168            existing
169        } else {
170            let workspace_handle = cx.entity();
171            let project_diff =
172                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
173            workspace.add_item_to_active_pane(
174                Box::new(project_diff.clone()),
175                None,
176                true,
177                window,
178                cx,
179            );
180            project_diff
181        };
182        project_diff.update(cx, |project_diff, cx| {
183            project_diff.move_to_project_path(&project_path, window, cx);
184        });
185    }
186
187    pub fn autoscroll(&self, cx: &mut Context<Self>) {
188        self.diff.update(cx, |diff, cx| diff.autoscroll(cx));
189    }
190
191    fn new(
192        project: Entity<Project>,
193        workspace: Entity<Workspace>,
194        window: &mut Window,
195        cx: &mut Context<Self>,
196    ) -> Self {
197        let branch_diff = cx.new(|cx| {
198            diff_buffer_list::DiffBufferList::new(DiffBase::Head, project.clone(), window, cx)
199        });
200        Self::new_impl(branch_diff, project, workspace, window, cx)
201    }
202
203    fn new_impl(
204        branch_diff: Entity<diff_buffer_list::DiffBufferList>,
205        project: Entity<Project>,
206        workspace: Entity<Workspace>,
207        window: &mut Window,
208        cx: &mut Context<Self>,
209    ) -> Self {
210        let workspace_handle = workspace.downgrade();
211        let diff = cx.new(|cx| {
212            DiffMultibuffer::new(
213                branch_diff,
214                Capability::ReadWrite,
215                "No uncommitted changes",
216                move |editor, cx| {
217                    editor.set_diff_hunk_delegate(Some(Arc::new(UncommittedDiffHunkDelegate)), cx);
218                    editor.rhs_editor().update(cx, |rhs_editor, _cx| {
219                        rhs_editor.set_read_only(false);
220                        rhs_editor.register_addon(GitPanelAddon {
221                            workspace: workspace_handle,
222                        });
223                    });
224                },
225                project.clone(),
226                workspace.clone(),
227                window,
228                cx,
229            )
230        });
231        Self::from_diff(diff, project, workspace, cx)
232    }
233
234    fn from_diff(
235        diff: Entity<DiffMultibuffer>,
236        project: Entity<Project>,
237        workspace: Entity<Workspace>,
238        cx: &mut Context<Self>,
239    ) -> Self {
240        let observation = cx.observe(&diff, |_, _, cx| cx.notify());
241        Self {
242            project,
243            workspace: workspace.downgrade(),
244            diff,
245            _diff_observation: observation,
246        }
247    }
248
249    pub fn diff_base<'a>(&'a self, cx: &'a App) -> &'a DiffBase {
250        self.diff.read(cx).diff_base(cx)
251    }
252
253    pub(crate) fn repo(&self, cx: &App) -> Option<Entity<Repository>> {
254        self.diff.read(cx).repo(cx)
255    }
256
257    pub(crate) fn set_repo(&mut self, repo: Option<Entity<Repository>>, cx: &mut Context<Self>) {
258        self.diff
259            .update(cx, |diff, cx| diff.set_repo(repo.clone(), cx));
260    }
261
262    pub fn move_to_entry(
263        &mut self,
264        entry: GitStatusEntry,
265        window: &mut Window,
266        cx: &mut Context<Self>,
267    ) {
268        self.diff
269            .update(cx, |diff, cx| diff.move_to_entry(entry, window, cx));
270    }
271
272    pub fn move_to_project_path(
273        &mut self,
274        project_path: &ProjectPath,
275        window: &mut Window,
276        cx: &mut Context<Self>,
277    ) {
278        self.diff.update(cx, |diff, cx| {
279            diff.move_to_project_path(project_path, window, cx)
280        });
281    }
282
283    fn move_to_beginning(&mut self, window: &mut Window, cx: &mut Context<Self>) {
284        self.diff
285            .update(cx, |diff, cx| diff.move_to_beginning(window, cx));
286    }
287
288    pub fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
289        self.diff.read(cx).calculate_changed_lines(cx)
290    }
291
292    /// Returns the total count of review comments across all hunks/files.
293    pub fn total_review_comment_count(&self, cx: &App) -> usize {
294        self.diff.read(cx).total_review_comment_count()
295    }
296
297    /// Returns the splittable editor of the currently-shown diff view.
298    pub fn editor(&self, cx: &App) -> Entity<SplittableEditor> {
299        self.diff.read(cx).editor().clone()
300    }
301
302    /// Returns the multibuffer of the currently-shown diff view.
303    pub fn multibuffer(&self, cx: &App) -> Entity<MultiBuffer> {
304        self.diff.read(cx).multibuffer().clone()
305    }
306
307    fn button_states(&self, cx: &App) -> ButtonStates {
308        let diff = self.diff.read(cx);
309        let editor = diff.editor().read(cx).rhs_editor().clone();
310        let editor = editor.read(cx);
311        let snapshot = diff.multibuffer().read(cx).snapshot(cx);
312        let prev_next = snapshot.diff_hunks().nth(1).is_some();
313        let (selection, ranges) = diff.selected_ranges(cx);
314        let mut has_staged_hunks = false;
315        let mut has_unstaged_hunks = false;
316        for hunk in editor.diff_hunks_in_ranges(&ranges, &snapshot) {
317            match hunk.status.secondary {
318                DiffHunkSecondaryStatus::HasSecondaryHunk
319                | DiffHunkSecondaryStatus::SecondaryHunkAdditionPending => {
320                    has_unstaged_hunks = true;
321                }
322                DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk => {
323                    has_staged_hunks = true;
324                    has_unstaged_hunks = true;
325                }
326                DiffHunkSecondaryStatus::NoSecondaryHunk
327                | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending => {
328                    has_staged_hunks = true;
329                }
330            }
331        }
332        let mut stage_all = false;
333        let mut unstage_all = false;
334        self.workspace
335            .read_with(cx, |workspace, cx| {
336                if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
337                    let git_panel = git_panel.read(cx);
338                    stage_all = git_panel.can_stage_all();
339                    unstage_all = git_panel.can_unstage_all();
340                }
341            })
342            .ok();
343
344        ButtonStates {
345            stage: has_unstaged_hunks,
346            unstage: has_staged_hunks,
347            prev_next,
348            selection,
349            stage_all,
350            unstage_all,
351        }
352    }
353
354    #[cfg(any(test, feature = "test-support"))]
355    pub fn excerpt_paths(&self, cx: &App) -> Vec<std::sync::Arc<util::rel_path::RelPath>> {
356        self.diff.read(cx).excerpt_paths(cx)
357    }
358
359    #[cfg(any(test, feature = "test-support"))]
360    pub fn excerpt_file_paths(&self, cx: &App) -> Vec<String> {
361        self.diff.read(cx).excerpt_file_paths(cx)
362    }
363}
364
365struct ButtonStates {
366    stage: bool,
367    unstage: bool,
368    prev_next: bool,
369    selection: bool,
370    stage_all: bool,
371    unstage_all: bool,
372}
373
374impl EventEmitter<EditorEvent> for ProjectDiff {}
375
376impl Focusable for ProjectDiff {
377    fn focus_handle(&self, cx: &App) -> FocusHandle {
378        self.diff.read(cx).focus_handle(cx)
379    }
380}
381
382impl Item for ProjectDiff {
383    type Event = EditorEvent;
384
385    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
386        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
387    }
388
389    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
390        Editor::to_item_events(event, f)
391    }
392
393    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
394        self.diff
395            .update(cx, |diff, cx| diff.deactivated(window, cx));
396    }
397
398    fn navigate(
399        &mut self,
400        data: Arc<dyn Any + Send>,
401        window: &mut Window,
402        cx: &mut Context<Self>,
403    ) -> bool {
404        self.diff
405            .update(cx, |diff, cx| diff.navigate(data, window, cx))
406    }
407
408    fn tab_tooltip_text(&self, cx: &App) -> Option<SharedString> {
409        Some(self.tab_content_text(0, cx))
410    }
411
412    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
413        Label::new(self.tab_content_text(0, cx))
414            .color(if params.selected {
415                Color::Default
416            } else {
417                Color::Muted
418            })
419            .into_any_element()
420    }
421
422    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
423        "Uncommitted Changes".into()
424    }
425
426    fn telemetry_event_text(&self) -> Option<&'static str> {
427        Some("Project Diff Opened")
428    }
429
430    fn as_searchable(&self, _: &Entity<Self>, cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
431        Some(Box::new(self.diff.read(cx).editor().clone()))
432    }
433
434    fn for_each_project_item(
435        &self,
436        cx: &App,
437        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
438    ) {
439        self.diff.read(cx).for_each_project_item(cx, f)
440    }
441
442    fn active_project_path(&self, cx: &App) -> Option<ProjectPath> {
443        self.diff.read(cx).active_project_path(cx)
444    }
445
446    fn set_nav_history(
447        &mut self,
448        nav_history: ItemNavHistory,
449        _: &mut Window,
450        cx: &mut Context<Self>,
451    ) {
452        self.diff
453            .update(cx, |diff, cx| diff.set_nav_history(nav_history, cx));
454    }
455
456    fn can_split(&self) -> bool {
457        true
458    }
459
460    fn clone_on_split(
461        &self,
462        _workspace_id: Option<workspace::WorkspaceId>,
463        window: &mut Window,
464        cx: &mut Context<Self>,
465    ) -> Task<Option<Entity<Self>>>
466    where
467        Self: Sized,
468    {
469        let Some(workspace) = self.workspace.upgrade() else {
470            return Task::ready(None);
471        };
472        Task::ready(Some(cx.new(|cx| {
473            ProjectDiff::new(self.project.clone(), workspace, window, cx)
474        })))
475    }
476
477    fn is_dirty(&self, cx: &App) -> bool {
478        self.diff.read(cx).is_dirty(cx)
479    }
480
481    fn has_conflict(&self, cx: &App) -> bool {
482        self.diff.read(cx).has_conflict(cx)
483    }
484
485    fn can_save(&self, _: &App) -> bool {
486        true
487    }
488
489    fn save(
490        &mut self,
491        options: SaveOptions,
492        project: Entity<Project>,
493        window: &mut Window,
494        cx: &mut Context<Self>,
495    ) -> Task<Result<()>> {
496        self.diff
497            .update(cx, |diff, cx| diff.save(options, project, window, cx))
498    }
499
500    fn save_as(
501        &mut self,
502        _: Entity<Project>,
503        _: ProjectPath,
504        _window: &mut Window,
505        _: &mut Context<Self>,
506    ) -> Task<Result<()>> {
507        unreachable!()
508    }
509
510    fn reload(
511        &mut self,
512        project: Entity<Project>,
513        window: &mut Window,
514        cx: &mut Context<Self>,
515    ) -> Task<Result<()>> {
516        self.diff
517            .update(cx, |diff, cx| diff.reload(project, window, cx))
518    }
519
520    fn act_as_type<'a>(
521        &'a self,
522        type_id: TypeId,
523        self_handle: &'a Entity<Self>,
524        cx: &'a App,
525    ) -> Option<gpui::AnyEntity> {
526        if type_id == TypeId::of::<Self>() {
527            Some(self_handle.clone().into())
528        } else if type_id == TypeId::of::<Editor>() {
529            Some(
530                self.diff
531                    .read(cx)
532                    .editor()
533                    .read(cx)
534                    .rhs_editor()
535                    .clone()
536                    .into(),
537            )
538        } else if type_id == TypeId::of::<SplittableEditor>() {
539            Some(self.diff.read(cx).editor().clone().into())
540        } else if type_id == TypeId::of::<diff_buffer_list::DiffBufferList>() {
541            Some(self.diff.read(cx).branch_diff().clone().into())
542        } else {
543            None
544        }
545    }
546
547    fn added_to_workspace(
548        &mut self,
549        workspace: &mut Workspace,
550        window: &mut Window,
551        cx: &mut Context<Self>,
552    ) {
553        self.diff.update(cx, |diff, cx| {
554            diff.added_to_workspace(workspace, window, cx)
555        });
556    }
557}
558
559impl Render for ProjectDiff {
560    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
561        div().size_full().child(self.diff.clone())
562    }
563}
564
565impl SerializableItem for ProjectDiff {
566    fn serialized_item_kind() -> &'static str {
567        "ProjectDiff"
568    }
569
570    fn cleanup(
571        _: workspace::WorkspaceId,
572        _: Vec<workspace::ItemId>,
573        _: &mut Window,
574        _: &mut App,
575    ) -> Task<Result<()>> {
576        Task::ready(Ok(()))
577    }
578
579    fn deserialize(
580        project: Entity<Project>,
581        workspace: WeakEntity<Workspace>,
582        _workspace_id: workspace::WorkspaceId,
583        _item_id: workspace::ItemId,
584        window: &mut Window,
585        cx: &mut App,
586    ) -> Task<Result<Entity<Self>>> {
587        window.spawn(cx, async move |cx| {
588            cx.update(|window, cx| {
589                let branch_diff = cx.new(|cx| {
590                    diff_buffer_list::DiffBufferList::new(
591                        DiffBase::Head,
592                        project.clone(),
593                        window,
594                        cx,
595                    )
596                });
597                let workspace = workspace.upgrade().context("workspace gone")?;
598                anyhow::Ok(
599                    cx.new(|cx| ProjectDiff::new_impl(branch_diff, project, workspace, window, cx)),
600                )
601            })?
602        })
603    }
604
605    fn serialize(
606        &mut self,
607        _: &mut Workspace,
608        _: workspace::ItemId,
609        _: bool,
610        _: &mut Window,
611        _: &mut Context<Self>,
612    ) -> Option<Task<Result<()>>> {
613        Some(Task::ready(Ok(())))
614    }
615
616    fn should_serialize(&self, _: &Self::Event) -> bool {
617        false
618    }
619}
620
621pub(crate) mod persistence {
622
623    use anyhow::Context as _;
624    use db::{
625        sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
626        sqlez_macros::sql,
627    };
628    use project::git_store::diff_buffer_list::DiffBase;
629    use workspace::{ItemId, WorkspaceDb, WorkspaceId};
630
631    pub struct ProjectDiffDb(ThreadSafeConnection);
632
633    impl Domain for ProjectDiffDb {
634        const NAME: &str = stringify!(ProjectDiffDb);
635
636        // Legacy databases stored branch diffs under the "ProjectDiff" item
637        // kind, disambiguated by the `diff_base` column. Step 1 rewrites those
638        // item kinds so that each diff view owns its serialized kind.
639        const MIGRATIONS: &[&str] = &[
640            sql!(
641                CREATE TABLE project_diffs(
642                    workspace_id INTEGER,
643                    item_id INTEGER UNIQUE,
644
645                    diff_base TEXT,
646
647                    PRIMARY KEY(workspace_id, item_id),
648                    FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
649                    ON DELETE CASCADE
650                ) STRICT;
651            ),
652            r#"
653                UPDATE items SET kind = 'BranchDiff'
654                WHERE kind = 'ProjectDiff' AND EXISTS (
655                    SELECT 1 FROM project_diffs
656                    WHERE project_diffs.item_id = items.item_id
657                    AND project_diffs.workspace_id = items.workspace_id
658                    AND project_diffs.diff_base LIKE '{"Merge"%'
659                );
660            "#,
661        ];
662    }
663
664    db::static_connection!(ProjectDiffDb, [WorkspaceDb]);
665
666    impl ProjectDiffDb {
667        pub async fn save_project_diff_base(
668            &self,
669            item_id: ItemId,
670            workspace_id: WorkspaceId,
671            diff_base: DiffBase,
672        ) -> anyhow::Result<()> {
673            self.write(move |connection| {
674                let sql_stmt = sql!(
675                    INSERT OR REPLACE INTO project_diffs(item_id, workspace_id, diff_base) VALUES (?, ?, ?)
676                );
677                let diff_base_str = serde_json::to_string(&diff_base)?;
678                let mut query = connection.exec_bound::<(ItemId, WorkspaceId, String)>(sql_stmt)?;
679                query((item_id, workspace_id, diff_base_str)).context(format!(
680                    "exec_bound failed to execute or parse for: {}",
681                    sql_stmt
682                ))
683            })
684            .await
685        }
686
687        pub fn get_project_diff_base(
688            &self,
689            item_id: ItemId,
690            workspace_id: WorkspaceId,
691        ) -> anyhow::Result<DiffBase> {
692            let sql_stmt =
693                sql!(SELECT diff_base FROM project_diffs WHERE item_id =  ?AND workspace_id =  ?);
694            let diff_base_str = self.select_row_bound::<(ItemId, WorkspaceId), String>(sql_stmt)?(
695                (item_id, workspace_id),
696            )
697            .context(::std::format!(
698                "Error in get_diff_base, select_row_bound failed to execute or parse for: {}",
699                sql_stmt
700            ))?;
701            let Some(diff_base_str) = diff_base_str else {
702                return Ok(DiffBase::Head);
703            };
704            serde_json::from_str(&diff_base_str).context("deserializing diff base")
705        }
706    }
707}
708
709pub struct ProjectDiffToolbar {
710    project_diff: Option<WeakEntity<ProjectDiff>>,
711    workspace: WeakEntity<Workspace>,
712}
713
714impl ProjectDiffToolbar {
715    pub fn new(workspace: &Workspace, _: &mut Context<Self>) -> Self {
716        Self {
717            project_diff: None,
718            workspace: workspace.weak_handle(),
719        }
720    }
721
722    fn project_diff(&self, _: &App) -> Option<Entity<ProjectDiff>> {
723        self.project_diff.as_ref()?.upgrade()
724    }
725
726    fn dispatch_action(&self, action: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
727        if let Some(project_diff) = self.project_diff(cx) {
728            project_diff.focus_handle(cx).focus(window, cx);
729        }
730        let action = action.boxed_clone();
731        cx.defer(move |cx| {
732            cx.dispatch_action(action.as_ref());
733        })
734    }
735
736    fn stage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
737        self.workspace
738            .update(cx, |workspace, cx| {
739                if let Some(panel) = workspace.panel::<GitPanel>(cx) {
740                    panel.update(cx, |panel, cx| {
741                        panel.stage_all(&Default::default(), window, cx);
742                    });
743                }
744            })
745            .ok();
746    }
747
748    fn unstage_all(&mut self, window: &mut Window, cx: &mut Context<Self>) {
749        self.workspace
750            .update(cx, |workspace, cx| {
751                let Some(panel) = workspace.panel::<GitPanel>(cx) else {
752                    return;
753                };
754                panel.update(cx, |panel, cx| {
755                    panel.unstage_all(&Default::default(), window, cx);
756                });
757            })
758            .ok();
759    }
760}
761
762impl EventEmitter<ToolbarItemEvent> for ProjectDiffToolbar {}
763
764impl ToolbarItemView for ProjectDiffToolbar {
765    fn set_active_pane_item(
766        &mut self,
767        active_pane_item: Option<&dyn ItemHandle>,
768        _: &mut Window,
769        cx: &mut Context<Self>,
770    ) -> ToolbarItemLocation {
771        self.project_diff = active_pane_item
772            .and_then(|item| item.act_as::<ProjectDiff>(cx))
773            .map(|entity| entity.downgrade());
774        if self.project_diff.is_some() {
775            ToolbarItemLocation::PrimaryRight
776        } else {
777            ToolbarItemLocation::Hidden
778        }
779    }
780
781    fn pane_focus_update(
782        &mut self,
783        _pane_focused: bool,
784        _window: &mut Window,
785        _cx: &mut Context<Self>,
786    ) {
787    }
788}
789
790impl Render for ProjectDiffToolbar {
791    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
792        let Some(project_diff) = self.project_diff(cx) else {
793            return div();
794        };
795        let focus_handle = project_diff.focus_handle(cx);
796        let button_states = project_diff.read(cx).button_states(cx);
797        let review_count = project_diff.read(cx).total_review_comment_count(cx);
798
799        let (additions, deletions) = project_diff.read(cx).calculate_changed_lines(cx);
800        let is_multibuffer_empty = project_diff.read(cx).multibuffer(cx).read(cx).is_empty();
801
802        let stage_all_button_width = rems(5.);
803
804        h_flex()
805            .my_neg_1()
806            .py_1()
807            .gap_1p5()
808            .flex_wrap()
809            .justify_between()
810            .when(!is_multibuffer_empty, |this| {
811                this.child(DiffStat::new(
812                    "project-diff-stat",
813                    additions as usize,
814                    deletions as usize,
815                ))
816                .child(Divider::vertical().ml_1())
817            })
818            // n.b. the only reason these arrows are here is because we don't
819            // support "undo" for staging so we need a way to go back.
820            .child(
821                h_group_sm()
822                    .child(
823                        IconButton::new("up", IconName::ArrowUp)
824                            .icon_size(IconSize::Small)
825                            .disabled(!button_states.prev_next)
826                            .tooltip(Tooltip::for_action_title_in(
827                                "Go to Previous Hunk",
828                                &GoToPreviousHunk,
829                                &focus_handle,
830                            ))
831                            .on_click(cx.listener(|this, _, window, cx| {
832                                this.dispatch_action(&GoToPreviousHunk, window, cx)
833                            })),
834                    )
835                    .child(
836                        IconButton::new("down", IconName::ArrowDown)
837                            .icon_size(IconSize::Small)
838                            .disabled(!button_states.prev_next)
839                            .tooltip(Tooltip::for_action_title_in(
840                                "Go to Next Hunk",
841                                &GoToHunk,
842                                &focus_handle,
843                            ))
844                            .on_click(cx.listener(|this, _, window, cx| {
845                                this.dispatch_action(&GoToHunk, window, cx)
846                            })),
847                    ),
848            )
849            .child(Divider::vertical())
850            .child(
851                h_group_sm()
852                    .when(button_states.selection, |this| {
853                        this.child(
854                            Button::new("stage", "Toggle Staged")
855                                .tooltip(Tooltip::for_action_title_in(
856                                    "Toggle Staged",
857                                    &ToggleStaged,
858                                    &focus_handle,
859                                ))
860                                .disabled(!button_states.stage && !button_states.unstage)
861                                .on_click(cx.listener(|this, _, window, cx| {
862                                    this.dispatch_action(&ToggleStaged, window, cx)
863                                })),
864                        )
865                    })
866                    .when(!button_states.selection, |this| {
867                        this.child(
868                            Button::new("stage", "Stage")
869                                .disabled(!button_states.stage)
870                                .tooltip(Tooltip::for_action_title_in(
871                                    "Stage and Go to Next Hunk",
872                                    &StageAndNext,
873                                    &focus_handle,
874                                ))
875                                .on_click(cx.listener(|this, _, window, cx| {
876                                    this.dispatch_action(&StageAndNext, window, cx)
877                                })),
878                        )
879                        .child(
880                            Button::new("unstage", "Unstage")
881                                .disabled(!button_states.unstage)
882                                .tooltip(Tooltip::for_action_title_in(
883                                    "Unstage and Go to Next Hunk",
884                                    &UnstageAndNext,
885                                    &focus_handle,
886                                ))
887                                .on_click(cx.listener(|this, _, window, cx| {
888                                    this.dispatch_action(&UnstageAndNext, window, cx)
889                                })),
890                        )
891                    }),
892            )
893            .child(Divider::vertical())
894            .when(
895                button_states.unstage_all && !button_states.stage_all,
896                |this| {
897                    this.child(
898                        Button::new("unstage-all", "Unstage All")
899                            .width(stage_all_button_width)
900                            .tooltip(Tooltip::for_action_title_in(
901                                "Unstage All Changes",
902                                &UnstageAll,
903                                &focus_handle,
904                            ))
905                            .on_click(
906                                cx.listener(|this, _, window, cx| this.unstage_all(window, cx)),
907                            ),
908                    )
909                },
910            )
911            .when(
912                !button_states.unstage_all || button_states.stage_all,
913                |this| {
914                    this.child(
915                        Button::new("stage-all", "Stage All")
916                            .width(stage_all_button_width)
917                            .disabled(!button_states.stage_all)
918                            .tooltip(Tooltip::for_action_title_in(
919                                "Stage All Changes",
920                                &StageAll,
921                                &focus_handle,
922                            ))
923                            .on_click(
924                                cx.listener(|this, _, window, cx| this.stage_all(window, cx)),
925                            ),
926                    )
927                },
928            )
929            .child(Divider::vertical())
930            .child(
931                Button::new("commit", "Commit")
932                    .tooltip(Tooltip::for_action_title_in(
933                        "Commit",
934                        &Commit,
935                        &focus_handle,
936                    ))
937                    .on_click(cx.listener(|this, _, window, cx| {
938                        this.dispatch_action(&Commit, window, cx);
939                    })),
940            )
941            .when(review_count > 0, |el| {
942                el.child(Divider::vertical()).child(
943                    render_send_review_to_agent_button(review_count, &focus_handle).on_click(
944                        cx.listener(|this, _, window, cx| {
945                            this.dispatch_action(&SendReviewToAgent, window, cx)
946                        }),
947                    ),
948                )
949            })
950    }
951}
952
953pub(crate) fn render_send_review_to_agent_button(
954    review_count: usize,
955    focus_handle: &FocusHandle,
956) -> Button {
957    Button::new(
958        "send-review",
959        format!("Send Review to Agent ({})", review_count),
960    )
961    .start_icon(
962        Icon::new(IconName::OmegaAssistant)
963            .size(IconSize::Small)
964            .color(Color::Muted),
965    )
966    .tooltip(Tooltip::for_action_title_in(
967        "Send all review comments to the Agent panel",
968        &SendReviewToAgent,
969        focus_handle,
970    ))
971}
972
973#[cfg(test)]
974mod tests {
975    use buffer_diff::DiffHunkSecondaryStatus;
976    use db::indoc;
977    use editor::test::editor_test_context::{EditorTestContext, assert_state_with_diff};
978    use gpui::TestAppContext;
979    use multi_buffer::PathKey;
980    use project::FakeFs;
981    use serde_json::json;
982    use settings::{DiffViewStyle, GitPanelGroupBy, GitPanelSortBy, SettingsStore};
983    use std::path::Path;
984    use unindent::Unindent as _;
985    use util::{path, rel_path::rel_path};
986
987    use workspace::MultiWorkspace;
988
989    use super::*;
990
991    #[ctor::ctor(unsafe)]
992    fn init_logger() {
993        zlog::init_test();
994    }
995
996    fn init_test(cx: &mut TestAppContext) {
997        cx.update(|cx| {
998            let store = SettingsStore::test(cx);
999            cx.set_global(store);
1000            cx.update_global::<SettingsStore, _>(|store, cx| {
1001                store.update_user_settings(cx, |settings| {
1002                    settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
1003                });
1004            });
1005            theme_settings::init(theme::LoadThemes::JustBase, cx);
1006            editor::init(cx);
1007            crate::init(cx);
1008        });
1009    }
1010
1011    use zed_actions::git as git_actions;
1012
1013    use crate::project_diff::{self, ProjectDiff};
1014
1015    #[test]
1016    fn test_legacy_branch_diff_rows_migrate_to_their_own_kind() {
1017        use db::sqlez::{
1018            connection::Connection,
1019            domain::{Domain as _, Migrator as _},
1020        };
1021
1022        let connection = Connection::open_memory(Some(
1023            "test_legacy_branch_diff_rows_migrate_to_their_own_kind",
1024        ));
1025        connection.exec("PRAGMA foreign_keys = OFF").unwrap()().unwrap();
1026        workspace::WorkspaceDb::migrate(&connection).unwrap();
1027        connection
1028            .migrate(
1029                persistence::ProjectDiffDb::NAME,
1030                &persistence::ProjectDiffDb::MIGRATIONS[..1],
1031                &mut |_, _, _| false,
1032            )
1033            .unwrap();
1034
1035        connection
1036            .exec(
1037                "INSERT INTO workspaces(workspace_id) VALUES (1);
1038                INSERT INTO panes(pane_id, workspace_id, active) VALUES (1, 1, 1);
1039                INSERT INTO items(item_id, workspace_id, pane_id, kind, position, active) VALUES
1040                    (1, 1, 1, 'ProjectDiff', 0, 1),
1041                    (2, 1, 1, 'ProjectDiff', 1, 0)",
1042            )
1043            .unwrap()()
1044        .unwrap();
1045        let head = serde_json::to_string(&DiffBase::Head).unwrap();
1046        let merge = serde_json::to_string(&DiffBase::Merge {
1047            base_ref: "main".into(),
1048        })
1049        .unwrap();
1050        connection
1051            .exec_bound::<(String, String)>(
1052                "INSERT INTO project_diffs(workspace_id, item_id, diff_base) VALUES (1, 1, ?), (1, 2, ?)",
1053            )
1054            .unwrap()((head, merge))
1055        .unwrap();
1056
1057        persistence::ProjectDiffDb::migrate(&connection).unwrap();
1058
1059        let kinds = connection
1060            .select::<(i64, String)>("SELECT item_id, kind FROM items ORDER BY item_id")
1061            .unwrap()()
1062        .unwrap();
1063        assert_eq!(
1064            kinds,
1065            [
1066                (1, "ProjectDiff".to_string()),
1067                (2, "BranchDiff".to_string())
1068            ]
1069        );
1070    }
1071
1072    #[gpui::test]
1073    async fn test_update_on_uncommit(cx: &mut TestAppContext) {
1074        init_test(cx);
1075
1076        let fs = FakeFs::new(cx.executor());
1077        fs.insert_tree(
1078            path!("/project"),
1079            json!({
1080                ".git": {},
1081                "README.md": "# My cool project\n".to_owned()
1082            }),
1083        )
1084        .await;
1085        fs.set_head_and_index_for_repo(
1086            Path::new(path!("/project/.git")),
1087            &[("README.md", "# My cool project\n".to_owned())],
1088        );
1089        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
1090        let worktree_id = project.read_with(cx, |project, cx| {
1091            project.worktrees(cx).next().unwrap().read(cx).id()
1092        });
1093        let (multi_workspace, cx) =
1094            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1095        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1096        cx.run_until_parked();
1097
1098        let _editor = workspace
1099            .update_in(cx, |workspace, window, cx| {
1100                workspace.open_path((worktree_id, rel_path("README.md")), None, true, window, cx)
1101            })
1102            .await
1103            .unwrap()
1104            .downcast::<Editor>()
1105            .unwrap();
1106
1107        cx.focus(&workspace);
1108        cx.update(|window, cx| {
1109            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1110        });
1111        cx.run_until_parked();
1112        let item = workspace.update(cx, |workspace, cx| {
1113            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1114        });
1115        cx.focus(&item);
1116        let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone());
1117
1118        fs.set_head_and_index_for_repo(
1119            Path::new(path!("/project/.git")),
1120            &[(
1121                "README.md",
1122                "# My cool project\nDetails to come.\n".to_owned(),
1123            )],
1124        );
1125        cx.run_until_parked();
1126
1127        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1128
1129        cx.assert_excerpts_with_selections("[EXCERPT]\nˇ# My cool project\nDetails to come.\n");
1130    }
1131
1132    #[gpui::test]
1133    async fn test_deploy_at_respects_active_repository_selection(cx: &mut TestAppContext) {
1134        init_test(cx);
1135
1136        let fs = FakeFs::new(cx.executor());
1137        fs.insert_tree(
1138            path!("/project_a"),
1139            json!({
1140                ".git": {},
1141                "a.txt": "CHANGED_A\n",
1142            }),
1143        )
1144        .await;
1145        fs.insert_tree(
1146            path!("/project_b"),
1147            json!({
1148                ".git": {},
1149                "b.txt": "CHANGED_B\n",
1150            }),
1151        )
1152        .await;
1153
1154        fs.set_head_and_index_for_repo(
1155            Path::new(path!("/project_a/.git")),
1156            &[("a.txt", "original_a\n".to_string())],
1157        );
1158        fs.set_head_and_index_for_repo(
1159            Path::new(path!("/project_b/.git")),
1160            &[("b.txt", "original_b\n".to_string())],
1161        );
1162
1163        let project = Project::test(
1164            fs.clone(),
1165            [
1166                Path::new(path!("/project_a")),
1167                Path::new(path!("/project_b")),
1168            ],
1169            cx,
1170        )
1171        .await;
1172
1173        let (worktree_a_id, worktree_b_id) = project.read_with(cx, |project, cx| {
1174            let mut worktrees: Vec<_> = project.worktrees(cx).collect();
1175            worktrees.sort_by_key(|w| w.read(cx).abs_path());
1176            (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
1177        });
1178
1179        let (multi_workspace, cx) =
1180            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1181        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1182        cx.run_until_parked();
1183
1184        // Select project A explicitly and open the diff.
1185        workspace.update(cx, |workspace, cx| {
1186            let git_store = workspace.project().read(cx).git_store().clone();
1187            git_store.update(cx, |git_store, cx| {
1188                git_store.set_active_repo_for_worktree(worktree_a_id, cx);
1189            });
1190        });
1191        cx.focus(&workspace);
1192        cx.update(|window, cx| {
1193            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1194        });
1195        cx.run_until_parked();
1196
1197        let diff_item = workspace.update(cx, |workspace, cx| {
1198            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1199        });
1200        let paths_a = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
1201        assert_eq!(paths_a.len(), 1);
1202        assert_eq!(*paths_a[0], *"a.txt");
1203
1204        // Switch the explicit active repository to project B and re-run the diff action.
1205        workspace.update(cx, |workspace, cx| {
1206            let git_store = workspace.project().read(cx).git_store().clone();
1207            git_store.update(cx, |git_store, cx| {
1208                git_store.set_active_repo_for_worktree(worktree_b_id, cx);
1209            });
1210        });
1211        cx.focus(&workspace);
1212        cx.update(|window, cx| {
1213            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1214        });
1215        cx.run_until_parked();
1216
1217        let same_diff_item = workspace.update(cx, |workspace, cx| {
1218            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1219        });
1220        assert_eq!(diff_item.entity_id(), same_diff_item.entity_id());
1221
1222        let paths_b = diff_item.read_with(cx, |diff, cx| diff.excerpt_paths(cx));
1223        assert_eq!(paths_b.len(), 1);
1224        assert_eq!(*paths_b[0], *"b.txt");
1225    }
1226
1227    #[gpui::test]
1228    async fn test_project_diff_actions_filter_mixed_staged_and_unstaged_hunks(
1229        cx: &mut TestAppContext,
1230    ) {
1231        init_test(cx);
1232
1233        let committed_contents = r#"
1234            fn main() {
1235                println!("hello world");
1236            }
1237        "#
1238        .unindent();
1239        let staged_contents = r#"
1240            fn main() {
1241                println!("goodbye world");
1242            }
1243        "#
1244        .unindent();
1245        let file_contents = r#"
1246            // print goodbye
1247            fn main() {
1248                println!("goodbye world");
1249            }
1250        "#
1251        .unindent();
1252
1253        let fs = FakeFs::new(cx.executor());
1254        fs.insert_tree(
1255            path!("/project"),
1256            json!({
1257                ".git": {},
1258                "src": {
1259                    "main.rs": file_contents,
1260                }
1261            }),
1262        )
1263        .await;
1264
1265        fs.set_head_for_repo(
1266            Path::new(path!("/project/.git")),
1267            &[("src/main.rs", committed_contents)],
1268            "deadbeef",
1269        );
1270        fs.set_index_for_repo(
1271            Path::new(path!("/project/.git")),
1272            &[("src/main.rs", staged_contents)],
1273        );
1274
1275        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
1276        let (multi_workspace, cx) =
1277            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1278        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1279        cx.run_until_parked();
1280
1281        cx.focus(&workspace);
1282        cx.update(|window, cx| {
1283            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1284        });
1285        cx.run_until_parked();
1286
1287        let diff_item = workspace.update(cx, |workspace, cx| {
1288            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1289        });
1290        let diff_editor =
1291            diff_item.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
1292        assert_eq!(
1293            diff_editor.read_with(cx, |editor, cx| {
1294                let snapshot = editor.buffer().read(cx).snapshot(cx);
1295                editor
1296                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
1297                    .map(|hunk| hunk.status.secondary)
1298                    .collect::<Vec<_>>()
1299            }),
1300            vec![
1301                DiffHunkSecondaryStatus::HasSecondaryHunk,
1302                DiffHunkSecondaryStatus::NoSecondaryHunk,
1303            ]
1304        );
1305
1306        cx.focus(&workspace);
1307        cx.update(|window, cx| {
1308            window.dispatch_action(git_actions::ViewUnstagedChanges.boxed_clone(), cx);
1309        });
1310        cx.run_until_parked();
1311
1312        let unstaged_item = workspace.update(cx, |workspace, cx| {
1313            workspace.active_item_as::<UnstagedDiff>(cx).unwrap()
1314        });
1315        assert_ne!(diff_item.entity_id(), unstaged_item.entity_id());
1316        let unstaged_editor = workspace.update(cx, |workspace, cx| {
1317            let active_item = workspace.active_item(cx).unwrap();
1318            assert_eq!(active_item.tab_content_text(0, cx), "Unstaged Changes");
1319            active_item
1320                .act_as::<DiffMultibuffer>(cx)
1321                .unwrap()
1322                .read(cx)
1323                .editor()
1324                .read(cx)
1325                .rhs_editor()
1326                .clone()
1327        });
1328        assert_eq!(
1329            unstaged_editor.read_with(cx, |editor, cx| {
1330                let snapshot = editor.buffer().read(cx).snapshot(cx);
1331                editor
1332                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
1333                    .map(|hunk| hunk.status.secondary)
1334                    .collect::<Vec<_>>()
1335            }),
1336            vec![DiffHunkSecondaryStatus::NoSecondaryHunk]
1337        );
1338
1339        cx.focus(&workspace);
1340        cx.update(|window, cx| {
1341            window.dispatch_action(git_actions::ViewUncommittedChanges.boxed_clone(), cx);
1342        });
1343        cx.run_until_parked();
1344
1345        let uncommitted_item = workspace.update(cx, |workspace, cx| {
1346            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1347        });
1348        assert_eq!(diff_item.entity_id(), uncommitted_item.entity_id());
1349        assert_eq!(
1350            uncommitted_item.read_with(cx, |diff, cx| diff.tab_content_text(0, cx)),
1351            "Uncommitted Changes"
1352        );
1353        let uncommitted_editor = uncommitted_item
1354            .read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
1355        assert_eq!(
1356            uncommitted_editor.read_with(cx, |editor, cx| {
1357                let snapshot = editor.buffer().read(cx).snapshot(cx);
1358                editor
1359                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
1360                    .map(|hunk| hunk.status.secondary)
1361                    .collect::<Vec<_>>()
1362            }),
1363            vec![
1364                DiffHunkSecondaryStatus::HasSecondaryHunk,
1365                DiffHunkSecondaryStatus::NoSecondaryHunk,
1366            ]
1367        );
1368
1369        cx.focus(&workspace);
1370        cx.update(|window, cx| {
1371            window.dispatch_action(git_actions::ViewStagedChanges.boxed_clone(), cx);
1372        });
1373        cx.run_until_parked();
1374
1375        let staged_editor = workspace.update(cx, |workspace, cx| {
1376            workspace.active_item_as::<StagedDiff>(cx).unwrap();
1377            let active_item = workspace.active_item(cx).unwrap();
1378            assert_eq!(active_item.tab_content_text(0, cx), "Staged Changes");
1379            active_item
1380                .act_as::<DiffMultibuffer>(cx)
1381                .unwrap()
1382                .read(cx)
1383                .editor()
1384                .read(cx)
1385                .rhs_editor()
1386                .clone()
1387        });
1388        assert_eq!(
1389            staged_editor.read_with(cx, |editor, cx| {
1390                let snapshot = editor.buffer().read(cx).snapshot(cx);
1391                editor
1392                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], &snapshot)
1393                    .map(|hunk| hunk.status.secondary)
1394                    .collect::<Vec<_>>()
1395            }),
1396            vec![DiffHunkSecondaryStatus::NoSecondaryHunk]
1397        );
1398    }
1399
1400    #[gpui::test]
1401    async fn test_go_to_prev_hunk_multibuffer(cx: &mut TestAppContext) {
1402        init_test(cx);
1403
1404        let fs = FakeFs::new(cx.executor());
1405        fs.insert_tree(
1406            path!("/a"),
1407            json!({
1408                ".git": {},
1409                "a.txt": "created\n",
1410                "b.txt": "really changed\n",
1411                "c.txt": "unchanged\n"
1412            }),
1413        )
1414        .await;
1415
1416        fs.set_head_and_index_for_repo(
1417            Path::new(path!("/a/.git")),
1418            &[
1419                ("b.txt", "before\n".to_string()),
1420                ("c.txt", "unchanged\n".to_string()),
1421                ("d.txt", "deleted\n".to_string()),
1422            ],
1423        );
1424
1425        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
1426        let (multi_workspace, cx) =
1427            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
1428        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1429
1430        cx.run_until_parked();
1431
1432        cx.focus(&workspace);
1433        cx.update(|window, cx| {
1434            window.dispatch_action(project_diff::Diff.boxed_clone(), cx);
1435        });
1436
1437        cx.run_until_parked();
1438
1439        let item = workspace.update(cx, |workspace, cx| {
1440            workspace.active_item_as::<ProjectDiff>(cx).unwrap()
1441        });
1442        cx.focus(&item);
1443        let editor = item.read_with(cx, |item, cx| item.editor(cx).read(cx).rhs_editor().clone());
1444
1445        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
1446
1447        cx.set_selections_state(indoc!(
1448            "
1449            before
1450            really changed
1451
1452            deleted
1453
1454            ˇcreated
1455        "
1456        ));
1457
1458        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1459
1460        cx.assert_excerpts_with_selections(indoc!(
1461            "
1462            [EXCERPT]
1463            before
1464            really changed
1465            [EXCERPT]
1466            ˇ[FOLDED]
1467            [EXCERPT]
1468            created
1469        "
1470        ));
1471
1472        cx.dispatch_action(editor::actions::GoToPreviousHunk);
1473
1474        cx.assert_excerpts_with_selections(indoc!(
1475            "
1476            [EXCERPT]
1477            ˇbefore
1478            really changed
1479            [EXCERPT]
1480            [FOLDED]
1481            [EXCERPT]
1482            created
1483        "
1484        ));
1485    }
1486
1487    #[gpui::test]
1488    async fn test_save_after_restore(cx: &mut TestAppContext) {
1489        init_test(cx);
1490
1491        let fs = FakeFs::new(cx.executor());
1492        fs.insert_tree(
1493            path!("/project"),
1494            json!({
1495                ".git": {},
1496                "foo.txt": "FOO\n",
1497            }),
1498        )
1499        .await;
1500        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1501
1502        fs.set_head_for_repo(
1503            path!("/project/.git").as_ref(),
1504            &[("foo.txt", "foo\n".into())],
1505            "deadbeef",
1506        );
1507        fs.set_index_for_repo(
1508            path!("/project/.git").as_ref(),
1509            &[("foo.txt", "foo\n".into())],
1510        );
1511
1512        let (multi_workspace, cx) =
1513            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1514        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1515        let diff = cx.new_window_entity(|window, cx| {
1516            ProjectDiff::new(project.clone(), workspace, window, cx)
1517        });
1518        cx.run_until_parked();
1519
1520        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
1521        assert_state_with_diff(
1522            &editor,
1523            cx,
1524            &"
1525                - ˇfoo
1526                + FOO
1527            "
1528            .unindent(),
1529        );
1530
1531        editor
1532            .update_in(cx, |editor, window, cx| {
1533                editor.git_restore(&Default::default(), window, cx);
1534                editor.save(SaveOptions::default(), project.clone(), window, cx)
1535            })
1536            .await
1537            .unwrap();
1538        cx.run_until_parked();
1539
1540        assert_state_with_diff(&editor, cx, &"ˇ".unindent());
1541
1542        let text = String::from_utf8(fs.read_file_sync("/project/foo.txt").unwrap()).unwrap();
1543        assert_eq!(text, "foo\n");
1544    }
1545
1546    #[gpui::test]
1547    async fn test_scroll_to_beginning_with_deletion(cx: &mut TestAppContext) {
1548        init_test(cx);
1549
1550        let fs = FakeFs::new(cx.executor());
1551        fs.insert_tree(
1552            path!("/project"),
1553            json!({
1554                ".git": {},
1555                "bar": "BAR\n",
1556                "foo": "FOO\n",
1557            }),
1558        )
1559        .await;
1560        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1561        let (multi_workspace, cx) =
1562            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1563        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1564        let diff = cx.new_window_entity(|window, cx| {
1565            ProjectDiff::new(project.clone(), workspace, window, cx)
1566        });
1567        cx.run_until_parked();
1568
1569        fs.set_head_and_index_for_repo(
1570            path!("/project/.git").as_ref(),
1571            &[("bar", "bar\n".into()), ("foo", "foo\n".into())],
1572        );
1573        cx.run_until_parked();
1574
1575        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1576            diff.diff.update(cx, |diff, cx| {
1577                diff.move_to_path(
1578                    PathKey::with_sort_prefix(2, rel_path("foo").into_arc()),
1579                    window,
1580                    cx,
1581                )
1582            });
1583            diff.editor(cx).read(cx).rhs_editor().clone()
1584        });
1585        assert_state_with_diff(
1586            &editor,
1587            cx,
1588            &"
1589                - bar
1590                + BAR
1591
1592                - ˇfoo
1593                + FOO
1594            "
1595            .unindent(),
1596        );
1597
1598        let editor = cx.update_window_entity(&diff, |diff, window, cx| {
1599            diff.diff.update(cx, |diff, cx| {
1600                diff.move_to_path(
1601                    PathKey::with_sort_prefix(2, rel_path("bar").into_arc()),
1602                    window,
1603                    cx,
1604                )
1605            });
1606            diff.editor(cx).read(cx).rhs_editor().clone()
1607        });
1608        assert_state_with_diff(
1609            &editor,
1610            cx,
1611            &"
1612                - ˇbar
1613                + BAR
1614
1615                - foo
1616                + FOO
1617            "
1618            .unindent(),
1619        );
1620    }
1621
1622    #[gpui::test]
1623    async fn test_hunks_after_restore_then_modify(cx: &mut TestAppContext) {
1624        init_test(cx);
1625
1626        let fs = FakeFs::new(cx.executor());
1627        fs.insert_tree(
1628            path!("/project"),
1629            json!({
1630                ".git": {},
1631                "foo": "modified\n",
1632            }),
1633        )
1634        .await;
1635        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1636        let (multi_workspace, cx) =
1637            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1638        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1639        fs.set_head_for_repo(
1640            path!("/project/.git").as_ref(),
1641            &[("foo", "original\n".into())],
1642            "deadbeef",
1643        );
1644
1645        let buffer = project
1646            .update(cx, |project, cx| {
1647                project.open_local_buffer(path!("/project/foo"), cx)
1648            })
1649            .await
1650            .unwrap();
1651        let buffer_editor = cx.new_window_entity(|window, cx| {
1652            Editor::for_buffer(buffer, Some(project.clone()), window, cx)
1653        });
1654        let diff = cx.new_window_entity(|window, cx| {
1655            ProjectDiff::new(project.clone(), workspace, window, cx)
1656        });
1657        cx.run_until_parked();
1658
1659        let diff_editor =
1660            diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
1661
1662        assert_state_with_diff(
1663            &diff_editor,
1664            cx,
1665            &"
1666                - ˇoriginal
1667                + modified
1668            "
1669            .unindent(),
1670        );
1671
1672        let prev_buffer_hunks =
1673            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1674                let snapshot = buffer_editor.snapshot(window, cx);
1675                let snapshot = &snapshot.buffer_snapshot();
1676                let prev_buffer_hunks = buffer_editor
1677                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot)
1678                    .collect::<Vec<_>>();
1679                buffer_editor.git_restore(&Default::default(), window, cx);
1680                prev_buffer_hunks
1681            });
1682        assert_eq!(prev_buffer_hunks.len(), 1);
1683        cx.run_until_parked();
1684
1685        let new_buffer_hunks =
1686            cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1687                let snapshot = buffer_editor.snapshot(window, cx);
1688                let snapshot = &snapshot.buffer_snapshot();
1689                buffer_editor
1690                    .diff_hunks_in_ranges(&[editor::Anchor::Min..editor::Anchor::Max], snapshot)
1691                    .collect::<Vec<_>>()
1692            });
1693        assert_eq!(new_buffer_hunks.as_slice(), &[]);
1694
1695        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1696            buffer_editor.set_text("different\n", window, cx);
1697            buffer_editor.save(
1698                SaveOptions {
1699                    format: false,
1700                    force_format: false,
1701                    autosave: false,
1702                },
1703                project.clone(),
1704                window,
1705                cx,
1706            )
1707        })
1708        .await
1709        .unwrap();
1710
1711        cx.run_until_parked();
1712
1713        cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
1714            buffer_editor.expand_all_diff_hunks(&Default::default(), window, cx);
1715        });
1716
1717        assert_state_with_diff(
1718            &buffer_editor,
1719            cx,
1720            &"
1721                - original
1722                + different
1723                  ˇ"
1724            .unindent(),
1725        );
1726
1727        assert_state_with_diff(
1728            &diff_editor,
1729            cx,
1730            &"
1731                - ˇoriginal
1732                + different
1733            "
1734            .unindent(),
1735        );
1736    }
1737
1738    #[gpui::test]
1739    async fn test_new_hunk_in_modified_file(cx: &mut TestAppContext) {
1740        init_test(cx);
1741
1742        let fs = FakeFs::new(cx.executor());
1743        fs.insert_tree(
1744            path!("/project"),
1745            json!({
1746                ".git": {},
1747                "foo.txt": "
1748                    one
1749                    two
1750                    three
1751                    four
1752                    five
1753                    six
1754                    seven
1755                    eight
1756                    nine
1757                    ten
1758                    ELEVEN
1759                    twelve
1760                ".unindent()
1761            }),
1762        )
1763        .await;
1764        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1765        let (multi_workspace, cx) =
1766            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1767        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1768        let diff = cx.new_window_entity(|window, cx| {
1769            ProjectDiff::new(project.clone(), workspace, window, cx)
1770        });
1771        cx.run_until_parked();
1772
1773        fs.set_head_and_index_for_repo(
1774            Path::new(path!("/project/.git")),
1775            &[(
1776                "foo.txt",
1777                "
1778                    one
1779                    two
1780                    three
1781                    four
1782                    five
1783                    six
1784                    seven
1785                    eight
1786                    nine
1787                    ten
1788                    eleven
1789                    twelve
1790                "
1791                .unindent(),
1792            )],
1793        );
1794        cx.run_until_parked();
1795
1796        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
1797
1798        assert_state_with_diff(
1799            &editor,
1800            cx,
1801            &"
1802                  ˇnine
1803                  ten
1804                - eleven
1805                + ELEVEN
1806                  twelve
1807            "
1808            .unindent(),
1809        );
1810
1811        // The project diff updates its excerpts when a new hunk appears in a buffer that already has a diff.
1812        let buffer = project
1813            .update(cx, |project, cx| {
1814                project.open_local_buffer(path!("/project/foo.txt"), cx)
1815            })
1816            .await
1817            .unwrap();
1818        buffer.update(cx, |buffer, cx| {
1819            buffer.edit_via_marked_text(
1820                &"
1821                    one
1822                    «TWO»
1823                    three
1824                    four
1825                    five
1826                    six
1827                    seven
1828                    eight
1829                    nine
1830                    ten
1831                    ELEVEN
1832                    twelve
1833                "
1834                .unindent(),
1835                None,
1836                cx,
1837            );
1838        });
1839        project
1840            .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
1841            .await
1842            .unwrap();
1843        cx.run_until_parked();
1844
1845        assert_state_with_diff(
1846            &editor,
1847            cx,
1848            &"
1849                  one
1850                - two
1851                + TWO
1852                  three
1853                  four
1854                  five
1855                  ˇnine
1856                  ten
1857                - eleven
1858                + ELEVEN
1859                  twelve
1860            "
1861            .unindent(),
1862        );
1863    }
1864
1865    #[gpui::test]
1866    async fn test_sort_by_name_tie_breaks_on_path(cx: &mut TestAppContext) {
1867        init_test(cx);
1868
1869        cx.update(|cx| {
1870            cx.update_global::<SettingsStore, _>(|store, cx| {
1871                store.update_user_settings(cx, |settings| {
1872                    let git_panel = settings.git_panel.get_or_insert_default();
1873                    git_panel.sort_by = Some(GitPanelSortBy::Name);
1874                    git_panel.group_by = Some(GitPanelGroupBy::None);
1875                });
1876            });
1877        });
1878
1879        let fs = FakeFs::new(cx.executor());
1880        fs.insert_tree(
1881            path!("/project"),
1882            json!({
1883                ".git": {},
1884                "lib": { "foo.rs": "LIB FOO\n" },
1885                "src": { "foo.rs": "SRC FOO\n" },
1886                "m.rs": "M\n",
1887            }),
1888        )
1889        .await;
1890        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1891        let (multi_workspace, cx) =
1892            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1893        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1894        let diff = cx.new_window_entity(|window, cx| {
1895            ProjectDiff::new(project.clone(), workspace, window, cx)
1896        });
1897        cx.run_until_parked();
1898
1899        fs.set_head_and_index_for_repo(
1900            path!("/project/.git").as_ref(),
1901            &[
1902                ("lib/foo.rs", "lib foo\n".into()),
1903                ("src/foo.rs", "src foo\n".into()),
1904                ("m.rs", "m\n".into()),
1905            ],
1906        );
1907        cx.run_until_parked();
1908
1909        // Sorted by file name, the two `foo.rs` files come before `m.rs`, and the
1910        // tie between them is broken by the full path (`lib/` before `src/`).
1911        // A plain path sort would instead order them `lib/foo.rs`, `m.rs`,
1912        // `src/foo.rs`.
1913        let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx));
1914        assert_eq!(paths, vec!["lib/foo.rs", "src/foo.rs", "m.rs"]);
1915    }
1916
1917    #[gpui::test]
1918    async fn test_tree_view_orders_directories_before_files(cx: &mut TestAppContext) {
1919        init_test(cx);
1920
1921        cx.update(|cx| {
1922            cx.update_global::<SettingsStore, _>(|store, cx| {
1923                store.update_user_settings(cx, |settings| {
1924                    let git_panel = settings.git_panel.get_or_insert_default();
1925                    git_panel.tree_view = Some(true);
1926                    git_panel.group_by = Some(GitPanelGroupBy::None);
1927                });
1928            });
1929        });
1930
1931        let fs = FakeFs::new(cx.executor());
1932        fs.insert_tree(
1933            path!("/project"),
1934            json!({
1935                ".git": {},
1936                "src": {
1937                    "a.rs": "A\n",
1938                    "m.rs": "M\n",
1939                    "sub": { "b.rs": "B\n" },
1940                },
1941            }),
1942        )
1943        .await;
1944        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
1945        let (multi_workspace, cx) =
1946            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1947        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
1948        let diff = cx.new_window_entity(|window, cx| {
1949            ProjectDiff::new(project.clone(), workspace, window, cx)
1950        });
1951        cx.run_until_parked();
1952
1953        fs.set_head_and_index_for_repo(
1954            path!("/project/.git").as_ref(),
1955            &[
1956                ("src/a.rs", "a\n".into()),
1957                ("src/m.rs", "m\n".into()),
1958                ("src/sub/b.rs", "b\n".into()),
1959            ],
1960        );
1961        cx.run_until_parked();
1962
1963        // In tree view the `src/sub/` directory sorts before the files directly
1964        // in `src/`. A plain path sort would interleave them as `src/a.rs`,
1965        // `src/m.rs`, `src/sub/b.rs`.
1966        let paths = diff.read_with(cx, |diff, cx| diff.excerpt_file_paths(cx));
1967        assert_eq!(paths, vec!["src/sub/b.rs", "src/a.rs", "src/m.rs"]);
1968    }
1969
1970    #[gpui::test]
1971    async fn test_excerpts_splitting_after_restoring_the_middle_excerpt(cx: &mut TestAppContext) {
1972        init_test(cx);
1973
1974        let git_contents = indoc! {r#"
1975            #[rustfmt::skip]
1976            fn main() {
1977                let x = 0.0; // this line will be removed
1978                // 1
1979                // 2
1980                // 3
1981                let y = 0.0; // this line will be removed
1982                // 1
1983                // 2
1984                // 3
1985                let arr = [
1986                    0.0, // this line will be removed
1987                    0.0, // this line will be removed
1988                    0.0, // this line will be removed
1989                    0.0, // this line will be removed
1990                ];
1991            }
1992        "#};
1993        let buffer_contents = indoc! {"
1994            #[rustfmt::skip]
1995            fn main() {
1996                // 1
1997                // 2
1998                // 3
1999                // 1
2000                // 2
2001                // 3
2002                let arr = [
2003                ];
2004            }
2005        "};
2006
2007        let fs = FakeFs::new(cx.executor());
2008        fs.insert_tree(
2009            path!("/a"),
2010            json!({
2011                ".git": {},
2012                "main.rs": buffer_contents,
2013            }),
2014        )
2015        .await;
2016
2017        fs.set_head_and_index_for_repo(
2018            Path::new(path!("/a/.git")),
2019            &[("main.rs", git_contents.to_owned())],
2020        );
2021
2022        let project = Project::test(fs, [Path::new(path!("/a"))], cx).await;
2023        let (multi_workspace, cx) =
2024            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
2025        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
2026
2027        cx.run_until_parked();
2028
2029        let diff = cx.new_window_entity(|window, cx| {
2030            ProjectDiff::new(project.clone(), workspace, window, cx)
2031        });
2032        cx.run_until_parked();
2033        let editor = diff.read_with(cx, |diff, cx| diff.editor(cx).read(cx).rhs_editor().clone());
2034
2035        let mut cx = EditorTestContext::for_editor_in(editor, cx).await;
2036
2037        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2038
2039        cx.dispatch_action(editor::actions::GoToHunk);
2040        cx.dispatch_action(editor::actions::GoToHunk);
2041        cx.dispatch_action(git::Restore);
2042        cx.dispatch_action(editor::actions::MoveToBeginning);
2043
2044        cx.assert_excerpts_with_selections(&format!("[EXCERPT]\nˇ{git_contents}"));
2045    }
2046}
2047
Served at tenant.openagents/omega Member data and write actions are omitted.