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