project_diff.rs

  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}