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 .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 diff_hunk_ranges = change_set
297 .read(cx)
298 .diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot)
299 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
300 .collect::<Vec<_>>();
301
302 self.multibuffer.update(cx, |multibuffer, cx| {
303 multibuffer.set_excerpts_for_path(
304 path_key.clone(),
305 buffer,
306 diff_hunk_ranges,
307 editor::DEFAULT_MULTIBUFFER_CONTEXT,
308 cx,
309 );
310 });
311 if self.pending_scroll.as_ref() == Some(&path_key) {
312 self.scroll_to_path(path_key, window, cx);
313 }
314 }
315
316 pub async fn handle_status_updates(
317 this: WeakEntity<Self>,
318 mut recv: postage::watch::Receiver<()>,
319 mut cx: AsyncWindowContext,
320 ) -> Result<()> {
321 while let Some(_) = recv.next().await {
322 let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
323 for buffer_to_load in buffers_to_load {
324 if let Some(buffer) = buffer_to_load.await.log_err() {
325 cx.update(|window, cx| {
326 this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
327 .ok();
328 })?;
329 }
330 }
331 this.update(&mut cx, |this, _| this.pending_scroll.take())?;
332 }
333
334 Ok(())
335 }
336}
337
338impl EventEmitter<EditorEvent> for ProjectDiff {}
339
340impl Focusable for ProjectDiff {
341 fn focus_handle(&self, _: &App) -> FocusHandle {
342 self.focus_handle.clone()
343 }
344}
345
346impl Item for ProjectDiff {
347 type Event = EditorEvent;
348
349 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
350 Editor::to_item_events(event, f)
351 }
352
353 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
354 self.editor
355 .update(cx, |editor, cx| editor.deactivated(window, cx));
356 }
357
358 fn navigate(
359 &mut self,
360 data: Box<dyn Any>,
361 window: &mut Window,
362 cx: &mut Context<Self>,
363 ) -> bool {
364 self.editor
365 .update(cx, |editor, cx| editor.navigate(data, window, cx))
366 }
367
368 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
369 Some("Project Diff".into())
370 }
371
372 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
373 Label::new("Uncommitted Changes")
374 .color(if params.selected {
375 Color::Default
376 } else {
377 Color::Muted
378 })
379 .into_any_element()
380 }
381
382 fn telemetry_event_text(&self) -> Option<&'static str> {
383 Some("project diagnostics")
384 }
385
386 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
387 Some(Box::new(self.editor.clone()))
388 }
389
390 fn for_each_project_item(
391 &self,
392 cx: &App,
393 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
394 ) {
395 self.editor.for_each_project_item(cx, f)
396 }
397
398 fn is_singleton(&self, _: &App) -> bool {
399 false
400 }
401
402 fn set_nav_history(
403 &mut self,
404 nav_history: ItemNavHistory,
405 _: &mut Window,
406 cx: &mut Context<Self>,
407 ) {
408 self.editor.update(cx, |editor, _| {
409 editor.set_nav_history(Some(nav_history));
410 });
411 }
412
413 fn clone_on_split(
414 &self,
415 _workspace_id: Option<workspace::WorkspaceId>,
416 window: &mut Window,
417 cx: &mut Context<Self>,
418 ) -> Option<Entity<Self>>
419 where
420 Self: Sized,
421 {
422 Some(
423 cx.new(|cx| ProjectDiff::new(self.project.clone(), self.workspace.clone(), window, cx)),
424 )
425 }
426
427 fn is_dirty(&self, cx: &App) -> bool {
428 self.multibuffer.read(cx).is_dirty(cx)
429 }
430
431 fn has_conflict(&self, cx: &App) -> bool {
432 self.multibuffer.read(cx).has_conflict(cx)
433 }
434
435 fn can_save(&self, _: &App) -> bool {
436 true
437 }
438
439 fn save(
440 &mut self,
441 format: bool,
442 project: Entity<Project>,
443 window: &mut Window,
444 cx: &mut Context<Self>,
445 ) -> Task<Result<()>> {
446 self.editor.save(format, project, window, cx)
447 }
448
449 fn save_as(
450 &mut self,
451 _: Entity<Project>,
452 _: ProjectPath,
453 _window: &mut Window,
454 _: &mut Context<Self>,
455 ) -> Task<Result<()>> {
456 unreachable!()
457 }
458
459 fn reload(
460 &mut self,
461 project: Entity<Project>,
462 window: &mut Window,
463 cx: &mut Context<Self>,
464 ) -> Task<Result<()>> {
465 self.editor.reload(project, window, cx)
466 }
467
468 fn act_as_type<'a>(
469 &'a self,
470 type_id: TypeId,
471 self_handle: &'a Entity<Self>,
472 _: &'a App,
473 ) -> Option<AnyView> {
474 if type_id == TypeId::of::<Self>() {
475 Some(self_handle.to_any())
476 } else if type_id == TypeId::of::<Editor>() {
477 Some(self.editor.to_any())
478 } else {
479 None
480 }
481 }
482
483 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
484 ToolbarItemLocation::PrimaryLeft
485 }
486
487 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
488 self.editor.breadcrumbs(theme, cx)
489 }
490
491 fn added_to_workspace(
492 &mut self,
493 workspace: &mut Workspace,
494 window: &mut Window,
495 cx: &mut Context<Self>,
496 ) {
497 self.editor.update(cx, |editor, cx| {
498 editor.added_to_workspace(workspace, window, cx)
499 });
500 }
501}
502
503impl Render for ProjectDiff {
504 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
505 let is_empty = self.multibuffer.read(cx).is_empty();
506 if is_empty {
507 div()
508 .bg(cx.theme().colors().editor_background)
509 .flex()
510 .items_center()
511 .justify_center()
512 .size_full()
513 .child(Label::new("No uncommitted changes"))
514 } else {
515 div()
516 .bg(cx.theme().colors().editor_background)
517 .flex()
518 .items_center()
519 .justify_center()
520 .size_full()
521 .child(self.editor.clone())
522 }
523 }
524}