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