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