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, 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_store: Entity<GitStore>,
 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_store = project.read(cx).git_store().clone();
141        let git_store_subscription = cx.subscribe_in(
142            &git_store,
143            window,
144            move |this, _git_store, _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_store: git_store.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_store_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_store.read(cx).active_repository() else {
179            return;
180        };
181        let repo = git_repo.read(cx);
182
183        let namespace = if repo.has_conflict(&entry.repo_path) {
184            CONFLICT_NAMESPACE
185        } else if entry.status.is_created() {
186            NEW_NAMESPACE
187        } else {
188            TRACKED_NAMESPACE
189        };
190
191        let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
192
193        self.scroll_to_path(path_key, window, cx)
194    }
195
196    fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
197        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
198            self.editor.update(cx, |editor, cx| {
199                editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
200                    s.select_ranges([position..position]);
201                })
202            })
203        } else {
204            self.pending_scroll = Some(path_key);
205        }
206    }
207
208    fn handle_editor_event(
209        &mut self,
210        editor: &Entity<Editor>,
211        event: &EditorEvent,
212        window: &mut Window,
213        cx: &mut Context<Self>,
214    ) {
215        match event {
216            EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
217                let anchor = editor.scroll_manager.anchor().anchor;
218                let multibuffer = self.multibuffer.read(cx);
219                let snapshot = multibuffer.snapshot(cx);
220                let mut point = anchor.to_point(&snapshot);
221                point.row = (point.row + 1).min(snapshot.max_row().0);
222
223                let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(point, 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_store.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 namespace = if repo.has_conflict(&entry.repo_path) {
268                    CONFLICT_NAMESPACE
269                } else if entry.status.is_created() {
270                    NEW_NAMESPACE
271                } else {
272                    TRACKED_NAMESPACE
273                };
274                let path_key = PathKey::namespaced(namespace, entry.repo_path.0.clone());
275
276                previous_paths.remove(&path_key);
277                let load_buffer = self
278                    .project
279                    .update(cx, |project, cx| project.open_buffer(project_path, cx));
280
281                let project = self.project.clone();
282                result.push(cx.spawn(|_, mut cx| async move {
283                    let buffer = load_buffer.await?;
284                    let changes = project
285                        .update(&mut cx, |project, cx| {
286                            project.open_uncommitted_diff(buffer.clone(), cx)
287                        })?
288                        .await?;
289                    Ok(DiffBuffer {
290                        path_key,
291                        buffer,
292                        diff: changes,
293                    })
294                }));
295            }
296        });
297        self.multibuffer.update(cx, |multibuffer, cx| {
298            for path in previous_paths {
299                multibuffer.remove_excerpts_for_path(path, cx);
300            }
301        });
302        result
303    }
304
305    fn register_buffer(
306        &mut self,
307        diff_buffer: DiffBuffer,
308        window: &mut Window,
309        cx: &mut Context<Self>,
310    ) {
311        let path_key = diff_buffer.path_key;
312        let buffer = diff_buffer.buffer;
313        let diff = diff_buffer.diff;
314
315        let snapshot = buffer.read(cx).snapshot();
316        let diff = diff.read(cx);
317        let diff_hunk_ranges = if diff.base_text().is_none() {
318            vec![Point::zero()..snapshot.max_point()]
319        } else {
320            diff.hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
321                .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
322                .collect::<Vec<_>>()
323        };
324
325        self.multibuffer.update(cx, |multibuffer, cx| {
326            multibuffer.set_excerpts_for_path(
327                path_key.clone(),
328                buffer,
329                diff_hunk_ranges,
330                editor::DEFAULT_MULTIBUFFER_CONTEXT,
331                cx,
332            );
333        });
334        if self.multibuffer.read(cx).is_empty()
335            && self
336                .editor
337                .read(cx)
338                .focus_handle(cx)
339                .contains_focused(window, cx)
340        {
341            self.focus_handle.focus(window);
342        } else if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
343            self.editor.update(cx, |editor, cx| {
344                editor.focus_handle(cx).focus(window);
345            });
346        }
347        if self.pending_scroll.as_ref() == Some(&path_key) {
348            self.scroll_to_path(path_key, window, cx);
349        }
350    }
351
352    pub async fn handle_status_updates(
353        this: WeakEntity<Self>,
354        mut recv: postage::watch::Receiver<()>,
355        mut cx: AsyncWindowContext,
356    ) -> Result<()> {
357        while let Some(_) = recv.next().await {
358            let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
359            for buffer_to_load in buffers_to_load {
360                if let Some(buffer) = buffer_to_load.await.log_err() {
361                    cx.update(|window, cx| {
362                        this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
363                            .ok();
364                    })?;
365                }
366            }
367            this.update(&mut cx, |this, _| this.pending_scroll.take())?;
368        }
369
370        Ok(())
371    }
372}
373
374impl EventEmitter<EditorEvent> for ProjectDiff {}
375
376impl Focusable for ProjectDiff {
377    fn focus_handle(&self, cx: &App) -> FocusHandle {
378        if self.multibuffer.read(cx).is_empty() {
379            self.focus_handle.clone()
380        } else {
381            self.editor.focus_handle(cx)
382        }
383    }
384}
385
386impl Item for ProjectDiff {
387    type Event = EditorEvent;
388
389    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
390        Some(Icon::new(IconName::GitBranch).color(Color::Muted))
391    }
392
393    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
394        Editor::to_item_events(event, f)
395    }
396
397    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
398        self.editor
399            .update(cx, |editor, cx| editor.deactivated(window, cx));
400    }
401
402    fn navigate(
403        &mut self,
404        data: Box<dyn Any>,
405        window: &mut Window,
406        cx: &mut Context<Self>,
407    ) -> bool {
408        self.editor
409            .update(cx, |editor, cx| editor.navigate(data, window, cx))
410    }
411
412    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
413        Some("Project Diff".into())
414    }
415
416    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
417        Label::new("Uncommitted Changes")
418            .color(if params.selected {
419                Color::Default
420            } else {
421                Color::Muted
422            })
423            .into_any_element()
424    }
425
426    fn telemetry_event_text(&self) -> Option<&'static str> {
427        Some("Project Diff Opened")
428    }
429
430    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
431        Some(Box::new(self.editor.clone()))
432    }
433
434    fn for_each_project_item(
435        &self,
436        cx: &App,
437        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
438    ) {
439        self.editor.for_each_project_item(cx, f)
440    }
441
442    fn is_singleton(&self, _: &App) -> bool {
443        false
444    }
445
446    fn set_nav_history(
447        &mut self,
448        nav_history: ItemNavHistory,
449        _: &mut Window,
450        cx: &mut Context<Self>,
451    ) {
452        self.editor.update(cx, |editor, _| {
453            editor.set_nav_history(Some(nav_history));
454        });
455    }
456
457    fn clone_on_split(
458        &self,
459        _workspace_id: Option<workspace::WorkspaceId>,
460        window: &mut Window,
461        cx: &mut Context<Self>,
462    ) -> Option<Entity<Self>>
463    where
464        Self: Sized,
465    {
466        let workspace = self.workspace.upgrade()?;
467        Some(cx.new(|cx| {
468            ProjectDiff::new(
469                self.project.clone(),
470                workspace,
471                self.git_panel.clone(),
472                window,
473                cx,
474            )
475        }))
476    }
477
478    fn is_dirty(&self, cx: &App) -> bool {
479        self.multibuffer.read(cx).is_dirty(cx)
480    }
481
482    fn has_conflict(&self, cx: &App) -> bool {
483        self.multibuffer.read(cx).has_conflict(cx)
484    }
485
486    fn can_save(&self, _: &App) -> bool {
487        true
488    }
489
490    fn save(
491        &mut self,
492        format: bool,
493        project: Entity<Project>,
494        window: &mut Window,
495        cx: &mut Context<Self>,
496    ) -> Task<Result<()>> {
497        self.editor.save(format, project, window, cx)
498    }
499
500    fn save_as(
501        &mut self,
502        _: Entity<Project>,
503        _: ProjectPath,
504        _window: &mut Window,
505        _: &mut Context<Self>,
506    ) -> Task<Result<()>> {
507        unreachable!()
508    }
509
510    fn reload(
511        &mut self,
512        project: Entity<Project>,
513        window: &mut Window,
514        cx: &mut Context<Self>,
515    ) -> Task<Result<()>> {
516        self.editor.reload(project, window, cx)
517    }
518
519    fn act_as_type<'a>(
520        &'a self,
521        type_id: TypeId,
522        self_handle: &'a Entity<Self>,
523        _: &'a App,
524    ) -> Option<AnyView> {
525        if type_id == TypeId::of::<Self>() {
526            Some(self_handle.to_any())
527        } else if type_id == TypeId::of::<Editor>() {
528            Some(self.editor.to_any())
529        } else {
530            None
531        }
532    }
533
534    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
535        ToolbarItemLocation::PrimaryLeft
536    }
537
538    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
539        self.editor.breadcrumbs(theme, cx)
540    }
541
542    fn added_to_workspace(
543        &mut self,
544        workspace: &mut Workspace,
545        window: &mut Window,
546        cx: &mut Context<Self>,
547    ) {
548        self.editor.update(cx, |editor, cx| {
549            editor.added_to_workspace(workspace, window, cx)
550        });
551    }
552}
553
554impl Render for ProjectDiff {
555    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
556        let is_empty = self.multibuffer.read(cx).is_empty();
557
558        div()
559            .track_focus(&self.focus_handle)
560            .bg(cx.theme().colors().editor_background)
561            .flex()
562            .items_center()
563            .justify_center()
564            .size_full()
565            .when(is_empty, |el| {
566                el.child(Label::new("No uncommitted changes"))
567            })
568            .when(!is_empty, |el| el.child(self.editor.clone()))
569    }
570}