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 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_state = project.read(cx).git_state().clone();
141 let git_state_subscription = cx.subscribe_in(
142 &git_state,
143 window,
144 move |this, _git_state, _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_state: git_state.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_state_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_state.read(cx).active_repository() else {
179 return;
180 };
181 let repo = git_repo.read(cx);
182
183 let Some(abs_path) = repo
184 .repo_path_to_project_path(&entry.repo_path)
185 .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx))
186 else {
187 return;
188 };
189
190 let namespace = if repo.has_conflict(&entry.repo_path) {
191 CONFLICT_NAMESPACE
192 } else if entry.status.is_created() {
193 NEW_NAMESPACE
194 } else {
195 TRACKED_NAMESPACE
196 };
197
198 let path_key = PathKey::namespaced(namespace, &abs_path);
199
200 self.scroll_to_path(path_key, window, cx)
201 }
202
203 fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
204 if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
205 self.editor.update(cx, |editor, cx| {
206 editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
207 s.select_ranges([position..position]);
208 })
209 })
210 } else {
211 self.pending_scroll = Some(path_key);
212 }
213 }
214
215 fn handle_editor_event(
216 &mut self,
217 editor: &Entity<Editor>,
218 event: &EditorEvent,
219 window: &mut Window,
220 cx: &mut Context<Self>,
221 ) {
222 match event {
223 EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
224 let anchor = editor.scroll_manager.anchor().anchor;
225 let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(anchor, cx)
226 else {
227 return;
228 };
229 let Some(project_path) = buffer
230 .read(cx)
231 .file()
232 .map(|file| (file.worktree_id(cx), file.path().clone()))
233 else {
234 return;
235 };
236 self.workspace
237 .update(cx, |workspace, cx| {
238 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
239 git_panel.update(cx, |git_panel, cx| {
240 git_panel.select_entry_by_path(project_path.into(), window, cx)
241 })
242 }
243 })
244 .ok();
245 }),
246 _ => {}
247 }
248 }
249
250 fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
251 let Some(repo) = self.git_state.read(cx).active_repository() else {
252 self.multibuffer.update(cx, |multibuffer, cx| {
253 multibuffer.clear(cx);
254 });
255 return vec![];
256 };
257
258 let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
259
260 let mut result = vec![];
261 repo.update(cx, |repo, cx| {
262 for entry in repo.status() {
263 if !entry.status.has_changes() {
264 continue;
265 }
266 let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
267 continue;
268 };
269 let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
270 continue;
271 };
272 let namespace = if repo.has_conflict(&entry.repo_path) {
273 CONFLICT_NAMESPACE
274 } else if entry.status.is_created() {
275 NEW_NAMESPACE
276 } else {
277 TRACKED_NAMESPACE
278 };
279 let path_key = PathKey::namespaced(namespace, &abs_path);
280
281 previous_paths.remove(&path_key);
282 let load_buffer = self
283 .project
284 .update(cx, |project, cx| project.open_buffer(project_path, cx));
285
286 let project = self.project.clone();
287 result.push(cx.spawn(|_, mut cx| async move {
288 let buffer = load_buffer.await?;
289 let changes = project
290 .update(&mut cx, |project, cx| {
291 project.open_uncommitted_diff(buffer.clone(), cx)
292 })?
293 .await?;
294 Ok(DiffBuffer {
295 path_key,
296 buffer,
297 diff: changes,
298 })
299 }));
300 }
301 });
302 self.multibuffer.update(cx, |multibuffer, cx| {
303 for path in previous_paths {
304 multibuffer.remove_excerpts_for_path(path, cx);
305 }
306 });
307 result
308 }
309
310 fn register_buffer(
311 &mut self,
312 diff_buffer: DiffBuffer,
313 window: &mut Window,
314 cx: &mut Context<Self>,
315 ) {
316 let path_key = diff_buffer.path_key;
317 let buffer = diff_buffer.buffer;
318 let diff = diff_buffer.diff;
319
320 let snapshot = buffer.read(cx).snapshot();
321 let diff = diff.read(cx);
322 let diff_hunk_ranges = if diff.base_text().is_none() {
323 vec![Point::zero()..snapshot.max_point()]
324 } else {
325 diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
326 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
327 .collect::<Vec<_>>()
328 };
329
330 self.multibuffer.update(cx, |multibuffer, cx| {
331 multibuffer.set_excerpts_for_path(
332 path_key.clone(),
333 buffer,
334 diff_hunk_ranges,
335 editor::DEFAULT_MULTIBUFFER_CONTEXT,
336 cx,
337 );
338 });
339 if self.multibuffer.read(cx).is_empty()
340 && self
341 .editor
342 .read(cx)
343 .focus_handle(cx)
344 .contains_focused(window, cx)
345 {
346 self.focus_handle.focus(window);
347 } else if self.focus_handle.contains_focused(window, cx)
348 && !self.multibuffer.read(cx).is_empty()
349 {
350 self.editor.update(cx, |editor, cx| {
351 editor.focus_handle(cx).focus(window);
352 editor.move_to_beginning(&Default::default(), window, cx);
353 });
354 }
355 if self.pending_scroll.as_ref() == Some(&path_key) {
356 self.scroll_to_path(path_key, window, cx);
357 }
358 }
359
360 pub async fn handle_status_updates(
361 this: WeakEntity<Self>,
362 mut recv: postage::watch::Receiver<()>,
363 mut cx: AsyncWindowContext,
364 ) -> Result<()> {
365 while let Some(_) = recv.next().await {
366 let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
367 for buffer_to_load in buffers_to_load {
368 if let Some(buffer) = buffer_to_load.await.log_err() {
369 cx.update(|window, cx| {
370 this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
371 .ok();
372 })?;
373 }
374 }
375 this.update(&mut cx, |this, _| this.pending_scroll.take())?;
376 }
377
378 Ok(())
379 }
380}
381
382impl EventEmitter<EditorEvent> for ProjectDiff {}
383
384impl Focusable for ProjectDiff {
385 fn focus_handle(&self, cx: &App) -> FocusHandle {
386 if self.multibuffer.read(cx).is_empty() {
387 self.focus_handle.clone()
388 } else {
389 self.editor.focus_handle(cx)
390 }
391 }
392}
393
394impl Item for ProjectDiff {
395 type Event = EditorEvent;
396
397 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
398 Editor::to_item_events(event, f)
399 }
400
401 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
402 self.editor
403 .update(cx, |editor, cx| editor.deactivated(window, cx));
404 }
405
406 fn navigate(
407 &mut self,
408 data: Box<dyn Any>,
409 window: &mut Window,
410 cx: &mut Context<Self>,
411 ) -> bool {
412 self.editor
413 .update(cx, |editor, cx| editor.navigate(data, window, cx))
414 }
415
416 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
417 Some("Project Diff".into())
418 }
419
420 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
421 Label::new("Uncommitted Changes")
422 .color(if params.selected {
423 Color::Default
424 } else {
425 Color::Muted
426 })
427 .into_any_element()
428 }
429
430 fn telemetry_event_text(&self) -> Option<&'static str> {
431 Some("Project Diff Opened")
432 }
433
434 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
435 Some(Box::new(self.editor.clone()))
436 }
437
438 fn for_each_project_item(
439 &self,
440 cx: &App,
441 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
442 ) {
443 self.editor.for_each_project_item(cx, f)
444 }
445
446 fn is_singleton(&self, _: &App) -> bool {
447 false
448 }
449
450 fn set_nav_history(
451 &mut self,
452 nav_history: ItemNavHistory,
453 _: &mut Window,
454 cx: &mut Context<Self>,
455 ) {
456 self.editor.update(cx, |editor, _| {
457 editor.set_nav_history(Some(nav_history));
458 });
459 }
460
461 fn clone_on_split(
462 &self,
463 _workspace_id: Option<workspace::WorkspaceId>,
464 window: &mut Window,
465 cx: &mut Context<Self>,
466 ) -> Option<Entity<Self>>
467 where
468 Self: Sized,
469 {
470 let workspace = self.workspace.upgrade()?;
471 Some(cx.new(|cx| {
472 ProjectDiff::new(
473 self.project.clone(),
474 workspace,
475 self.git_panel.clone(),
476 window,
477 cx,
478 )
479 }))
480 }
481
482 fn is_dirty(&self, cx: &App) -> bool {
483 self.multibuffer.read(cx).is_dirty(cx)
484 }
485
486 fn has_conflict(&self, cx: &App) -> bool {
487 self.multibuffer.read(cx).has_conflict(cx)
488 }
489
490 fn can_save(&self, _: &App) -> bool {
491 true
492 }
493
494 fn save(
495 &mut self,
496 format: bool,
497 project: Entity<Project>,
498 window: &mut Window,
499 cx: &mut Context<Self>,
500 ) -> Task<Result<()>> {
501 self.editor.save(format, project, window, cx)
502 }
503
504 fn save_as(
505 &mut self,
506 _: Entity<Project>,
507 _: ProjectPath,
508 _window: &mut Window,
509 _: &mut Context<Self>,
510 ) -> Task<Result<()>> {
511 unreachable!()
512 }
513
514 fn reload(
515 &mut self,
516 project: Entity<Project>,
517 window: &mut Window,
518 cx: &mut Context<Self>,
519 ) -> Task<Result<()>> {
520 self.editor.reload(project, window, cx)
521 }
522
523 fn act_as_type<'a>(
524 &'a self,
525 type_id: TypeId,
526 self_handle: &'a Entity<Self>,
527 _: &'a App,
528 ) -> Option<AnyView> {
529 if type_id == TypeId::of::<Self>() {
530 Some(self_handle.to_any())
531 } else if type_id == TypeId::of::<Editor>() {
532 Some(self.editor.to_any())
533 } else {
534 None
535 }
536 }
537
538 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
539 ToolbarItemLocation::PrimaryLeft
540 }
541
542 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
543 self.editor.breadcrumbs(theme, cx)
544 }
545
546 fn added_to_workspace(
547 &mut self,
548 workspace: &mut Workspace,
549 window: &mut Window,
550 cx: &mut Context<Self>,
551 ) {
552 self.editor.update(cx, |editor, cx| {
553 editor.added_to_workspace(workspace, window, cx)
554 });
555 }
556}
557
558impl Render for ProjectDiff {
559 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
560 let is_empty = self.multibuffer.read(cx).is_empty();
561
562 div()
563 .track_focus(&self.focus_handle)
564 .bg(cx.theme().colors().editor_background)
565 .flex()
566 .items_center()
567 .justify_center()
568 .size_full()
569 .when(is_empty, |el| {
570 el.child(Label::new("No uncommitted changes"))
571 })
572 .when(!is_empty, |el| el.child(self.editor.clone()))
573 }
574}