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