project_diff.rs

  1use std::any::{Any, TypeId};
  2
  3use anyhow::Result;
  4use collections::HashSet;
  5use editor::{scroll::Autoscroll, Editor, EditorEvent};
  6use feature_flags::FeatureFlagViewExt;
  7use futures::StreamExt;
  8use gpui::{
  9    actions, AnyElement, AnyView, App, AppContext, AsyncWindowContext, Entity, EventEmitter,
 10    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
 11};
 12use language::{Anchor, Buffer, Capability, OffsetRangeExt, Point};
 13use multi_buffer::{MultiBuffer, PathKey};
 14use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
 15use theme::ActiveTheme;
 16use ui::prelude::*;
 17use util::ResultExt as _;
 18use workspace::{
 19    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
 20    searchable::SearchableItemHandle,
 21    ItemNavHistory, ToolbarItemLocation, Workspace,
 22};
 23
 24use crate::git_panel::{GitPanel, GitPanelAddon, GitStatusEntry};
 25
 26actions!(git, [Diff]);
 27
 28pub(crate) struct ProjectDiff {
 29    multibuffer: Entity<MultiBuffer>,
 30    editor: Entity<Editor>,
 31    project: Entity<Project>,
 32    git_panel: Entity<GitPanel>,
 33    git_state: Entity<GitState>,
 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    change_set: Entity<BufferChangeSet>,
 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
 65    fn deploy(
 66        workspace: &mut Workspace,
 67        _: &Diff,
 68        window: &mut Window,
 69        cx: &mut Context<Workspace>,
 70    ) {
 71        Self::deploy_at(workspace, None, window, cx)
 72    }
 73
 74    pub fn deploy_at(
 75        workspace: &mut Workspace,
 76        entry: Option<GitStatusEntry>,
 77        window: &mut Window,
 78        cx: &mut Context<Workspace>,
 79    ) {
 80        let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
 81            workspace.activate_item(&existing, true, true, window, cx);
 82            existing
 83        } else {
 84            let workspace_handle = cx.entity();
 85            let project_diff = cx.new(|cx| {
 86                Self::new(
 87                    workspace.project().clone(),
 88                    workspace_handle,
 89                    workspace.panel::<GitPanel>(cx).unwrap(),
 90                    window,
 91                    cx,
 92                )
 93            });
 94            workspace.add_item_to_active_pane(
 95                Box::new(project_diff.clone()),
 96                None,
 97                true,
 98                window,
 99                cx,
100            );
101            project_diff
102        };
103        if let Some(entry) = entry {
104            project_diff.update(cx, |project_diff, cx| {
105                project_diff.scroll_to(entry, window, cx);
106            })
107        }
108    }
109
110    fn new(
111        project: Entity<Project>,
112        workspace: Entity<Workspace>,
113        git_panel: Entity<GitPanel>,
114        window: &mut Window,
115        cx: &mut Context<Self>,
116    ) -> Self {
117        let focus_handle = cx.focus_handle();
118        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
119
120        let editor = cx.new(|cx| {
121            let mut diff_display_editor = Editor::for_multibuffer(
122                multibuffer.clone(),
123                Some(project.clone()),
124                true,
125                window,
126                cx,
127            );
128            diff_display_editor.set_expand_all_diff_hunks(cx);
129            diff_display_editor.register_addon(GitPanelAddon {
130                git_panel: git_panel.clone(),
131            });
132            diff_display_editor
133        });
134        cx.subscribe_in(&editor, window, Self::handle_editor_event)
135            .detach();
136
137        let git_state = project.read(cx).git_state().clone();
138        let git_state_subscription = cx.subscribe_in(
139            &git_state,
140            window,
141            move |this, _git_state, _event, _window, _cx| {
142                *this.update_needed.borrow_mut() = ();
143            },
144        );
145
146        let (mut send, recv) = postage::watch::channel::<()>();
147        let worker = window.spawn(cx, {
148            let this = cx.weak_entity();
149            |cx| Self::handle_status_updates(this, recv, cx)
150        });
151        // Kick of a refresh immediately
152        *send.borrow_mut() = ();
153
154        Self {
155            project,
156            git_state: git_state.clone(),
157            git_panel: git_panel.clone(),
158            workspace: workspace.downgrade(),
159            focus_handle,
160            editor,
161            multibuffer,
162            pending_scroll: None,
163            update_needed: send,
164            _task: worker,
165            _subscription: git_state_subscription,
166        }
167    }
168
169    pub fn scroll_to(
170        &mut self,
171        entry: GitStatusEntry,
172        window: &mut Window,
173        cx: &mut Context<Self>,
174    ) {
175        let Some(git_repo) = self.git_state.read(cx).active_repository() else {
176            return;
177        };
178        let repo = git_repo.read(cx);
179
180        let Some(abs_path) = repo
181            .repo_path_to_project_path(&entry.repo_path)
182            .and_then(|project_path| self.project.read(cx).absolute_path(&project_path, cx))
183        else {
184            return;
185        };
186
187        let namespace = if repo.has_conflict(&entry.repo_path) {
188            CONFLICT_NAMESPACE
189        } else if entry.status.is_created() {
190            NEW_NAMESPACE
191        } else {
192            TRACKED_NAMESPACE
193        };
194
195        let path_key = PathKey::namespaced(namespace, &abs_path);
196
197        self.scroll_to_path(path_key, window, cx)
198    }
199
200    fn scroll_to_path(&mut self, path_key: PathKey, window: &mut Window, cx: &mut Context<Self>) {
201        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path_key, cx) {
202            self.editor.update(cx, |editor, cx| {
203                editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
204                    s.select_ranges([position..position]);
205                })
206            })
207        } else {
208            self.pending_scroll = Some(path_key);
209        }
210    }
211
212    fn handle_editor_event(
213        &mut self,
214        editor: &Entity<Editor>,
215        event: &EditorEvent,
216        window: &mut Window,
217        cx: &mut Context<Self>,
218    ) {
219        match event {
220            EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
221                let anchor = editor.scroll_manager.anchor().anchor;
222                let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(anchor, cx)
223                else {
224                    return;
225                };
226                let Some(project_path) = buffer
227                    .read(cx)
228                    .file()
229                    .map(|file| (file.worktree_id(cx), file.path().clone()))
230                else {
231                    return;
232                };
233                self.workspace
234                    .update(cx, |workspace, cx| {
235                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
236                            git_panel.update(cx, |git_panel, cx| {
237                                git_panel.set_focused_path(project_path.into(), window, cx)
238                            })
239                        }
240                    })
241                    .ok();
242            }),
243            _ => {}
244        }
245    }
246
247    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
248        let Some(repo) = self.git_state.read(cx).active_repository() else {
249            self.multibuffer.update(cx, |multibuffer, cx| {
250                multibuffer.clear(cx);
251            });
252            return vec![];
253        };
254
255        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
256
257        let mut result = vec![];
258        repo.update(cx, |repo, cx| {
259            for entry in repo.status() {
260                if !entry.status.has_changes() {
261                    continue;
262                }
263                let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
264                    continue;
265                };
266                let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
267                    continue;
268                };
269                let namespace = if repo.has_conflict(&entry.repo_path) {
270                    CONFLICT_NAMESPACE
271                } else if entry.status.is_created() {
272                    NEW_NAMESPACE
273                } else {
274                    TRACKED_NAMESPACE
275                };
276                let path_key = PathKey::namespaced(namespace, &abs_path);
277
278                previous_paths.remove(&path_key);
279                let load_buffer = self
280                    .project
281                    .update(cx, |project, cx| project.open_buffer(project_path, cx));
282
283                let project = self.project.clone();
284                result.push(cx.spawn(|_, mut cx| async move {
285                    let buffer = load_buffer.await?;
286                    let changes = project
287                        .update(&mut cx, |project, cx| {
288                            project.open_uncommitted_changes(buffer.clone(), cx)
289                        })?
290                        .await?;
291                    Ok(DiffBuffer {
292                        path_key,
293                        buffer,
294                        change_set: changes,
295                    })
296                }));
297            }
298        });
299        self.multibuffer.update(cx, |multibuffer, cx| {
300            for path in previous_paths {
301                multibuffer.remove_excerpts_for_path(path, cx);
302            }
303        });
304        result
305    }
306
307    fn register_buffer(
308        &mut self,
309        diff_buffer: DiffBuffer,
310        window: &mut Window,
311        cx: &mut Context<Self>,
312    ) {
313        let path_key = diff_buffer.path_key;
314        let buffer = diff_buffer.buffer;
315        let change_set = diff_buffer.change_set;
316
317        let snapshot = buffer.read(cx).snapshot();
318        let change_set = change_set.read(cx);
319        let diff_hunk_ranges = if change_set.base_text.is_none() {
320            vec![Point::zero()..snapshot.max_point()]
321        } else {
322            change_set
323                .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}