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};
 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}