1use anyhow::{Context as _, Result};
2use buffer_diff::BufferDiff;
3use collections::HashMap;
4use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
5use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
6use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
7use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
8use git::status::{FileStatus, StatusCode, TrackedStatus};
9use git::{
10 BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote,
11 parse_git_remote_url,
12};
13use gpui::{
14 AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context, Entity,
15 EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
16 PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
17};
18use language::{
19 Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
20 Point, ReplicaId, Rope, TextBuffer,
21};
22use multi_buffer::PathKey;
23use project::{Project, WorktreeId, git_store::Repository};
24use std::{
25 any::{Any, TypeId},
26 collections::HashSet,
27 path::PathBuf,
28 sync::Arc,
29};
30use theme::ActiveTheme;
31use ui::{DiffStat, Divider, Tooltip, prelude::*};
32use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
33use workspace::item::TabTooltipContent;
34use workspace::{
35 Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
36 Workspace,
37 item::{ItemEvent, TabContentParams},
38 notifications::NotifyTaskExt,
39 pane::SaveIntent,
40 searchable::SearchableItemHandle,
41};
42
43use crate::commit_tooltip::CommitAvatar;
44use crate::git_panel::GitPanel;
45
46actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]);
47
48pub fn init(cx: &mut App) {
49 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
50 workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| {
51 CommitView::apply_stash(workspace, window, cx);
52 });
53 workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| {
54 CommitView::remove_stash(workspace, window, cx);
55 });
56 workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| {
57 CommitView::pop_stash(workspace, window, cx);
58 });
59 })
60 .detach();
61}
62
63pub struct CommitView {
64 commit: CommitDetails,
65 editor: Entity<Editor>,
66 stash: Option<usize>,
67 multibuffer: Entity<MultiBuffer>,
68 repository: Entity<Repository>,
69 remote: Option<GitRemote>,
70}
71
72struct GitBlob {
73 path: RepoPath,
74 worktree_id: WorktreeId,
75 is_deleted: bool,
76 is_binary: bool,
77 display_name: String,
78}
79
80struct CommitDiffAddon {
81 file_statuses: HashMap<language::BufferId, FileStatus>,
82}
83
84impl Addon for CommitDiffAddon {
85 fn to_any(&self) -> &dyn std::any::Any {
86 self
87 }
88
89 fn override_status_for_buffer_id(
90 &self,
91 buffer_id: language::BufferId,
92 _cx: &App,
93 ) -> Option<FileStatus> {
94 self.file_statuses.get(&buffer_id).copied()
95 }
96}
97
98const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0;
99const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
100
101impl CommitView {
102 pub fn open(
103 commit_sha: String,
104 repo: WeakEntity<Repository>,
105 workspace: WeakEntity<Workspace>,
106 stash: Option<usize>,
107 file_filter: Option<RepoPath>,
108 window: &mut Window,
109 cx: &mut App,
110 ) {
111 let commit_diff = repo
112 .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone()))
113 .ok();
114 let commit_details = repo
115 .update(cx, |repo, _| repo.show(commit_sha.clone()))
116 .ok();
117
118 window
119 .spawn(cx, async move |cx| {
120 let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?);
121 let mut commit_diff = commit_diff.log_err()?.log_err()?;
122 let commit_details = commit_details.log_err()?.log_err()?;
123
124 // Filter to specific file if requested
125 if let Some(ref filter_path) = file_filter {
126 commit_diff.files.retain(|f| &f.path == filter_path);
127 }
128
129 let repo = repo.upgrade()?;
130
131 workspace
132 .update_in(cx, |workspace, window, cx| {
133 let project = workspace.project();
134 let commit_view = cx.new(|cx| {
135 CommitView::new(
136 commit_details,
137 commit_diff,
138 repo,
139 project.clone(),
140 stash,
141 window,
142 cx,
143 )
144 });
145
146 let pane = workspace.active_pane();
147 pane.update(cx, |pane, cx| {
148 let ix = pane.items().position(|item| {
149 let commit_view = item.downcast::<CommitView>();
150 commit_view
151 .is_some_and(|view| view.read(cx).commit.sha == commit_sha)
152 });
153 if let Some(ix) = ix {
154 pane.activate_item(ix, true, true, window, cx);
155 } else {
156 pane.add_item(Box::new(commit_view), true, true, None, window, cx);
157 }
158 })
159 })
160 .log_err()
161 })
162 .detach();
163 }
164
165 fn new(
166 commit: CommitDetails,
167 commit_diff: CommitDiff,
168 repository: Entity<Repository>,
169 project: Entity<Project>,
170 stash: Option<usize>,
171 window: &mut Window,
172 cx: &mut Context<Self>,
173 ) -> Self {
174 let language_registry = project.read(cx).languages().clone();
175 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly));
176
177 let message_buffer = cx.new(|cx| {
178 let mut buffer = Buffer::local(commit.message.clone(), cx);
179 buffer.set_capability(Capability::ReadOnly, cx);
180 buffer
181 });
182
183 multibuffer.update(cx, |multibuffer, cx| {
184 let snapshot = message_buffer.read(cx).snapshot();
185 let full_range = Point::zero()..snapshot.max_point();
186 let range = ExcerptRange {
187 context: full_range.clone(),
188 primary: full_range,
189 };
190 multibuffer.set_excerpt_ranges_for_path(
191 PathKey::with_sort_prefix(
192 COMMIT_MESSAGE_SORT_PREFIX,
193 RelPath::unix("commit message").unwrap().into(),
194 ),
195 message_buffer.clone(),
196 &snapshot,
197 vec![range],
198 cx,
199 )
200 });
201
202 let editor = cx.new(|cx| {
203 let mut editor =
204 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
205
206 editor.disable_inline_diagnostics();
207 editor.set_show_breakpoints(false, cx);
208 editor.set_show_diff_review_button(true, cx);
209 editor.set_expand_all_diff_hunks(cx);
210 editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx);
211 editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx);
212
213 editor.insert_blocks(
214 [BlockProperties {
215 placement: BlockPlacement::Above(editor::Anchor::Min),
216 height: Some(1),
217 style: BlockStyle::Sticky,
218 render: Arc::new(|_| gpui::Empty.into_any_element()),
219 priority: 0,
220 }]
221 .into_iter()
222 .chain(
223 editor
224 .buffer()
225 .read(cx)
226 .snapshot(cx)
227 .anchor_in_buffer(Anchor::max_for_buffer(
228 message_buffer.read(cx).remote_id(),
229 ))
230 .map(|anchor| BlockProperties {
231 placement: BlockPlacement::Below(anchor),
232 height: Some(1),
233 style: BlockStyle::Sticky,
234 render: Arc::new(|_| gpui::Empty.into_any_element()),
235 priority: 0,
236 }),
237 ),
238 None,
239 cx,
240 );
241
242 editor
243 });
244
245 let commit_sha = Arc::<str>::from(commit.sha.as_ref());
246
247 let first_worktree_id = project
248 .read(cx)
249 .worktrees(cx)
250 .next()
251 .map(|worktree| worktree.read(cx).id());
252
253 let repository_clone = repository.clone();
254
255 cx.spawn(async move |this, cx| {
256 let mut binary_buffer_ids: HashSet<language::BufferId> = HashSet::default();
257 let mut file_statuses: HashMap<language::BufferId, FileStatus> = HashMap::default();
258
259 for file in commit_diff.files {
260 let is_created = file.old_text.is_none();
261 let is_deleted = file.new_text.is_none();
262 let raw_new_text = file.new_text.unwrap_or_default();
263 let raw_old_text = file.old_text;
264
265 let is_binary = file.is_binary
266 || is_binary_content(raw_new_text.as_bytes())
267 || raw_old_text
268 .as_ref()
269 .is_some_and(|text| is_binary_content(text.as_bytes()));
270
271 let new_text = if is_binary {
272 "(binary file not shown)".to_string()
273 } else {
274 raw_new_text
275 };
276 let old_text = if is_binary { None } else { raw_old_text };
277 let worktree_id = repository_clone
278 .update(cx, |repository, cx| {
279 repository
280 .repo_path_to_project_path(&file.path, cx)
281 .map(|path| path.worktree_id)
282 .or(first_worktree_id)
283 })
284 .context("project has no worktrees")?;
285 let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha);
286 let file_name = file
287 .path
288 .file_name()
289 .map(|name| name.to_string())
290 .unwrap_or_else(|| file.path.display(PathStyle::local()).to_string());
291 let display_name = format!("{short_sha} - {file_name}");
292
293 let file = Arc::new(GitBlob {
294 path: file.path.clone(),
295 is_deleted,
296 is_binary,
297 worktree_id,
298 display_name,
299 }) as Arc<dyn language::File>;
300
301 let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
302 let buffer_id = cx.update(|cx| buffer.read(cx).remote_id());
303
304 let status_code = if is_created {
305 StatusCode::Added
306 } else if is_deleted {
307 StatusCode::Deleted
308 } else {
309 StatusCode::Modified
310 };
311 file_statuses.insert(
312 buffer_id,
313 FileStatus::Tracked(TrackedStatus {
314 index_status: status_code,
315 worktree_status: StatusCode::Unmodified,
316 }),
317 );
318
319 if is_binary {
320 binary_buffer_ids.insert(buffer_id);
321 }
322
323 let buffer_diff = if is_binary {
324 None
325 } else {
326 Some(build_buffer_diff(old_text, &buffer, &language_registry, cx).await?)
327 };
328
329 this.update(cx, |this, cx| {
330 this.multibuffer.update(cx, |multibuffer, cx| {
331 let snapshot = buffer.read(cx).snapshot();
332 let path = snapshot.file().unwrap().path().clone();
333 let excerpt_ranges = if is_binary {
334 vec![language::Point::zero()..snapshot.max_point()]
335 } else if let Some(buffer_diff) = &buffer_diff {
336 let diff_snapshot = buffer_diff.read(cx).snapshot(cx);
337 let mut hunks = diff_snapshot.hunks(&snapshot).peekable();
338 if hunks.peek().is_none() {
339 vec![language::Point::zero()..snapshot.max_point()]
340 } else {
341 hunks
342 .map(|hunk| hunk.buffer_range.to_point(&snapshot))
343 .collect::<Vec<_>>()
344 }
345 } else {
346 vec![language::Point::zero()..snapshot.max_point()]
347 };
348
349 let _is_newly_added = multibuffer.set_excerpts_for_path(
350 PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
351 buffer,
352 excerpt_ranges,
353 multibuffer_context_lines(cx),
354 cx,
355 );
356 if let Some(buffer_diff) = buffer_diff {
357 multibuffer.add_diff(buffer_diff, cx);
358 }
359 });
360 })?;
361 }
362
363 this.update(cx, |this, cx| {
364 this.editor.update(cx, |editor, _cx| {
365 editor.register_addon(CommitDiffAddon { file_statuses });
366 });
367 if !binary_buffer_ids.is_empty() {
368 this.editor.update(cx, |editor, cx| {
369 editor.fold_buffers(binary_buffer_ids, cx);
370 });
371 }
372 })?;
373
374 anyhow::Ok(())
375 })
376 .detach();
377
378 let snapshot = repository.read(cx).snapshot();
379 let remote_url = snapshot
380 .remote_upstream_url
381 .as_ref()
382 .or(snapshot.remote_origin_url.as_ref());
383
384 let remote = remote_url.and_then(|url| {
385 let provider_registry = GitHostingProviderRegistry::default_global(cx);
386 parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
387 host,
388 owner: parsed.owner.into(),
389 repo: parsed.repo.into(),
390 })
391 });
392
393 Self {
394 commit,
395 editor,
396 multibuffer,
397 stash,
398 repository,
399 remote,
400 }
401 }
402
403 fn render_commit_avatar(
404 &self,
405 sha: &SharedString,
406 size: impl Into<gpui::AbsoluteLength>,
407 window: &mut Window,
408 cx: &mut App,
409 ) -> AnyElement {
410 CommitAvatar::new(
411 sha,
412 Some(self.commit.author_email.clone()),
413 self.remote.as_ref(),
414 )
415 .size(size)
416 .render(window, cx)
417 }
418
419 fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
420 self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
421 }
422
423 fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
424 let commit = &self.commit;
425 let author_name = commit.author_name.clone();
426 let author_email = commit.author_email.clone();
427 let commit_sha = commit.sha.clone();
428 let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
429 .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
430 let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
431 let date_string = time_format::format_localized_timestamp(
432 commit_date,
433 time::OffsetDateTime::now_utc(),
434 local_offset,
435 time_format::TimestampFormat::MediumAbsolute,
436 );
437
438 let gutter_width = self.editor.update(cx, |editor, cx| {
439 let snapshot = editor.snapshot(window, cx);
440 let style = editor.style(cx);
441 let font_id = window.text_system().resolve_font(&style.text.font());
442 let font_size = style.text.font_size.to_pixels(window.rem_size());
443 snapshot
444 .gutter_dimensions(font_id, font_size, style, window, cx)
445 .full_width()
446 });
447
448 let clipboard_has_sha = cx
449 .read_from_clipboard()
450 .and_then(|entry| entry.text())
451 .map_or(false, |clipboard_text| {
452 clipboard_text.trim() == commit_sha.as_ref()
453 });
454
455 let (copy_icon, copy_icon_color) = if clipboard_has_sha {
456 (IconName::Check, Color::Success)
457 } else {
458 (IconName::Copy, Color::Muted)
459 };
460
461 h_flex()
462 .py_2()
463 .pr_2p5()
464 .w_full()
465 .justify_between()
466 .border_b_1()
467 .border_color(cx.theme().colors().border_variant)
468 .child(
469 h_flex()
470 .child(h_flex().w(gutter_width).justify_center().child(
471 self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx),
472 ))
473 .child(
474 v_flex().child(Label::new(author_name)).child(
475 h_flex()
476 .gap_1p5()
477 .child(
478 Label::new(date_string)
479 .color(Color::Muted)
480 .size(LabelSize::Small),
481 )
482 .child(
483 Label::new("•")
484 .size(LabelSize::Small)
485 .color(Color::Muted)
486 .alpha(0.5),
487 )
488 .child(
489 Label::new(author_email)
490 .color(Color::Muted)
491 .size(LabelSize::Small),
492 ),
493 ),
494 ),
495 )
496 .when(self.stash.is_none(), |this| {
497 this.child(
498 Button::new("sha", "Commit SHA")
499 .start_icon(
500 Icon::new(copy_icon)
501 .size(IconSize::Small)
502 .color(copy_icon_color),
503 )
504 .tooltip({
505 let commit_sha = commit_sha.clone();
506 move |_, cx| {
507 Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx)
508 }
509 })
510 .on_click(move |_, _, cx| {
511 cx.stop_propagation();
512 cx.write_to_clipboard(ClipboardItem::new_string(
513 commit_sha.to_string(),
514 ));
515 }),
516 )
517 })
518 }
519
520 fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
521 Self::stash_action(
522 workspace,
523 "Apply",
524 window,
525 cx,
526 async move |repository, sha, stash, commit_view, workspace, cx| {
527 let result = repository.update(cx, |repo, cx| {
528 if !stash_matches_index(&sha, stash, repo) {
529 return Err(anyhow::anyhow!("Stash has changed, not applying"));
530 }
531 Ok(repo.stash_apply(Some(stash), cx))
532 });
533
534 match result {
535 Ok(task) => task.await?,
536 Err(err) => {
537 Self::close_commit_view(commit_view, workspace, cx).await?;
538 return Err(err);
539 }
540 };
541 Self::close_commit_view(commit_view, workspace, cx).await?;
542 anyhow::Ok(())
543 },
544 );
545 }
546
547 fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
548 Self::stash_action(
549 workspace,
550 "Pop",
551 window,
552 cx,
553 async move |repository, sha, stash, commit_view, workspace, cx| {
554 let result = repository.update(cx, |repo, cx| {
555 if !stash_matches_index(&sha, stash, repo) {
556 return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
557 }
558 Ok(repo.stash_pop(Some(stash), cx))
559 });
560
561 match result {
562 Ok(task) => task.await?,
563 Err(err) => {
564 Self::close_commit_view(commit_view, workspace, cx).await?;
565 return Err(err);
566 }
567 };
568 Self::close_commit_view(commit_view, workspace, cx).await?;
569 anyhow::Ok(())
570 },
571 );
572 }
573
574 fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
575 Self::stash_action(
576 workspace,
577 "Drop",
578 window,
579 cx,
580 async move |repository, sha, stash, commit_view, workspace, cx| {
581 let result = repository.update(cx, |repo, cx| {
582 if !stash_matches_index(&sha, stash, repo) {
583 return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
584 }
585 Ok(repo.stash_drop(Some(stash), cx))
586 });
587
588 match result {
589 Ok(task) => task.await??,
590 Err(err) => {
591 Self::close_commit_view(commit_view, workspace, cx).await?;
592 return Err(err);
593 }
594 };
595 Self::close_commit_view(commit_view, workspace, cx).await?;
596 anyhow::Ok(())
597 },
598 );
599 }
600
601 fn stash_action<AsyncFn>(
602 workspace: &mut Workspace,
603 str_action: &str,
604 window: &mut Window,
605 cx: &mut App,
606 callback: AsyncFn,
607 ) where
608 AsyncFn: AsyncFnOnce(
609 Entity<Repository>,
610 &SharedString,
611 usize,
612 Entity<CommitView>,
613 WeakEntity<Workspace>,
614 &mut AsyncWindowContext,
615 ) -> anyhow::Result<()>
616 + 'static,
617 {
618 let Some(commit_view) = workspace.active_item_as::<CommitView>(cx) else {
619 return;
620 };
621 let Some(stash) = commit_view.read(cx).stash else {
622 return;
623 };
624 let sha = commit_view.read(cx).commit.sha.clone();
625 let answer = window.prompt(
626 PromptLevel::Info,
627 &format!("{} stash@{{{}}}?", str_action, stash),
628 None,
629 &[str_action, "Cancel"],
630 cx,
631 );
632
633 let workspace_weak = workspace.weak_handle();
634 let commit_view_entity = commit_view;
635
636 window
637 .spawn(cx, async move |cx| {
638 if answer.await != Ok(0) {
639 return anyhow::Ok(());
640 }
641
642 let Some(workspace) = workspace_weak.upgrade() else {
643 return Ok(());
644 };
645
646 let repo = workspace.update(cx, |workspace, cx| {
647 workspace
648 .panel::<GitPanel>(cx)
649 .and_then(|p| p.read(cx).active_repository.clone())
650 });
651
652 let Some(repo) = repo else {
653 return Ok(());
654 };
655
656 callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
657 anyhow::Ok(())
658 })
659 .detach_and_notify_err(workspace.weak_handle(), window, cx);
660 }
661
662 async fn close_commit_view(
663 commit_view: Entity<CommitView>,
664 workspace: WeakEntity<Workspace>,
665 cx: &mut AsyncWindowContext,
666 ) -> anyhow::Result<()> {
667 workspace
668 .update_in(cx, |workspace, window, cx| {
669 let active_pane = workspace.active_pane();
670 let commit_view_id = commit_view.entity_id();
671 active_pane.update(cx, |pane, cx| {
672 pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
673 })
674 })?
675 .await?;
676 anyhow::Ok(())
677 }
678}
679
680impl language::File for GitBlob {
681 fn as_local(&self) -> Option<&dyn language::LocalFile> {
682 None
683 }
684
685 fn disk_state(&self) -> DiskState {
686 DiskState::Historic {
687 was_deleted: self.is_deleted,
688 }
689 }
690
691 fn path_style(&self, _: &App) -> PathStyle {
692 PathStyle::local()
693 }
694
695 fn path(&self) -> &Arc<RelPath> {
696 self.path.as_ref()
697 }
698
699 fn full_path(&self, _: &App) -> PathBuf {
700 self.path.as_std_path().to_path_buf()
701 }
702
703 fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
704 self.display_name.as_ref()
705 }
706
707 fn worktree_id(&self, _: &App) -> WorktreeId {
708 self.worktree_id
709 }
710
711 fn to_proto(&self, _cx: &App) -> language::proto::File {
712 unimplemented!()
713 }
714
715 fn is_private(&self) -> bool {
716 false
717 }
718
719 fn can_open(&self) -> bool {
720 !self.is_binary
721 }
722}
723
724async fn build_buffer(
725 mut text: String,
726 blob: Arc<dyn File>,
727 language_registry: &Arc<language::LanguageRegistry>,
728 cx: &mut AsyncApp,
729) -> Result<Entity<Buffer>> {
730 let line_ending = LineEnding::detect(&text);
731 LineEnding::normalize(&mut text);
732 let text = Rope::from(text);
733 let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx));
734 let language = if let Some(language) = language {
735 language_registry
736 .load_language(&language)
737 .await
738 .ok()
739 .and_then(|e| e.log_err())
740 } else {
741 None
742 };
743 let buffer = cx.new(|cx| {
744 let buffer = TextBuffer::new_normalized(
745 ReplicaId::LOCAL,
746 cx.entity_id().as_non_zero_u64().into(),
747 line_ending,
748 text,
749 );
750 let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite);
751 buffer.set_language_async(language, cx);
752 buffer
753 });
754 Ok(buffer)
755}
756
757async fn build_buffer_diff(
758 mut old_text: Option<String>,
759 buffer: &Entity<Buffer>,
760 language_registry: &Arc<LanguageRegistry>,
761 cx: &mut AsyncApp,
762) -> Result<Entity<BufferDiff>> {
763 if let Some(old_text) = &mut old_text {
764 LineEnding::normalize(old_text);
765 }
766
767 let language = cx.update(|cx| buffer.read(cx).language().cloned());
768 let buffer = cx.update(|cx| buffer.read(cx).snapshot());
769
770 let diff = cx.new(|cx| BufferDiff::new(&buffer.text, cx));
771
772 let update = diff
773 .update(cx, |diff, cx| {
774 diff.update_diff(
775 buffer.text.clone(),
776 old_text.map(|old_text| Arc::from(old_text.as_str())),
777 Some(true),
778 language.clone(),
779 cx,
780 )
781 })
782 .await;
783
784 diff.update(cx, |diff, cx| {
785 diff.language_changed(language, Some(language_registry.clone()), cx);
786 diff.set_snapshot(update, &buffer.text, cx)
787 })
788 .await;
789
790 Ok(diff)
791}
792
793impl EventEmitter<EditorEvent> for CommitView {}
794
795impl Focusable for CommitView {
796 fn focus_handle(&self, cx: &App) -> FocusHandle {
797 self.editor.focus_handle(cx)
798 }
799}
800
801impl Item for CommitView {
802 type Event = EditorEvent;
803
804 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
805 Some(Icon::new(IconName::GitCommit).color(Color::Muted))
806 }
807
808 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
809 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
810 .color(if params.selected {
811 Color::Default
812 } else {
813 Color::Muted
814 })
815 .into_any_element()
816 }
817
818 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
819 let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
820 let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
821 format!("{short_sha} — {subject}").into()
822 }
823
824 fn tab_tooltip_content(&self, _: &App) -> Option<TabTooltipContent> {
825 let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
826 let subject = self.commit.message.split('\n').next().unwrap();
827
828 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
829 let subject = subject.to_string();
830 let short_sha = short_sha.to_string();
831
832 move |_, _| {
833 v_flex()
834 .child(Label::new(subject.clone()))
835 .child(
836 Label::new(short_sha.clone())
837 .color(Color::Muted)
838 .size(LabelSize::Small),
839 )
840 .into_any_element()
841 }
842 }))))
843 }
844
845 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
846 Editor::to_item_events(event, f)
847 }
848
849 fn telemetry_event_text(&self) -> Option<&'static str> {
850 Some("Commit View Opened")
851 }
852
853 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
854 self.editor
855 .update(cx, |editor, cx| editor.deactivated(window, cx));
856 }
857
858 fn act_as_type<'a>(
859 &'a self,
860 type_id: TypeId,
861 self_handle: &'a Entity<Self>,
862 _: &'a App,
863 ) -> Option<gpui::AnyEntity> {
864 if type_id == TypeId::of::<Self>() {
865 Some(self_handle.clone().into())
866 } else if type_id == TypeId::of::<Editor>() {
867 Some(self.editor.clone().into())
868 } else {
869 None
870 }
871 }
872
873 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
874 Some(Box::new(self.editor.clone()))
875 }
876
877 fn for_each_project_item(
878 &self,
879 cx: &App,
880 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
881 ) {
882 self.editor.for_each_project_item(cx, f)
883 }
884
885 fn set_nav_history(
886 &mut self,
887 nav_history: ItemNavHistory,
888 _: &mut Window,
889 cx: &mut Context<Self>,
890 ) {
891 self.editor.update(cx, |editor, _| {
892 editor.set_nav_history(Some(nav_history));
893 });
894 }
895
896 fn navigate(
897 &mut self,
898 data: Arc<dyn Any + Send>,
899 window: &mut Window,
900 cx: &mut Context<Self>,
901 ) -> bool {
902 self.editor
903 .update(cx, |editor, cx| editor.navigate(data, window, cx))
904 }
905
906 fn added_to_workspace(
907 &mut self,
908 workspace: &mut Workspace,
909 window: &mut Window,
910 cx: &mut Context<Self>,
911 ) {
912 self.editor.update(cx, |editor, cx| {
913 editor.added_to_workspace(workspace, window, cx)
914 });
915 }
916
917 fn can_split(&self) -> bool {
918 true
919 }
920
921 fn clone_on_split(
922 &self,
923 _workspace_id: Option<workspace::WorkspaceId>,
924 window: &mut Window,
925 cx: &mut Context<Self>,
926 ) -> Task<Option<Entity<Self>>>
927 where
928 Self: Sized,
929 {
930 let file_statuses = self
931 .editor
932 .read(cx)
933 .addon::<CommitDiffAddon>()
934 .map(|addon| addon.file_statuses.clone())
935 .unwrap_or_default();
936 Task::ready(Some(cx.new(|cx| {
937 let editor = cx.new({
938 let file_statuses = file_statuses.clone();
939 |cx| {
940 let mut editor = self
941 .editor
942 .update(cx, |editor, cx| editor.clone(window, cx));
943 editor.register_addon(CommitDiffAddon { file_statuses });
944 editor
945 }
946 });
947 let multibuffer = editor.read(cx).buffer().clone();
948 Self {
949 editor,
950 multibuffer,
951 commit: self.commit.clone(),
952 stash: self.stash,
953 repository: self.repository.clone(),
954 remote: self.remote.clone(),
955 }
956 })))
957 }
958}
959
960impl Render for CommitView {
961 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
962 let is_stash = self.stash.is_some();
963
964 v_flex()
965 .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
966 .size_full()
967 .bg(cx.theme().colors().editor_background)
968 .child(self.render_header(window, cx))
969 .when(!self.editor.read(cx).is_empty(cx), |this| {
970 this.child(div().flex_grow().child(self.editor.clone()))
971 })
972 }
973}
974
975pub struct CommitViewToolbar {
976 commit_view: Option<WeakEntity<CommitView>>,
977}
978
979impl CommitViewToolbar {
980 pub fn new() -> Self {
981 Self { commit_view: None }
982 }
983}
984
985impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
986
987impl Render for CommitViewToolbar {
988 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
989 let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else {
990 return div();
991 };
992
993 let commit_view_ref = commit_view.read(cx);
994 let is_stash = commit_view_ref.stash.is_some();
995
996 let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx);
997
998 let commit_sha = commit_view_ref.commit.sha.clone();
999
1000 let remote_info = commit_view_ref.remote.as_ref().map(|remote| {
1001 let provider = remote.host.name();
1002 let parsed_remote = ParsedGitRemote {
1003 owner: remote.owner.as_ref().into(),
1004 repo: remote.repo.as_ref().into(),
1005 };
1006 let params = BuildCommitPermalinkParams { sha: &commit_sha };
1007 let url = remote
1008 .host
1009 .build_commit_permalink(&parsed_remote, params)
1010 .to_string();
1011 (provider, url)
1012 });
1013
1014 let sha_for_graph = commit_sha.to_string();
1015
1016 h_flex()
1017 .gap_1()
1018 .when(additions > 0 || deletions > 0, |this| {
1019 this.child(
1020 h_flex()
1021 .gap_2()
1022 .child(DiffStat::new(
1023 "toolbar-diff-stat",
1024 additions as usize,
1025 deletions as usize,
1026 ))
1027 .child(Divider::vertical()),
1028 )
1029 })
1030 .child(
1031 IconButton::new("buffer-search", IconName::MagnifyingGlass)
1032 .icon_size(IconSize::Small)
1033 .tooltip(move |_, cx| {
1034 Tooltip::for_action(
1035 "Buffer Search",
1036 &zed_actions::buffer_search::Deploy::find(),
1037 cx,
1038 )
1039 })
1040 .on_click(|_, window, cx| {
1041 window.dispatch_action(
1042 Box::new(zed_actions::buffer_search::Deploy::find()),
1043 cx,
1044 );
1045 }),
1046 )
1047 .when(!is_stash, |this| {
1048 this.when(cx.has_flag::<GitGraphFeatureFlag>(), |this| {
1049 this.child(
1050 IconButton::new("show-in-git-graph", IconName::GitGraph)
1051 .icon_size(IconSize::Small)
1052 .tooltip(Tooltip::text("Show in Git Graph"))
1053 .on_click(move |_, window, cx| {
1054 window.dispatch_action(
1055 Box::new(crate::git_panel::OpenAtCommit {
1056 sha: sha_for_graph.clone(),
1057 }),
1058 cx,
1059 );
1060 }),
1061 )
1062 })
1063 .children(remote_info.map(|(provider_name, url)| {
1064 let icon = match provider_name.as_str() {
1065 "GitHub" => IconName::Github,
1066 _ => IconName::Link,
1067 };
1068
1069 IconButton::new("view_on_provider", icon)
1070 .icon_size(IconSize::Small)
1071 .tooltip(Tooltip::text(format!("View on {}", provider_name)))
1072 .on_click(move |_, _, cx| cx.open_url(&url))
1073 }))
1074 })
1075 }
1076}
1077
1078impl ToolbarItemView for CommitViewToolbar {
1079 fn set_active_pane_item(
1080 &mut self,
1081 active_pane_item: Option<&dyn ItemHandle>,
1082 _: &mut Window,
1083 cx: &mut Context<Self>,
1084 ) -> ToolbarItemLocation {
1085 if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx)) {
1086 self.commit_view = Some(entity.downgrade());
1087 return ToolbarItemLocation::PrimaryRight;
1088 }
1089 self.commit_view = None;
1090 ToolbarItemLocation::Hidden
1091 }
1092
1093 fn pane_focus_update(
1094 &mut self,
1095 _pane_focused: bool,
1096 _window: &mut Window,
1097 _cx: &mut Context<Self>,
1098 ) {
1099 }
1100}
1101
1102fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool {
1103 repo.stash_entries
1104 .entries
1105 .get(stash_index)
1106 .map(|entry| entry.oid.to_string() == sha)
1107 .unwrap_or(false)
1108}