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.multibuffer.read(cx).is_empty()
339 && self
340 .editor
341 .read(cx)
342 .focus_handle(cx)
343 .contains_focused(window, cx)
344 {
345 self.focus_handle.focus(window);
346 } else if self.focus_handle.contains_focused(window, cx)
347 && !self.multibuffer.read(cx).is_empty()
348 {
349 self.editor.update(cx, |editor, cx| {
350 editor.focus_handle(cx).focus(window);
351 editor.move_to_beginning(&Default::default(), window, cx);
352 });
353 }
354 if self.pending_scroll.as_ref() == Some(&path_key) {
355 self.scroll_to_path(path_key, window, cx);
356 }
357 }
358
359 pub async fn handle_status_updates(
360 this: WeakEntity<Self>,
361 mut recv: postage::watch::Receiver<()>,
362 mut cx: AsyncWindowContext,
363 ) -> Result<()> {
364 while let Some(_) = recv.next().await {
365 let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
366 for buffer_to_load in buffers_to_load {
367 if let Some(buffer) = buffer_to_load.await.log_err() {
368 cx.update(|window, cx| {
369 this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
370 .ok();
371 })?;
372 }
373 }
374 this.update(&mut cx, |this, _| this.pending_scroll.take())?;
375 }
376
377 Ok(())
378 }
379}
380
381impl EventEmitter<EditorEvent> for ProjectDiff {}
382
383impl Focusable for ProjectDiff {
384 fn focus_handle(&self, cx: &App) -> FocusHandle {
385 if self.multibuffer.read(cx).is_empty() {
386 self.focus_handle.clone()
387 } else {
388 self.editor.focus_handle(cx)
389 }
390 }
391}
392
393impl Item for ProjectDiff {
394 type Event = EditorEvent;
395
396 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
397 Editor::to_item_events(event, f)
398 }
399
400 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
401 self.editor
402 .update(cx, |editor, cx| editor.deactivated(window, cx));
403 }
404
405 fn navigate(
406 &mut self,
407 data: Box<dyn Any>,
408 window: &mut Window,
409 cx: &mut Context<Self>,
410 ) -> bool {
411 self.editor
412 .update(cx, |editor, cx| editor.navigate(data, window, cx))
413 }
414
415 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
416 Some("Project Diff".into())
417 }
418
419 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
420 Label::new("Uncommitted Changes")
421 .color(if params.selected {
422 Color::Default
423 } else {
424 Color::Muted
425 })
426 .into_any_element()
427 }
428
429 fn telemetry_event_text(&self) -> Option<&'static str> {
430 Some("Project Diff Opened")
431 }
432
433 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
434 Some(Box::new(self.editor.clone()))
435 }
436
437 fn for_each_project_item(
438 &self,
439 cx: &App,
440 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
441 ) {
442 self.editor.for_each_project_item(cx, f)
443 }
444
445 fn is_singleton(&self, _: &App) -> bool {
446 false
447 }
448
449 fn set_nav_history(
450 &mut self,
451 nav_history: ItemNavHistory,
452 _: &mut Window,
453 cx: &mut Context<Self>,
454 ) {
455 self.editor.update(cx, |editor, _| {
456 editor.set_nav_history(Some(nav_history));
457 });
458 }
459
460 fn clone_on_split(
461 &self,
462 _workspace_id: Option<workspace::WorkspaceId>,
463 window: &mut Window,
464 cx: &mut Context<Self>,
465 ) -> Option<Entity<Self>>
466 where
467 Self: Sized,
468 {
469 let workspace = self.workspace.upgrade()?;
470 Some(cx.new(|cx| {
471 ProjectDiff::new(
472 self.project.clone(),
473 workspace,
474 self.git_panel.clone(),
475 window,
476 cx,
477 )
478 }))
479 }
480
481 fn is_dirty(&self, cx: &App) -> bool {
482 self.multibuffer.read(cx).is_dirty(cx)
483 }
484
485 fn has_conflict(&self, cx: &App) -> bool {
486 self.multibuffer.read(cx).has_conflict(cx)
487 }
488
489 fn can_save(&self, _: &App) -> bool {
490 true
491 }
492
493 fn save(
494 &mut self,
495 format: bool,
496 project: Entity<Project>,
497 window: &mut Window,
498 cx: &mut Context<Self>,
499 ) -> Task<Result<()>> {
500 self.editor.save(format, project, window, cx)
501 }
502
503 fn save_as(
504 &mut self,
505 _: Entity<Project>,
506 _: ProjectPath,
507 _window: &mut Window,
508 _: &mut Context<Self>,
509 ) -> Task<Result<()>> {
510 unreachable!()
511 }
512
513 fn reload(
514 &mut self,
515 project: Entity<Project>,
516 window: &mut Window,
517 cx: &mut Context<Self>,
518 ) -> Task<Result<()>> {
519 self.editor.reload(project, window, cx)
520 }
521
522 fn act_as_type<'a>(
523 &'a self,
524 type_id: TypeId,
525 self_handle: &'a Entity<Self>,
526 _: &'a App,
527 ) -> Option<AnyView> {
528 if type_id == TypeId::of::<Self>() {
529 Some(self_handle.to_any())
530 } else if type_id == TypeId::of::<Editor>() {
531 Some(self.editor.to_any())
532 } else {
533 None
534 }
535 }
536
537 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
538 ToolbarItemLocation::PrimaryLeft
539 }
540
541 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
542 self.editor.breadcrumbs(theme, cx)
543 }
544
545 fn added_to_workspace(
546 &mut self,
547 workspace: &mut Workspace,
548 window: &mut Window,
549 cx: &mut Context<Self>,
550 ) {
551 self.editor.update(cx, |editor, cx| {
552 editor.added_to_workspace(workspace, window, cx)
553 });
554 }
555}
556
557impl Render for ProjectDiff {
558 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
559 let is_empty = self.multibuffer.read(cx).is_empty();
560
561 div()
562 .track_focus(&self.focus_handle)
563 .bg(cx.theme().colors().editor_background)
564 .flex()
565 .items_center()
566 .justify_center()
567 .size_full()
568 .when(is_empty, |el| {
569 el.child(Label::new("No uncommitted changes"))
570 })
571 .when(!is_empty, |el| el.child(self.editor.clone()))
572 }
573}