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