Skip to repository content2047 lines · 68.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:30:22.908Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
project_diff.rs
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