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.multibuffer.read(cx).is_empty()
339            && self
340                .editor
341                .read(cx)
342                .focus_handle(cx)
343                .contains_focused(window, cx)
344        {
345            self.focus_handle.focus(window);
346        } else if self.focus_handle.contains_focused(window, cx)
347            && !self.multibuffer.read(cx).is_empty()
348        {
349            self.editor.update(cx, |editor, cx| {
350                editor.focus_handle(cx).focus(window);
351                editor.move_to_beginning(&Default::default(), window, cx);
352            });
353        }
354        if self.pending_scroll.as_ref() == Some(&path_key) {
355            self.scroll_to_path(path_key, window, cx);
356        }
357    }
358
359    pub async fn handle_status_updates(
360        this: WeakEntity<Self>,
361        mut recv: postage::watch::Receiver<()>,
362        mut cx: AsyncWindowContext,
363    ) -> Result<()> {
364        while let Some(_) = recv.next().await {
365            let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
366            for buffer_to_load in buffers_to_load {
367                if let Some(buffer) = buffer_to_load.await.log_err() {
368                    cx.update(|window, cx| {
369                        this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
370                            .ok();
371                    })?;
372                }
373            }
374            this.update(&mut cx, |this, _| this.pending_scroll.take())?;
375        }
376
377        Ok(())
378    }
379}
380
381impl EventEmitter<EditorEvent> for ProjectDiff {}
382
383impl Focusable for ProjectDiff {
384    fn focus_handle(&self, cx: &App) -> FocusHandle {
385        if self.multibuffer.read(cx).is_empty() {
386            self.focus_handle.clone()
387        } else {
388            self.editor.focus_handle(cx)
389        }
390    }
391}
392
393impl Item for ProjectDiff {
394    type Event = EditorEvent;
395
396    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
397        Editor::to_item_events(event, f)
398    }
399
400    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
401        self.editor
402            .update(cx, |editor, cx| editor.deactivated(window, cx));
403    }
404
405    fn navigate(
406        &mut self,
407        data: Box<dyn Any>,
408        window: &mut Window,
409        cx: &mut Context<Self>,
410    ) -> bool {
411        self.editor
412            .update(cx, |editor, cx| editor.navigate(data, window, cx))
413    }
414
415    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
416        Some("Project Diff".into())
417    }
418
419    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
420        Label::new("Uncommitted Changes")
421            .color(if params.selected {
422                Color::Default
423            } else {
424                Color::Muted
425            })
426            .into_any_element()
427    }
428
429    fn telemetry_event_text(&self) -> Option<&'static str> {
430        Some("Project Diff Opened")
431    }
432
433    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
434        Some(Box::new(self.editor.clone()))
435    }
436
437    fn for_each_project_item(
438        &self,
439        cx: &App,
440        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
441    ) {
442        self.editor.for_each_project_item(cx, f)
443    }
444
445    fn is_singleton(&self, _: &App) -> bool {
446        false
447    }
448
449    fn set_nav_history(
450        &mut self,
451        nav_history: ItemNavHistory,
452        _: &mut Window,
453        cx: &mut Context<Self>,
454    ) {
455        self.editor.update(cx, |editor, _| {
456            editor.set_nav_history(Some(nav_history));
457        });
458    }
459
460    fn clone_on_split(
461        &self,
462        _workspace_id: Option<workspace::WorkspaceId>,
463        window: &mut Window,
464        cx: &mut Context<Self>,
465    ) -> Option<Entity<Self>>
466    where
467        Self: Sized,
468    {
469        let workspace = self.workspace.upgrade()?;
470        Some(cx.new(|cx| {
471            ProjectDiff::new(
472                self.project.clone(),
473                workspace,
474                self.git_panel.clone(),
475                window,
476                cx,
477            )
478        }))
479    }
480
481    fn is_dirty(&self, cx: &App) -> bool {
482        self.multibuffer.read(cx).is_dirty(cx)
483    }
484
485    fn has_conflict(&self, cx: &App) -> bool {
486        self.multibuffer.read(cx).has_conflict(cx)
487    }
488
489    fn can_save(&self, _: &App) -> bool {
490        true
491    }
492
493    fn save(
494        &mut self,
495        format: bool,
496        project: Entity<Project>,
497        window: &mut Window,
498        cx: &mut Context<Self>,
499    ) -> Task<Result<()>> {
500        self.editor.save(format, project, window, cx)
501    }
502
503    fn save_as(
504        &mut self,
505        _: Entity<Project>,
506        _: ProjectPath,
507        _window: &mut Window,
508        _: &mut Context<Self>,
509    ) -> Task<Result<()>> {
510        unreachable!()
511    }
512
513    fn reload(
514        &mut self,
515        project: Entity<Project>,
516        window: &mut Window,
517        cx: &mut Context<Self>,
518    ) -> Task<Result<()>> {
519        self.editor.reload(project, window, cx)
520    }
521
522    fn act_as_type<'a>(
523        &'a self,
524        type_id: TypeId,
525        self_handle: &'a Entity<Self>,
526        _: &'a App,
527    ) -> Option<AnyView> {
528        if type_id == TypeId::of::<Self>() {
529            Some(self_handle.to_any())
530        } else if type_id == TypeId::of::<Editor>() {
531            Some(self.editor.to_any())
532        } else {
533            None
534        }
535    }
536
537    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
538        ToolbarItemLocation::PrimaryLeft
539    }
540
541    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
542        self.editor.breadcrumbs(theme, cx)
543    }
544
545    fn added_to_workspace(
546        &mut self,
547        workspace: &mut Workspace,
548        window: &mut Window,
549        cx: &mut Context<Self>,
550    ) {
551        self.editor.update(cx, |editor, cx| {
552            editor.added_to_workspace(workspace, window, cx)
553        });
554    }
555}
556
557impl Render for ProjectDiff {
558    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
559        let is_empty = self.multibuffer.read(cx).is_empty();
560
561        div()
562            .track_focus(&self.focus_handle)
563            .bg(cx.theme().colors().editor_background)
564            .flex()
565            .items_center()
566            .justify_center()
567            .size_full()
568            .when(is_empty, |el| {
569                el.child(Label::new("No uncommitted changes"))
570            })
571            .when(!is_empty, |el| el.child(self.editor.clone()))
572    }
573}