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