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