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