project_diff.rs

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