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