project_diff.rs

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