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, [Diff]);
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 _: &Diff,
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}