1use std::any::{Any, TypeId};
2
3use anyhow::Result;
4use buffer_diff::BufferDiff;
5use collections::HashSet;
6use editor::{scroll::Autoscroll, Editor, EditorEvent, ToPoint};
7use feature_flags::FeatureFlagViewExt;
8use futures::StreamExt;
9use gpui::{
10 actions, AnyElement, AnyView, App, AppContext as _, AsyncWindowContext, Entity, EventEmitter,
11 FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
12};
13use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point};
14use multi_buffer::{MultiBuffer, PathKey};
15use project::{git::GitStore, Project, ProjectPath};
16use theme::ActiveTheme;
17use ui::prelude::*;
18use util::ResultExt as _;
19use workspace::{
20 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
21 searchable::SearchableItemHandle,
22 ItemNavHistory, SerializableItem, ToolbarItemLocation, Workspace,
23};
24
25use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
26
27actions!(git, [Diff]);
28
29pub(crate) struct ProjectDiff {
30 multibuffer: Entity<MultiBuffer>,
31 editor: Entity<Editor>,
32 project: Entity<Project>,
33 git_store: Entity<GitStore>,
34 workspace: WeakEntity<Workspace>,
35 focus_handle: FocusHandle,
36 update_needed: postage::watch::Sender<()>,
37 pending_scroll: Option<PathKey>,
38
39 _task: Task<Result<()>>,
40 _subscription: Subscription,
41}
42
43struct DiffBuffer {
44 path_key: PathKey,
45 buffer: Entity<Buffer>,
46 diff: Entity<BufferDiff>,
47}
48
49const CONFLICT_NAMESPACE: &'static str = "0";
50const TRACKED_NAMESPACE: &'static str = "1";
51const NEW_NAMESPACE: &'static str = "2";
52
53impl ProjectDiff {
54 pub(crate) fn register(
55 _: &mut Workspace,
56 window: Option<&mut Window>,
57 cx: &mut Context<Workspace>,
58 ) {
59 let Some(window) = window else { return };
60 cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
61 workspace.register_action(Self::deploy);
62 });
63
64 workspace::register_serializable_item::<ProjectDiff>(cx);
65 }
66
67 fn deploy(
68 workspace: &mut Workspace,
69 _: &Diff,
70 window: &mut Window,
71 cx: &mut Context<Workspace>,
72 ) {
73 workspace.open_panel::<GitPanel>(window, cx);
74 Self::deploy_at(workspace, None, window, cx)
75 }
76
77 pub fn deploy_at(
78 workspace: &mut Workspace,
79 entry: Option<GitStatusEntry>,
80 window: &mut Window,
81 cx: &mut Context<Workspace>,
82 ) {
83 let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
84 workspace.activate_item(&existing, true, true, window, cx);
85 existing
86 } else {
87 let workspace_handle = cx.entity();
88 let project_diff =
89 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
90 workspace.add_item_to_active_pane(
91 Box::new(project_diff.clone()),
92 None,
93 true,
94 window,
95 cx,
96 );
97 project_diff
98 };
99 if let Some(entry) = entry {
100 project_diff.update(cx, |project_diff, cx| {
101 project_diff.scroll_to(entry, window, cx);
102 })
103 }
104 }
105
106 fn new(
107 project: Entity<Project>,
108 workspace: Entity<Workspace>,
109 window: &mut Window,
110 cx: &mut Context<Self>,
111 ) -> Self {
112 let focus_handle = cx.focus_handle();
113 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
114
115 let editor = cx.new(|cx| {
116 let mut diff_display_editor = Editor::for_multibuffer(
117 multibuffer.clone(),
118 Some(project.clone()),
119 true,
120 window,
121 cx,
122 );
123 diff_display_editor.set_distinguish_unstaged_diff_hunks();
124 diff_display_editor.set_expand_all_diff_hunks(cx);
125 diff_display_editor.register_addon(GitPanelAddon {
126 workspace: workspace.downgrade(),
127 });
128 diff_display_editor
129 });
130 cx.subscribe_in(&editor, window, Self::handle_editor_event)
131 .detach();
132
133 let git_store = project.read(cx).git_store().clone();
134 let git_store_subscription = cx.subscribe_in(
135 &git_store,
136 window,
137 move |this, _git_store, _event, _window, _cx| {
138 *this.update_needed.borrow_mut() = ();
139 },
140 );
141
142 let (mut send, recv) = postage::watch::channel::<()>();
143 let worker = window.spawn(cx, {
144 let this = cx.weak_entity();
145 |cx| Self::handle_status_updates(this, recv, cx)
146 });
147 // Kick of a refresh immediately
148 *send.borrow_mut() = ();
149
150 Self {
151 project,
152 git_store: git_store.clone(),
153 workspace: workspace.downgrade(),
154 focus_handle,
155 editor,
156 multibuffer,
157 pending_scroll: None,
158 update_needed: send,
159 _task: worker,
160 _subscription: git_store_subscription,
161 }
162 }
163
164 pub fn scroll_to(
165 &mut self,
166 entry: GitStatusEntry,
167 window: &mut Window,
168 cx: &mut Context<Self>,
169 ) {
170 let Some(git_repo) = self.git_store.read(cx).active_repository() else {
171 return;
172 };
173 let repo = git_repo.read(cx);
174
175 let namespace = if repo.has_conflict(&entry.repo_path) {
176 CONFLICT_NAMESPACE
177 } else if entry.status.is_created() {
178 NEW_NAMESPACE
179 } else {
180 TRACKED_NAMESPACE
181 };
182
183 let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
184
185 self.scroll_to_path(path_key, window, cx)
186 }
187
188 fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
189 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
190 self.editor.update(cx, |editor, cx| {
191 editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
192 s.select_ranges([position..position]);
193 })
194 })
195 } else {
196 self.pending_scroll = Some(path_key);
197 }
198 }
199
200 fn handle_editor_event(
201 &mut self,
202 editor: &Entity<Editor>,
203 event: &EditorEvent,
204 window: &mut Window,
205 cx: &mut Context<Self>,
206 ) {
207 match event {
208 EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
209 let anchor = editor.scroll_manager.anchor().anchor;
210 let multibuffer = self.multibuffer.read(cx);
211 let snapshot = multibuffer.snapshot(cx);
212 let mut point = anchor.to_point(&snapshot);
213 point.row = (point.row + 1).min(snapshot.max_row().0);
214
215 let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(point, cx)
216 else {
217 return;
218 };
219 let Some(project_path) = buffer
220 .read(cx)
221 .file()
222 .map(|file| (file.worktree_id(cx), file.path().clone()))
223 else {
224 return;
225 };
226 self.workspace
227 .update(cx, |workspace, cx| {
228 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
229 git_panel.update(cx, |git_panel, cx| {
230 git_panel.select_entry_by_path(project_path.into(), window, cx)
231 })
232 }
233 })
234 .ok();
235 }),
236 _ => {}
237 }
238 }
239
240 fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
241 let Some(repo) = self.git_store.read(cx).active_repository() else {
242 self.multibuffer.update(cx, |multibuffer, cx| {
243 multibuffer.clear(cx);
244 });
245 return vec![];
246 };
247
248 let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
249
250 let mut result = vec![];
251 repo.update(cx, |repo, cx| {
252 for entry in repo.status() {
253 if !entry.status.has_changes() {
254 continue;
255 }
256 let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
257 continue;
258 };
259 let namespace = if repo.has_conflict(&entry.repo_path) {
260 CONFLICT_NAMESPACE
261 } else if entry.status.is_created() {
262 NEW_NAMESPACE
263 } else {
264 TRACKED_NAMESPACE
265 };
266 let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
267
268 previous_paths.remove(&path_key);
269 let load_buffer = self
270 .project
271 .update(cx, |project, cx| project.open_buffer(project_path, cx));
272
273 let project = self.project.clone();
274 result.push(cx.spawn(|_, mut cx| async move {
275 let buffer = load_buffer.await?;
276 let changes = project
277 .update(&mut cx, |project, cx| {
278 project.open_uncommitted_diff(buffer.clone(), cx)
279 })?
280 .await?;
281 Ok(DiffBuffer {
282 path_key,
283 buffer,
284 diff: changes,
285 })
286 }));
287 }
288 });
289 self.multibuffer.update(cx, |multibuffer, cx| {
290 for path in previous_paths {
291 multibuffer.remove_excerpts_for_path(path, cx);
292 }
293 });
294 result
295 }
296
297 fn register_buffer(
298 &mut self,
299 diff_buffer: DiffBuffer,
300 window: &mut Window,
301 cx: &mut Context<Self>,
302 ) {
303 let path_key = diff_buffer.path_key;
304 let buffer = diff_buffer.buffer;
305 let diff = diff_buffer.diff;
306
307 let snapshot = buffer.read(cx).snapshot();
308 let diff = diff.read(cx);
309 let diff_hunk_ranges = if diff.base_text().is_none() {
310 vec![Point::zero()..snapshot.max_point()]
311 } else {
312 diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
313 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
314 .collect::<Vec<_>>()
315 };
316
317 self.multibuffer.update(cx, |multibuffer, cx| {
318 multibuffer.set_excerpts_for_path(
319 path_key.clone(),
320 buffer,
321 diff_hunk_ranges,
322 editor::DEFAULT_MULTIBUFFER_CONTEXT,
323 cx,
324 );
325 });
326 if self.multibuffer.read(cx).is_empty()
327 && self
328 .editor
329 .read(cx)
330 .focus_handle(cx)
331 .contains_focused(window, cx)
332 {
333 self.focus_handle.focus(window);
334 } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
335 self.editor.update(cx, |editor, cx| {
336 editor.focus_handle(cx).focus(window);
337 });
338 }
339 if self.pending_scroll.as_ref() == Some(&path_key) {
340 self.scroll_to_path(path_key, window, cx);
341 }
342 }
343
344 pub async fn handle_status_updates(
345 this: WeakEntity<Self>,
346 mut recv: postage::watch::Receiver<()>,
347 mut cx: AsyncWindowContext,
348 ) -> Result<()> {
349 while let Some(_) = recv.next().await {
350 let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
351 for buffer_to_load in buffers_to_load {
352 if let Some(buffer) = buffer_to_load.await.log_err() {
353 cx.update(|window, cx| {
354 this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
355 .ok();
356 })?;
357 }
358 }
359 this.update(&mut cx, |this, _| this.pending_scroll.take())?;
360 }
361
362 Ok(())
363 }
364}
365
366impl EventEmitter<EditorEvent> for ProjectDiff {}
367
368impl Focusable for ProjectDiff {
369 fn focus_handle(&self, cx: &App) -> FocusHandle {
370 if self.multibuffer.read(cx).is_empty() {
371 self.focus_handle.clone()
372 } else {
373 self.editor.focus_handle(cx)
374 }
375 }
376}
377
378impl Item for ProjectDiff {
379 type Event = EditorEvent;
380
381 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
382 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
383 }
384
385 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
386 Editor::to_item_events(event, f)
387 }
388
389 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
390 self.editor
391 .update(cx, |editor, cx| editor.deactivated(window, cx));
392 }
393
394 fn navigate(
395 &mut self,
396 data: Box<dyn Any>,
397 window: &mut Window,
398 cx: &mut Context<Self>,
399 ) -> bool {
400 self.editor
401 .update(cx, |editor, cx| editor.navigate(data, window, cx))
402 }
403
404 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
405 Some("Project Diff".into())
406 }
407
408 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
409 Label::new("Uncommitted Changes")
410 .color(if params.selected {
411 Color::Default
412 } else {
413 Color::Muted
414 })
415 .into_any_element()
416 }
417
418 fn telemetry_event_text(&self) -> Option<&'static str> {
419 Some("Project Diff Opened")
420 }
421
422 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
423 Some(Box::new(self.editor.clone()))
424 }
425
426 fn for_each_project_item(
427 &self,
428 cx: &App,
429 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
430 ) {
431 self.editor.for_each_project_item(cx, f)
432 }
433
434 fn is_singleton(&self, _: &App) -> bool {
435 false
436 }
437
438 fn set_nav_history(
439 &mut self,
440 nav_history: ItemNavHistory,
441 _: &mut Window,
442 cx: &mut Context<Self>,
443 ) {
444 self.editor.update(cx, |editor, _| {
445 editor.set_nav_history(Some(nav_history));
446 });
447 }
448
449 fn clone_on_split(
450 &self,
451 _workspace_id: Option<workspace::WorkspaceId>,
452 window: &mut Window,
453 cx: &mut Context<Self>,
454 ) -> Option<Entity<Self>>
455 where
456 Self: Sized,
457 {
458 let workspace = self.workspace.upgrade()?;
459 Some(cx.new(|cx| ProjectDiff::new(self.project.clone(), workspace, window, cx)))
460 }
461
462 fn is_dirty(&self, cx: &App) -> bool {
463 self.multibuffer.read(cx).is_dirty(cx)
464 }
465
466 fn has_conflict(&self, cx: &App) -> bool {
467 self.multibuffer.read(cx).has_conflict(cx)
468 }
469
470 fn can_save(&self, _: &App) -> bool {
471 true
472 }
473
474 fn save(
475 &mut self,
476 format: bool,
477 project: Entity<Project>,
478 window: &mut Window,
479 cx: &mut Context<Self>,
480 ) -> Task<Result<()>> {
481 self.editor.save(format, project, window, cx)
482 }
483
484 fn save_as(
485 &mut self,
486 _: Entity<Project>,
487 _: ProjectPath,
488 _window: &mut Window,
489 _: &mut Context<Self>,
490 ) -> Task<Result<()>> {
491 unreachable!()
492 }
493
494 fn reload(
495 &mut self,
496 project: Entity<Project>,
497 window: &mut Window,
498 cx: &mut Context<Self>,
499 ) -> Task<Result<()>> {
500 self.editor.reload(project, window, cx)
501 }
502
503 fn act_as_type<'a>(
504 &'a self,
505 type_id: TypeId,
506 self_handle: &'a Entity<Self>,
507 _: &'a App,
508 ) -> Option<AnyView> {
509 if type_id == TypeId::of::<Self>() {
510 Some(self_handle.to_any())
511 } else if type_id == TypeId::of::<Editor>() {
512 Some(self.editor.to_any())
513 } else {
514 None
515 }
516 }
517
518 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
519 ToolbarItemLocation::PrimaryLeft
520 }
521
522 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
523 self.editor.breadcrumbs(theme, cx)
524 }
525
526 fn added_to_workspace(
527 &mut self,
528 workspace: &mut Workspace,
529 window: &mut Window,
530 cx: &mut Context<Self>,
531 ) {
532 self.editor.update(cx, |editor, cx| {
533 editor.added_to_workspace(workspace, window, cx)
534 });
535 }
536}
537
538impl Render for ProjectDiff {
539 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
540 let is_empty = self.multibuffer.read(cx).is_empty();
541
542 div()
543 .track_focus(&self.focus_handle)
544 .bg(cx.theme().colors().editor_background)
545 .flex()
546 .items_center()
547 .justify_center()
548 .size_full()
549 .when(is_empty, |el| {
550 el.child(Label::new("No uncommitted changes"))
551 })
552 .when(!is_empty, |el| el.child(self.editor.clone()))
553 }
554}
555
556impl SerializableItem for ProjectDiff {
557 fn serialized_item_kind() -> &'static str {
558 "ProjectDiff"
559 }
560
561 fn cleanup(
562 _: workspace::WorkspaceId,
563 _: Vec<workspace::ItemId>,
564 _: &mut Window,
565 _: &mut App,
566 ) -> Task<Result<()>> {
567 Task::ready(Ok(()))
568 }
569
570 fn deserialize(
571 _project: Entity<Project>,
572 workspace: WeakEntity<Workspace>,
573 _workspace_id: workspace::WorkspaceId,
574 _item_id: workspace::ItemId,
575 window: &mut Window,
576 cx: &mut App,
577 ) -> Task<Result<Entity<Self>>> {
578 window.spawn(cx, |mut cx| async move {
579 workspace.update_in(&mut cx, |workspace, window, cx| {
580 let workspace_handle = cx.entity();
581 cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx))
582 })
583 })
584 }
585
586 fn serialize(
587 &mut self,
588 _workspace: &mut Workspace,
589 _item_id: workspace::ItemId,
590 _closing: bool,
591 _window: &mut Window,
592 _cx: &mut Context<Self>,
593 ) -> Option<Task<Result<()>>> {
594 None
595 }
596
597 fn should_serialize(&self, _: &Self::Event) -> bool {
598 false
599 }
600}