1use std::any::{Any, TypeId};
2
3use anyhow::Result;
4use collections::HashSet;
5use editor::{scroll::Autoscroll, Editor, EditorEvent};
6use feature_flags::FeatureFlagViewExt;
7use futures::StreamExt;
8use gpui::{
9 actions, AnyElement, AnyView, App, AppContext, AsyncWindowContext, Entity, EventEmitter,
10 FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
11};
12use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point};
13use multi_buffer::{MultiBuffer, PathKey};
14use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
15use theme::ActiveTheme;
16use ui::prelude::*;
17use util::ResultExt as _;
18use workspace::{
19 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
20 searchable::SearchableItemHandle,
21 ItemNavHistory, ToolbarItemLocation, Workspace,
22};
23
24use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
25
26actions!(git, [Diff]);
27
28pub(crate) struct ProjectDiff {
29 multibuffer: Entity<MultiBuffer>,
30 editor: Entity<Editor>,
31 project: Entity<Project>,
32 git_panel: Entity<GitPanel>,
33 git_state: Entity<GitState>,
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 change_set: Entity<BufferChangeSet>,
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
65 fn deploy(
66 workspace: &mut Workspace,
67 _: &Diff,
68 window: &mut Window,
69 cx: &mut Context<Workspace>,
70 ) {
71 Self::deploy_at(workspace, None, window, cx)
72 }
73
74 pub fn deploy_at(
75 workspace: &mut Workspace,
76 entry: Option<GitStatusEntry>,
77 window: &mut Window,
78 cx: &mut Context<Workspace>,
79 ) {
80 let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
81 workspace.activate_item(&existing, true, true, window, cx);
82 existing
83 } else {
84 let workspace_handle = cx.entity();
85 let project_diff = cx.new(|cx| {
86 Self::new(
87 workspace.project().clone(),
88 workspace_handle,
89 workspace.panel::<GitPanel>(cx).unwrap(),
90 window,
91 cx,
92 )
93 });
94 workspace.add_item_to_active_pane(
95 Box::new(project_diff.clone()),
96 None,
97 true,
98 window,
99 cx,
100 );
101 project_diff
102 };
103 if let Some(entry) = entry {
104 project_diff.update(cx, |project_diff, cx| {
105 project_diff.scroll_to(entry, window, cx);
106 })
107 }
108 }
109
110 fn new(
111 project: Entity<Project>,
112 workspace: Entity<Workspace>,
113 git_panel: Entity<GitPanel>,
114 window: &mut Window,
115 cx: &mut Context<Self>,
116 ) -> Self {
117 let focus_handle = cx.focus_handle();
118 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
119
120 let editor = cx.new(|cx| {
121 let mut diff_display_editor = Editor::for_multibuffer(
122 multibuffer.clone(),
123 Some(project.clone()),
124 true,
125 window,
126 cx,
127 );
128 diff_display_editor.set_expand_all_diff_hunks(cx);
129 diff_display_editor.register_addon(GitPanelAddon {
130 git_panel: git_panel.clone(),
131 });
132 diff_display_editor
133 });
134 cx.subscribe_in(&editor, window, Self::handle_editor_event)
135 .detach();
136
137 let git_state = project.read(cx).git_state().clone();
138 let git_state_subscription = cx.subscribe_in(
139 &git_state,
140 window,
141 move |this, _git_state, _event, _window, _cx| {
142 *this.update_needed.borrow_mut() = ();
143 },
144 );
145
146 let (mut send, recv) = postage::watch::channel::<()>();
147 let worker = window.spawn(cx, {
148 let this = cx.weak_entity();
149 |cx| Self::handle_status_updates(this, recv, cx)
150 });
151 // Kick of a refresh immediately
152 *send.borrow_mut() = ();
153
154 Self {
155 project,
156 git_state: git_state.clone(),
157 git_panel: git_panel.clone(),
158 workspace: workspace.downgrade(),
159 focus_handle,
160 editor,
161 multibuffer,
162 pending_scroll: None,
163 update_needed: send,
164 _task: worker,
165 _subscription: git_state_subscription,
166 }
167 }
168
169 pub fn scroll_to(
170 &mut self,
171 entry: GitStatusEntry,
172 window: &mut Window,
173 cx: &mut Context<Self>,
174 ) {
175 let Some(git_repo) = self.git_state.read(cx).active_repository() else {
176 return;
177 };
178 let repo = git_repo.read(cx);
179
180 let Some(abs_path) = repo
181 .repo_path_to_project_path(&entry.repo_path)
182 .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx))
183 else {
184 return;
185 };
186
187 let namespace = if repo.has_conflict(&entry.repo_path) {
188 CONFLICT_NAMESPACE
189 } else if entry.status.is_created() {
190 NEW_NAMESPACE
191 } else {
192 TRACKED_NAMESPACE
193 };
194
195 let path_key = PathKey::namespaced(namespace, &abs_path);
196
197 self.scroll_to_path(path_key, window, cx)
198 }
199
200 fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
201 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
202 self.editor.update(cx, |editor, cx| {
203 editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
204 s.select_ranges([position..position]);
205 })
206 })
207 } else {
208 self.pending_scroll = Some(path_key);
209 }
210 }
211
212 fn handle_editor_event(
213 &mut self,
214 editor: &Entity<Editor>,
215 event: &EditorEvent,
216 window: &mut Window,
217 cx: &mut Context<Self>,
218 ) {
219 match event {
220 EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
221 let anchor = editor.scroll_manager.anchor().anchor;
222 let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(anchor, cx)
223 else {
224 return;
225 };
226 let Some(project_path) = buffer
227 .read(cx)
228 .file()
229 .map(|file| (file.worktree_id(cx), file.path().clone()))
230 else {
231 return;
232 };
233 self.workspace
234 .update(cx, |workspace, cx| {
235 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
236 git_panel.update(cx, |git_panel, cx| {
237 git_panel.set_focused_path(project_path.into(), window, cx)
238 })
239 }
240 })
241 .ok();
242 }),
243 _ => {}
244 }
245 }
246
247 fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
248 let Some(repo) = self.git_state.read(cx).active_repository() else {
249 self.multibuffer.update(cx, |multibuffer, cx| {
250 multibuffer.clear(cx);
251 });
252 return vec![];
253 };
254
255 let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
256
257 let mut result = vec![];
258 repo.update(cx, |repo, cx| {
259 for entry in repo.status() {
260 if !entry.status.has_changes() {
261 continue;
262 }
263 let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
264 continue;
265 };
266 let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
267 continue;
268 };
269 let namespace = if repo.has_conflict(&entry.repo_path) {
270 CONFLICT_NAMESPACE
271 } else if entry.status.is_created() {
272 NEW_NAMESPACE
273 } else {
274 TRACKED_NAMESPACE
275 };
276 let path_key = PathKey::namespaced(namespace, &abs_path);
277
278 previous_paths.remove(&path_key);
279 let load_buffer = self
280 .project
281 .update(cx, |project, cx| project.open_buffer(project_path, cx));
282
283 let project = self.project.clone();
284 result.push(cx.spawn(|_, mut cx| async move {
285 let buffer = load_buffer.await?;
286 let changes = project
287 .update(&mut cx, |project, cx| {
288 project.open_uncommitted_changes(buffer.clone(), cx)
289 })?
290 .await?;
291 Ok(DiffBuffer {
292 path_key,
293 buffer,
294 change_set: changes,
295 })
296 }));
297 }
298 });
299 self.multibuffer.update(cx, |multibuffer, cx| {
300 for path in previous_paths {
301 multibuffer.remove_excerpts_for_path(path, cx);
302 }
303 });
304 result
305 }
306
307 fn register_buffer(
308 &mut self,
309 diff_buffer: DiffBuffer,
310 window: &mut Window,
311 cx: &mut Context<Self>,
312 ) {
313 let path_key = diff_buffer.path_key;
314 let buffer = diff_buffer.buffer;
315 let change_set = diff_buffer.change_set;
316
317 let snapshot = buffer.read(cx).snapshot();
318 let change_set = change_set.read(cx);
319 let diff_hunk_ranges = if change_set.base_text.is_none() {
320 vec![Point::zero()..snapshot.max_point()]
321 } else {
322 change_set
323 .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}