project_diff.rs

  1use std::{
  2    any::{Any, TypeId},
  3    path::Path,
  4    sync::Arc,
  5};
  6
  7use anyhow::Result;
  8use collections::HashSet;
  9use editor::{scroll::Autoscroll, Editor, EditorEvent};
 10use feature_flags::FeatureFlagViewExt;
 11use futures::StreamExt;
 12use gpui::{
 13    actions, AnyElement, AnyView, App, AppContext, AsyncWindowContext, Entity, EventEmitter,
 14    FocusHandle, Focusable, Render, Subscription, Task, WeakEntity,
 15};
 16use language::{Anchor, Buffer, Capability, OffsetRangeExt};
 17use multi_buffer::MultiBuffer;
 18use project::{buffer_store::BufferChangeSet, git::GitState, Project, ProjectPath};
 19use theme::ActiveTheme;
 20use ui::prelude::*;
 21use util::ResultExt as _;
 22use workspace::{
 23    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
 24    searchable::SearchableItemHandle,
 25    ItemNavHistory, ToolbarItemLocation, Workspace,
 26};
 27
 28use crate::git_panel::GitPanel;
 29
 30actions!(git, [ShowUncommittedChanges]);
 31
 32pub(crate) struct ProjectDiff {
 33    multibuffer: Entity<MultiBuffer>,
 34    editor: Entity<Editor>,
 35    project: Entity<Project>,
 36    git_state: Entity<GitState>,
 37    workspace: WeakEntity<Workspace>,
 38    focus_handle: FocusHandle,
 39    update_needed: postage::watch::Sender<()>,
 40    pending_scroll: Option<Arc<Path>>,
 41
 42    _task: Task<Result<()>>,
 43    _subscription: Subscription,
 44}
 45
 46struct DiffBuffer {
 47    abs_path: Arc<Path>,
 48    buffer: Entity<Buffer>,
 49    change_set: Entity<BufferChangeSet>,
 50}
 51
 52impl ProjectDiff {
 53    pub(crate) fn register(
 54        _: &mut Workspace,
 55        window: Option<&mut Window>,
 56        cx: &mut Context<Workspace>,
 57    ) {
 58        let Some(window) = window else { return };
 59        cx.when_flag_enabled::<feature_flags::GitUiFeatureFlag>(window, |workspace, _, _cx| {
 60            workspace.register_action(Self::deploy);
 61        });
 62    }
 63
 64    fn deploy(
 65        workspace: &mut Workspace,
 66        _: &ShowUncommittedChanges,
 67        window: &mut Window,
 68        cx: &mut Context<Workspace>,
 69    ) {
 70        Self::deploy_at(workspace, None, window, cx)
 71    }
 72
 73    pub fn deploy_at(
 74        workspace: &mut Workspace,
 75        path: Option<Arc<Path>>,
 76        window: &mut Window,
 77        cx: &mut Context<Workspace>,
 78    ) {
 79        let project_diff = if let Some(existing) = workspace.item_of_type::<Self>(cx) {
 80            workspace.activate_item(&existing, true, true, window, cx);
 81            existing
 82        } else {
 83            let workspace_handle = cx.entity().downgrade();
 84            let project_diff =
 85                cx.new(|cx| Self::new(workspace.project().clone(), workspace_handle, window, cx));
 86            workspace.add_item_to_active_pane(
 87                Box::new(project_diff.clone()),
 88                None,
 89                true,
 90                window,
 91                cx,
 92            );
 93            project_diff
 94        };
 95        if let Some(path) = path {
 96            project_diff.update(cx, |project_diff, cx| {
 97                project_diff.scroll_to(path, window, cx);
 98            })
 99        }
100    }
101
102    fn new(
103        project: Entity<Project>,
104        workspace: WeakEntity<Workspace>,
105        window: &mut Window,
106        cx: &mut Context<Self>,
107    ) -> Self {
108        let focus_handle = cx.focus_handle();
109        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadWrite));
110
111        let editor = cx.new(|cx| {
112            let mut diff_display_editor = Editor::for_multibuffer(
113                multibuffer.clone(),
114                Some(project.clone()),
115                true,
116                window,
117                cx,
118            );
119            diff_display_editor.set_expand_all_diff_hunks(cx);
120            diff_display_editor
121        });
122        cx.subscribe_in(&editor, window, Self::handle_editor_event)
123            .detach();
124
125        let git_state = project.read(cx).git_state().clone();
126        let git_state_subscription = cx.subscribe_in(
127            &git_state,
128            window,
129            move |this, _git_state, event, _window, _cx| match event {
130                project::git::Event::RepositoriesUpdated => {
131                    *this.update_needed.borrow_mut() = ();
132                }
133            },
134        );
135
136        let (mut send, recv) = postage::watch::channel::<()>();
137        let worker = window.spawn(cx, {
138            let this = cx.weak_entity();
139            |cx| Self::handle_status_updates(this, recv, cx)
140        });
141        // Kick of a refresh immediately
142        *send.borrow_mut() = ();
143
144        Self {
145            project,
146            git_state: git_state.clone(),
147            workspace,
148            focus_handle,
149            editor,
150            multibuffer,
151            pending_scroll: None,
152            update_needed: send,
153            _task: worker,
154            _subscription: git_state_subscription,
155        }
156    }
157
158    pub fn scroll_to(&mut self, path: Arc<Path>, window: &mut Window, cx: &mut Context<Self>) {
159        if let Some(position) = self.multibuffer.read(cx).location_for_path(&path, cx) {
160            self.editor.update(cx, |editor, cx| {
161                editor.change_selections(Some(Autoscroll::focused()), window, cx, |s| {
162                    s.select_ranges([position..position]);
163                })
164            })
165        } else {
166            self.pending_scroll = Some(path);
167        }
168    }
169
170    fn handle_editor_event(
171        &mut self,
172        editor: &Entity<Editor>,
173        event: &EditorEvent,
174        window: &mut Window,
175        cx: &mut Context<Self>,
176    ) {
177        match event {
178            EditorEvent::ScrollPositionChanged { .. } => editor.update(cx, |editor, cx| {
179                let anchor = editor.scroll_manager.anchor().anchor;
180                let Some((_, buffer, _)) = self.multibuffer.read(cx).excerpt_containing(anchor, cx)
181                else {
182                    return;
183                };
184                let Some(project_path) = buffer
185                    .read(cx)
186                    .file()
187                    .map(|file| (file.worktree_id(cx), file.path().clone()))
188                else {
189                    return;
190                };
191                self.workspace
192                    .update(cx, |workspace, cx| {
193                        if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
194                            git_panel.update(cx, |git_panel, cx| {
195                                git_panel.set_focused_path(project_path.into(), window, cx)
196                            })
197                        }
198                    })
199                    .ok();
200            }),
201            _ => {}
202        }
203    }
204
205    fn load_buffers(&mut self, cx: &mut Context<Self>) -> Vec<Task<Result<DiffBuffer>>> {
206        let Some(repo) = self.git_state.read(cx).active_repository() else {
207            self.multibuffer.update(cx, |multibuffer, cx| {
208                multibuffer.clear(cx);
209            });
210            return vec![];
211        };
212
213        let mut previous_paths = self.multibuffer.read(cx).paths().collect::<HashSet<_>>();
214
215        let mut result = vec![];
216        for entry in repo.status() {
217            if !entry.status.has_changes() {
218                continue;
219            }
220            let Some(project_path) = repo.repo_path_to_project_path(&entry.repo_path) else {
221                continue;
222            };
223            let Some(abs_path) = self.project.read(cx).absolute_path(&project_path, cx) else {
224                continue;
225            };
226            let abs_path = Arc::from(abs_path);
227
228            previous_paths.remove(&abs_path);
229            let load_buffer = self
230                .project
231                .update(cx, |project, cx| project.open_buffer(project_path, cx));
232
233            let project = self.project.clone();
234            result.push(cx.spawn(|_, mut cx| async move {
235                let buffer = load_buffer.await?;
236                let changes = project
237                    .update(&mut cx, |project, cx| {
238                        project.open_unstaged_changes(buffer.clone(), cx)
239                    })?
240                    .await?;
241                Ok(DiffBuffer {
242                    abs_path,
243                    buffer,
244                    change_set: changes,
245                })
246            }));
247        }
248        self.multibuffer.update(cx, |multibuffer, cx| {
249            for path in previous_paths {
250                multibuffer.remove_excerpts_for_path(path, cx);
251            }
252        });
253        result
254    }
255
256    fn register_buffer(
257        &mut self,
258        diff_buffer: DiffBuffer,
259        window: &mut Window,
260        cx: &mut Context<Self>,
261    ) {
262        let abs_path = diff_buffer.abs_path;
263        let buffer = diff_buffer.buffer;
264        let change_set = diff_buffer.change_set;
265
266        let snapshot = buffer.read(cx).snapshot();
267        let diff_hunk_ranges = change_set
268            .read(cx)
269            .diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot)
270            .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
271            .collect::<Vec<_>>();
272
273        self.multibuffer.update(cx, |multibuffer, cx| {
274            multibuffer.set_excerpts_for_path(
275                abs_path.clone(),
276                buffer,
277                diff_hunk_ranges,
278                editor::DEFAULT_MULTIBUFFER_CONTEXT,
279                cx,
280            );
281        });
282        if self.pending_scroll.as_ref() == Some(&abs_path) {
283            self.scroll_to(abs_path, window, cx);
284        }
285    }
286
287    pub async fn handle_status_updates(
288        this: WeakEntity<Self>,
289        mut recv: postage::watch::Receiver<()>,
290        mut cx: AsyncWindowContext,
291    ) -> Result<()> {
292        while let Some(_) = recv.next().await {
293            let buffers_to_load = this.update(&mut cx, |this, cx| this.load_buffers(cx))?;
294            for buffer_to_load in buffers_to_load {
295                if let Some(buffer) = buffer_to_load.await.log_err() {
296                    cx.update(|window, cx| {
297                        this.update(cx, |this, cx| this.register_buffer(buffer, window, cx))
298                            .ok();
299                    })?;
300                }
301            }
302            this.update(&mut cx, |this, _| this.pending_scroll.take())?;
303        }
304
305        Ok(())
306    }
307}
308
309impl EventEmitter<EditorEvent> for ProjectDiff {}
310
311impl Focusable for ProjectDiff {
312    fn focus_handle(&self, _: &App) -> FocusHandle {
313        self.focus_handle.clone()
314    }
315}
316
317impl Item for ProjectDiff {
318    type Event = EditorEvent;
319
320    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
321        Editor::to_item_events(event, f)
322    }
323
324    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
325        self.editor
326            .update(cx, |editor, cx| editor.deactivated(window, cx));
327    }
328
329    fn navigate(
330        &mut self,
331        data: Box<dyn Any>,
332        window: &mut Window,
333        cx: &mut Context<Self>,
334    ) -> bool {
335        self.editor
336            .update(cx, |editor, cx| editor.navigate(data, window, cx))
337    }
338
339    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
340        Some("Project Diff".into())
341    }
342
343    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
344        Label::new("Uncommitted Changes")
345            .color(if params.selected {
346                Color::Default
347            } else {
348                Color::Muted
349            })
350            .into_any_element()
351    }
352
353    fn telemetry_event_text(&self) -> Option<&'static str> {
354        Some("project diagnostics")
355    }
356
357    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
358        Some(Box::new(self.editor.clone()))
359    }
360
361    fn for_each_project_item(
362        &self,
363        cx: &App,
364        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
365    ) {
366        self.editor.for_each_project_item(cx, f)
367    }
368
369    fn is_singleton(&self, _: &App) -> bool {
370        false
371    }
372
373    fn set_nav_history(
374        &mut self,
375        nav_history: ItemNavHistory,
376        _: &mut Window,
377        cx: &mut Context<Self>,
378    ) {
379        self.editor.update(cx, |editor, _| {
380            editor.set_nav_history(Some(nav_history));
381        });
382    }
383
384    fn clone_on_split(
385        &self,
386        _workspace_id: Option<workspace::WorkspaceId>,
387        window: &mut Window,
388        cx: &mut Context<Self>,
389    ) -> Option<Entity<Self>>
390    where
391        Self: Sized,
392    {
393        Some(
394            cx.new(|cx| ProjectDiff::new(self.project.clone(), self.workspace.clone(), window, cx)),
395        )
396    }
397
398    fn is_dirty(&self, cx: &App) -> bool {
399        self.multibuffer.read(cx).is_dirty(cx)
400    }
401
402    fn has_conflict(&self, cx: &App) -> bool {
403        self.multibuffer.read(cx).has_conflict(cx)
404    }
405
406    fn can_save(&self, _: &App) -> bool {
407        true
408    }
409
410    fn save(
411        &mut self,
412        format: bool,
413        project: Entity<Project>,
414        window: &mut Window,
415        cx: &mut Context<Self>,
416    ) -> Task<Result<()>> {
417        self.editor.save(format, project, window, cx)
418    }
419
420    fn save_as(
421        &mut self,
422        _: Entity<Project>,
423        _: ProjectPath,
424        _window: &mut Window,
425        _: &mut Context<Self>,
426    ) -> Task<Result<()>> {
427        unreachable!()
428    }
429
430    fn reload(
431        &mut self,
432        project: Entity<Project>,
433        window: &mut Window,
434        cx: &mut Context<Self>,
435    ) -> Task<Result<()>> {
436        self.editor.reload(project, window, cx)
437    }
438
439    fn act_as_type<'a>(
440        &'a self,
441        type_id: TypeId,
442        self_handle: &'a Entity<Self>,
443        _: &'a App,
444    ) -> Option<AnyView> {
445        if type_id == TypeId::of::<Self>() {
446            Some(self_handle.to_any())
447        } else if type_id == TypeId::of::<Editor>() {
448            Some(self.editor.to_any())
449        } else {
450            None
451        }
452    }
453
454    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
455        ToolbarItemLocation::PrimaryLeft
456    }
457
458    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
459        self.editor.breadcrumbs(theme, cx)
460    }
461
462    fn added_to_workspace(
463        &mut self,
464        workspace: &mut Workspace,
465        window: &mut Window,
466        cx: &mut Context<Self>,
467    ) {
468        self.editor.update(cx, |editor, cx| {
469            editor.added_to_workspace(workspace, window, cx)
470        });
471    }
472}
473
474impl Render for ProjectDiff {
475    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
476        let is_empty = self.multibuffer.read(cx).is_empty();
477        if is_empty {
478            div()
479                .bg(cx.theme().colors().editor_background)
480                .flex()
481                .items_center()
482                .justify_center()
483                .size_full()
484                .child(Label::new("No uncommitted changes"))
485        } else {
486            div()
487                .bg(cx.theme().colors().editor_background)
488                .flex()
489                .items_center()
490                .justify_center()
491                .size_full()
492                .child(self.editor.clone())
493        }
494    }
495}