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