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 .buffer_anchor_to_anchor(
227 &message_buffer,
228 Anchor::max_for_buffer(&message_buffer.read(cx).remote_id()),
229 cx,
230 )
231 .map(|anchor| BlockProperties {
232 placement: BlockPlacement::Below(anchor),
233 height: Some(1),
234 style: BlockStyle::Sticky,
235 render: Arc::new(|_| gpui::Empty.into_any_element()),
236 priority: 0,
237 }),
238 ),
239 None,
240 cx,
241 );
242
243 editor
244 });
245
246 let commit_sha = Arc::<str>::from(commit.sha.as_ref());
247
248 let first_worktree_id = project
249 .read(cx)
250 .worktrees(cx)
251 .next()
252 .map(|worktree| worktree.read(cx).id());
253
254 let repository_clone = repository.clone();
255
256 cx.spawn(async move |this, cx| {
257 let mut binary_buffer_ids: HashSet<language::BufferId> = HashSet::default();
258 let mut file_statuses: HashMap<language::BufferId, FileStatus> = HashMap::default();
259
260 for file in commit_diff.files {
261 let is_created = file.old_text.is_none();
262 let is_deleted = file.new_text.is_none();
263 let raw_new_text = file.new_text.unwrap_or_default();
264 let raw_old_text = file.old_text;
265
266 let is_binary = file.is_binary
267 || is_binary_content(raw_new_text.as_bytes())
268 || raw_old_text
269 .as_ref()
270 .is_some_and(|text| is_binary_content(text.as_bytes()));
271
272 let new_text = if is_binary {
273 "(binary file not shown)".to_string()
274 } else {
275 raw_new_text
276 };
277 let old_text = if is_binary { None } else { raw_old_text };
278 let worktree_id = repository_clone
279 .update(cx, |repository, cx| {
280 repository
281 .repo_path_to_project_path(&file.path, cx)
282 .map(|path| path.worktree_id)
283 .or(first_worktree_id)
284 })
285 .context("project has no worktrees")?;
286 let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha);
287 let file_name = file
288 .path
289 .file_name()
290 .map(|name| name.to_string())
291 .unwrap_or_else(|| file.path.display(PathStyle::local()).to_string());
292 let display_name = format!("{short_sha} - {file_name}");
293
294 let file = Arc::new(GitBlob {
295 path: file.path.clone(),
296 is_deleted,
297 is_binary,
298 worktree_id,
299 display_name,
300 }) as Arc<dyn language::File>;
301
302 let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
303 let buffer_id = cx.update(|cx| buffer.read(cx).remote_id());
304
305 let status_code = if is_created {
306 StatusCode::Added
307 } else if is_deleted {
308 StatusCode::Deleted
309 } else {
310 StatusCode::Modified
311 };
312 file_statuses.insert(
313 buffer_id,
314 FileStatus::Tracked(TrackedStatus {
315 index_status: status_code,
316 worktree_status: StatusCode::Unmodified,
317 }),
318 );
319
320 if is_binary {
321 binary_buffer_ids.insert(buffer_id);
322 }
323
324 let buffer_diff = if is_binary {
325 None
326 } else {
327 Some(build_buffer_diff(old_text, &buffer, &language_registry, cx).await?)
328 };
329
330 this.update(cx, |this, cx| {
331 this.multibuffer.update(cx, |multibuffer, cx| {
332 let snapshot = buffer.read(cx).snapshot();
333 let path = snapshot.file().unwrap().path().clone();
334 let excerpt_ranges = if is_binary {
335 vec![language::Point::zero()..snapshot.max_point()]
336 } else if let Some(buffer_diff) = &buffer_diff {
337 let diff_snapshot = buffer_diff.read(cx).snapshot(cx);
338 let mut hunks = diff_snapshot.hunks(&snapshot).peekable();
339 if hunks.peek().is_none() {
340 vec![language::Point::zero()..snapshot.max_point()]
341 } else {
342 hunks
343 .map(|hunk| hunk.buffer_range.to_point(&snapshot))
344 .collect::<Vec<_>>()
345 }
346 } else {
347 vec![language::Point::zero()..snapshot.max_point()]
348 };
349
350 let _is_newly_added = multibuffer.set_excerpts_for_path(
351 PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
352 buffer,
353 excerpt_ranges,
354 multibuffer_context_lines(cx),
355 cx,
356 );
357 if let Some(buffer_diff) = buffer_diff {
358 multibuffer.add_diff(buffer_diff, cx);
359 }
360 });
361 })?;
362 }
363
364 this.update(cx, |this, cx| {
365 this.editor.update(cx, |editor, _cx| {
366 editor.register_addon(CommitDiffAddon { file_statuses });
367 });
368 if !binary_buffer_ids.is_empty() {
369 this.editor.update(cx, |editor, cx| {
370 editor.fold_buffers(binary_buffer_ids, cx);
371 });
372 }
373 })?;
374
375 anyhow::Ok(())
376 })
377 .detach();
378
379 let snapshot = repository.read(cx).snapshot();
380 let remote_url = snapshot
381 .remote_upstream_url
382 .as_ref()
383 .or(snapshot.remote_origin_url.as_ref());
384
385 let remote = remote_url.and_then(|url| {
386 let provider_registry = GitHostingProviderRegistry::default_global(cx);
387 parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
388 host,
389 owner: parsed.owner.into(),
390 repo: parsed.repo.into(),
391 })
392 });
393
394 Self {
395 commit,
396 editor,
397 multibuffer,
398 stash,
399 repository,
400 remote,
401 }
402 }
403
404 fn render_commit_avatar(
405 &self,
406 sha: &SharedString,
407 size: impl Into<gpui::AbsoluteLength>,
408 window: &mut Window,
409 cx: &mut App,
410 ) -> AnyElement {
411 CommitAvatar::new(
412 sha,
413 Some(self.commit.author_email.clone()),
414 self.remote.as_ref(),
415 )
416 .size(size)
417 .render(window, cx)
418 }
419
420 fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
421 let snapshot = self.multibuffer.read(cx).snapshot(cx);
422 let mut total_additions = 0u32;
423 let mut total_deletions = 0u32;
424
425 let mut seen_buffers = std::collections::HashSet::new();
426 for (_, buffer, _) in snapshot.excerpts() {
427 let buffer_id = buffer.remote_id();
428 if !seen_buffers.insert(buffer_id) {
429 continue;
430 }
431
432 let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else {
433 continue;
434 };
435
436 let base_text = diff.base_text();
437
438 for hunk in
439 diff.hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer_id), buffer)
440 {
441 let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
442 total_additions += added_rows;
443
444 let base_start = base_text
445 .offset_to_point(hunk.diff_base_byte_range.start)
446 .row;
447 let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row;
448 let deleted_rows = base_end.saturating_sub(base_start);
449
450 total_deletions += deleted_rows;
451 }
452 }
453
454 (total_additions, total_deletions)
455 }
456
457 fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
458 let commit = &self.commit;
459 let author_name = commit.author_name.clone();
460 let author_email = commit.author_email.clone();
461 let commit_sha = commit.sha.clone();
462 let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
463 .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
464 let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
465 let date_string = time_format::format_localized_timestamp(
466 commit_date,
467 time::OffsetDateTime::now_utc(),
468 local_offset,
469 time_format::TimestampFormat::MediumAbsolute,
470 );
471
472 let gutter_width = self.editor.update(cx, |editor, cx| {
473 let snapshot = editor.snapshot(window, cx);
474 let style = editor.style(cx);
475 let font_id = window.text_system().resolve_font(&style.text.font());
476 let font_size = style.text.font_size.to_pixels(window.rem_size());
477 snapshot
478 .gutter_dimensions(font_id, font_size, style, window, cx)
479 .full_width()
480 });
481
482 let clipboard_has_sha = cx
483 .read_from_clipboard()
484 .and_then(|entry| entry.text())
485 .map_or(false, |clipboard_text| {
486 clipboard_text.trim() == commit_sha.as_ref()
487 });
488
489 let (copy_icon, copy_icon_color) = if clipboard_has_sha {
490 (IconName::Check, Color::Success)
491 } else {
492 (IconName::Copy, Color::Muted)
493 };
494
495 h_flex()
496 .py_2()
497 .pr_2p5()
498 .w_full()
499 .justify_between()
500 .border_b_1()
501 .border_color(cx.theme().colors().border_variant)
502 .child(
503 h_flex()
504 .child(h_flex().w(gutter_width).justify_center().child(
505 self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx),
506 ))
507 .child(
508 v_flex().child(Label::new(author_name)).child(
509 h_flex()
510 .gap_1p5()
511 .child(
512 Label::new(date_string)
513 .color(Color::Muted)
514 .size(LabelSize::Small),
515 )
516 .child(
517 Label::new("•")
518 .size(LabelSize::Small)
519 .color(Color::Muted)
520 .alpha(0.5),
521 )
522 .child(
523 Label::new(author_email)
524 .color(Color::Muted)
525 .size(LabelSize::Small),
526 ),
527 ),
528 ),
529 )
530 .when(self.stash.is_none(), |this| {
531 this.child(
532 Button::new("sha", "Commit SHA")
533 .icon(copy_icon)
534 .icon_color(copy_icon_color)
535 .icon_position(IconPosition::Start)
536 .icon_size(IconSize::Small)
537 .tooltip({
538 let commit_sha = commit_sha.clone();
539 move |_, cx| {
540 Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx)
541 }
542 })
543 .on_click(move |_, _, cx| {
544 cx.stop_propagation();
545 cx.write_to_clipboard(ClipboardItem::new_string(
546 commit_sha.to_string(),
547 ));
548 }),
549 )
550 })
551 }
552
553 fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
554 Self::stash_action(
555 workspace,
556 "Apply",
557 window,
558 cx,
559 async move |repository, sha, stash, commit_view, workspace, cx| {
560 let result = repository.update(cx, |repo, cx| {
561 if !stash_matches_index(&sha, stash, repo) {
562 return Err(anyhow::anyhow!("Stash has changed, not applying"));
563 }
564 Ok(repo.stash_apply(Some(stash), cx))
565 });
566
567 match result {
568 Ok(task) => task.await?,
569 Err(err) => {
570 Self::close_commit_view(commit_view, workspace, cx).await?;
571 return Err(err);
572 }
573 };
574 Self::close_commit_view(commit_view, workspace, cx).await?;
575 anyhow::Ok(())
576 },
577 );
578 }
579
580 fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
581 Self::stash_action(
582 workspace,
583 "Pop",
584 window,
585 cx,
586 async move |repository, sha, stash, commit_view, workspace, cx| {
587 let result = repository.update(cx, |repo, cx| {
588 if !stash_matches_index(&sha, stash, repo) {
589 return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
590 }
591 Ok(repo.stash_pop(Some(stash), cx))
592 });
593
594 match result {
595 Ok(task) => task.await?,
596 Err(err) => {
597 Self::close_commit_view(commit_view, workspace, cx).await?;
598 return Err(err);
599 }
600 };
601 Self::close_commit_view(commit_view, workspace, cx).await?;
602 anyhow::Ok(())
603 },
604 );
605 }
606
607 fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
608 Self::stash_action(
609 workspace,
610 "Drop",
611 window,
612 cx,
613 async move |repository, sha, stash, commit_view, workspace, cx| {
614 let result = repository.update(cx, |repo, cx| {
615 if !stash_matches_index(&sha, stash, repo) {
616 return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
617 }
618 Ok(repo.stash_drop(Some(stash), cx))
619 });
620
621 match result {
622 Ok(task) => task.await??,
623 Err(err) => {
624 Self::close_commit_view(commit_view, workspace, cx).await?;
625 return Err(err);
626 }
627 };
628 Self::close_commit_view(commit_view, workspace, cx).await?;
629 anyhow::Ok(())
630 },
631 );
632 }
633
634 fn stash_action<AsyncFn>(
635 workspace: &mut Workspace,
636 str_action: &str,
637 window: &mut Window,
638 cx: &mut App,
639 callback: AsyncFn,
640 ) where
641 AsyncFn: AsyncFnOnce(
642 Entity<Repository>,
643 &SharedString,
644 usize,
645 Entity<CommitView>,
646 WeakEntity<Workspace>,
647 &mut AsyncWindowContext,
648 ) -> anyhow::Result<()>
649 + 'static,
650 {
651 let Some(commit_view) = workspace.active_item_as::<CommitView>(cx) else {
652 return;
653 };
654 let Some(stash) = commit_view.read(cx).stash else {
655 return;
656 };
657 let sha = commit_view.read(cx).commit.sha.clone();
658 let answer = window.prompt(
659 PromptLevel::Info,
660 &format!("{} stash@{{{}}}?", str_action, stash),
661 None,
662 &[str_action, "Cancel"],
663 cx,
664 );
665
666 let workspace_weak = workspace.weak_handle();
667 let commit_view_entity = commit_view;
668
669 window
670 .spawn(cx, async move |cx| {
671 if answer.await != Ok(0) {
672 return anyhow::Ok(());
673 }
674
675 let Some(workspace) = workspace_weak.upgrade() else {
676 return Ok(());
677 };
678
679 let repo = workspace.update(cx, |workspace, cx| {
680 workspace
681 .panel::<GitPanel>(cx)
682 .and_then(|p| p.read(cx).active_repository.clone())
683 });
684
685 let Some(repo) = repo else {
686 return Ok(());
687 };
688
689 callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
690 anyhow::Ok(())
691 })
692 .detach_and_notify_err(workspace.weak_handle(), window, cx);
693 }
694
695 async fn close_commit_view(
696 commit_view: Entity<CommitView>,
697 workspace: WeakEntity<Workspace>,
698 cx: &mut AsyncWindowContext,
699 ) -> anyhow::Result<()> {
700 workspace
701 .update_in(cx, |workspace, window, cx| {
702 let active_pane = workspace.active_pane();
703 let commit_view_id = commit_view.entity_id();
704 active_pane.update(cx, |pane, cx| {
705 pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
706 })
707 })?
708 .await?;
709 anyhow::Ok(())
710 }
711}
712
713impl language::File for GitBlob {
714 fn as_local(&self) -> Option<&dyn language::LocalFile> {
715 None
716 }
717
718 fn disk_state(&self) -> DiskState {
719 DiskState::Historic {
720 was_deleted: self.is_deleted,
721 }
722 }
723
724 fn path_style(&self, _: &App) -> PathStyle {
725 PathStyle::local()
726 }
727
728 fn path(&self) -> &Arc<RelPath> {
729 self.path.as_ref()
730 }
731
732 fn full_path(&self, _: &App) -> PathBuf {
733 self.path.as_std_path().to_path_buf()
734 }
735
736 fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
737 self.display_name.as_ref()
738 }
739
740 fn worktree_id(&self, _: &App) -> WorktreeId {
741 self.worktree_id
742 }
743
744 fn to_proto(&self, _cx: &App) -> language::proto::File {
745 unimplemented!()
746 }
747
748 fn is_private(&self) -> bool {
749 false
750 }
751
752 fn can_open(&self) -> bool {
753 !self.is_binary
754 }
755}
756
757async fn build_buffer(
758 mut text: String,
759 blob: Arc<dyn File>,
760 language_registry: &Arc<language::LanguageRegistry>,
761 cx: &mut AsyncApp,
762) -> Result<Entity<Buffer>> {
763 let line_ending = LineEnding::detect(&text);
764 LineEnding::normalize(&mut text);
765 let text = Rope::from(text);
766 let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx));
767 let language = if let Some(language) = language {
768 language_registry
769 .load_language(&language)
770 .await
771 .ok()
772 .and_then(|e| e.log_err())
773 } else {
774 None
775 };
776 let buffer = cx.new(|cx| {
777 let buffer = TextBuffer::new_normalized(
778 ReplicaId::LOCAL,
779 cx.entity_id().as_non_zero_u64().into(),
780 line_ending,
781 text,
782 );
783 let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite);
784 buffer.set_language_async(language, cx);
785 buffer
786 });
787 Ok(buffer)
788}
789
790async fn build_buffer_diff(
791 mut old_text: Option<String>,
792 buffer: &Entity<Buffer>,
793 language_registry: &Arc<LanguageRegistry>,
794 cx: &mut AsyncApp,
795) -> Result<Entity<BufferDiff>> {
796 if let Some(old_text) = &mut old_text {
797 LineEnding::normalize(old_text);
798 }
799
800 let language = cx.update(|cx| buffer.read(cx).language().cloned());
801 let buffer = cx.update(|cx| buffer.read(cx).snapshot());
802
803 let diff = cx.new(|cx| BufferDiff::new(&buffer.text, cx));
804
805 let update = diff
806 .update(cx, |diff, cx| {
807 diff.update_diff(
808 buffer.text.clone(),
809 old_text.map(|old_text| Arc::from(old_text.as_str())),
810 Some(true),
811 language.clone(),
812 cx,
813 )
814 })
815 .await;
816
817 diff.update(cx, |diff, cx| {
818 diff.language_changed(language, Some(language_registry.clone()), cx);
819 diff.set_snapshot(update, &buffer.text, cx)
820 })
821 .await;
822
823 Ok(diff)
824}
825
826impl EventEmitter<EditorEvent> for CommitView {}
827
828impl Focusable for CommitView {
829 fn focus_handle(&self, cx: &App) -> FocusHandle {
830 self.editor.focus_handle(cx)
831 }
832}
833
834impl Item for CommitView {
835 type Event = EditorEvent;
836
837 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
838 Some(Icon::new(IconName::GitCommit).color(Color::Muted))
839 }
840
841 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
842 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
843 .color(if params.selected {
844 Color::Default
845 } else {
846 Color::Muted
847 })
848 .into_any_element()
849 }
850
851 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
852 let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
853 let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
854 format!("{short_sha} — {subject}").into()
855 }
856
857 fn tab_tooltip_content(&self, _: &App) -> Option<TabTooltipContent> {
858 let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
859 let subject = self.commit.message.split('\n').next().unwrap();
860
861 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
862 let subject = subject.to_string();
863 let short_sha = short_sha.to_string();
864
865 move |_, _| {
866 v_flex()
867 .child(Label::new(subject.clone()))
868 .child(
869 Label::new(short_sha.clone())
870 .color(Color::Muted)
871 .size(LabelSize::Small),
872 )
873 .into_any_element()
874 }
875 }))))
876 }
877
878 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
879 Editor::to_item_events(event, f)
880 }
881
882 fn telemetry_event_text(&self) -> Option<&'static str> {
883 Some("Commit View Opened")
884 }
885
886 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
887 self.editor
888 .update(cx, |editor, cx| editor.deactivated(window, cx));
889 }
890
891 fn act_as_type<'a>(
892 &'a self,
893 type_id: TypeId,
894 self_handle: &'a Entity<Self>,
895 _: &'a App,
896 ) -> Option<gpui::AnyEntity> {
897 if type_id == TypeId::of::<Self>() {
898 Some(self_handle.clone().into())
899 } else if type_id == TypeId::of::<Editor>() {
900 Some(self.editor.clone().into())
901 } else {
902 None
903 }
904 }
905
906 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
907 Some(Box::new(self.editor.clone()))
908 }
909
910 fn for_each_project_item(
911 &self,
912 cx: &App,
913 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
914 ) {
915 self.editor.for_each_project_item(cx, f)
916 }
917
918 fn set_nav_history(
919 &mut self,
920 nav_history: ItemNavHistory,
921 _: &mut Window,
922 cx: &mut Context<Self>,
923 ) {
924 self.editor.update(cx, |editor, _| {
925 editor.set_nav_history(Some(nav_history));
926 });
927 }
928
929 fn navigate(
930 &mut self,
931 data: Arc<dyn Any + Send>,
932 window: &mut Window,
933 cx: &mut Context<Self>,
934 ) -> bool {
935 self.editor
936 .update(cx, |editor, cx| editor.navigate(data, window, cx))
937 }
938
939 fn added_to_workspace(
940 &mut self,
941 workspace: &mut Workspace,
942 window: &mut Window,
943 cx: &mut Context<Self>,
944 ) {
945 self.editor.update(cx, |editor, cx| {
946 editor.added_to_workspace(workspace, window, cx)
947 });
948 }
949
950 fn can_split(&self) -> bool {
951 true
952 }
953
954 fn clone_on_split(
955 &self,
956 _workspace_id: Option<workspace::WorkspaceId>,
957 window: &mut Window,
958 cx: &mut Context<Self>,
959 ) -> Task<Option<Entity<Self>>>
960 where
961 Self: Sized,
962 {
963 let file_statuses = self
964 .editor
965 .read(cx)
966 .addon::<CommitDiffAddon>()
967 .map(|addon| addon.file_statuses.clone())
968 .unwrap_or_default();
969 Task::ready(Some(cx.new(|cx| {
970 let editor = cx.new({
971 let file_statuses = file_statuses.clone();
972 |cx| {
973 let mut editor = self
974 .editor
975 .update(cx, |editor, cx| editor.clone(window, cx));
976 editor.register_addon(CommitDiffAddon { file_statuses });
977 editor
978 }
979 });
980 let multibuffer = editor.read(cx).buffer().clone();
981 Self {
982 editor,
983 multibuffer,
984 commit: self.commit.clone(),
985 stash: self.stash,
986 repository: self.repository.clone(),
987 remote: self.remote.clone(),
988 }
989 })))
990 }
991}
992
993impl Render for CommitView {
994 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
995 let is_stash = self.stash.is_some();
996
997 v_flex()
998 .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
999 .size_full()
1000 .bg(cx.theme().colors().editor_background)
1001 .child(self.render_header(window, cx))
1002 .when(!self.editor.read(cx).is_empty(cx), |this| {
1003 this.child(div().flex_grow().child(self.editor.clone()))
1004 })
1005 }
1006}
1007
1008pub struct CommitViewToolbar {
1009 commit_view: Option<WeakEntity<CommitView>>,
1010}
1011
1012impl CommitViewToolbar {
1013 pub fn new() -> Self {
1014 Self { commit_view: None }
1015 }
1016}
1017
1018impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
1019
1020impl Render for CommitViewToolbar {
1021 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1022 let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else {
1023 return div();
1024 };
1025
1026 let commit_view_ref = commit_view.read(cx);
1027 let is_stash = commit_view_ref.stash.is_some();
1028
1029 let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx);
1030
1031 let commit_sha = commit_view_ref.commit.sha.clone();
1032
1033 let remote_info = commit_view_ref.remote.as_ref().map(|remote| {
1034 let provider = remote.host.name();
1035 let parsed_remote = ParsedGitRemote {
1036 owner: remote.owner.as_ref().into(),
1037 repo: remote.repo.as_ref().into(),
1038 };
1039 let params = BuildCommitPermalinkParams { sha: &commit_sha };
1040 let url = remote
1041 .host
1042 .build_commit_permalink(&parsed_remote, params)
1043 .to_string();
1044 (provider, url)
1045 });
1046
1047 let sha_for_graph = commit_sha.to_string();
1048
1049 h_flex()
1050 .gap_1()
1051 .when(additions > 0 || deletions > 0, |this| {
1052 this.child(
1053 h_flex()
1054 .gap_2()
1055 .child(DiffStat::new(
1056 "toolbar-diff-stat",
1057 additions as usize,
1058 deletions as usize,
1059 ))
1060 .child(Divider::vertical()),
1061 )
1062 })
1063 .child(
1064 IconButton::new("buffer-search", IconName::MagnifyingGlass)
1065 .icon_size(IconSize::Small)
1066 .tooltip(move |_, cx| {
1067 Tooltip::for_action(
1068 "Buffer Search",
1069 &zed_actions::buffer_search::Deploy::find(),
1070 cx,
1071 )
1072 })
1073 .on_click(|_, window, cx| {
1074 window.dispatch_action(
1075 Box::new(zed_actions::buffer_search::Deploy::find()),
1076 cx,
1077 );
1078 }),
1079 )
1080 .when(!is_stash, |this| {
1081 this.when(cx.has_flag::<GitGraphFeatureFlag>(), |this| {
1082 this.child(
1083 IconButton::new("show-in-git-graph", IconName::GitGraph)
1084 .icon_size(IconSize::Small)
1085 .tooltip(Tooltip::text("Show in Git Graph"))
1086 .on_click(move |_, window, cx| {
1087 window.dispatch_action(
1088 Box::new(crate::git_panel::OpenAtCommit {
1089 sha: sha_for_graph.clone(),
1090 }),
1091 cx,
1092 );
1093 }),
1094 )
1095 })
1096 .children(remote_info.map(|(provider_name, url)| {
1097 let icon = match provider_name.as_str() {
1098 "GitHub" => IconName::Github,
1099 _ => IconName::Link,
1100 };
1101
1102 IconButton::new("view_on_provider", icon)
1103 .icon_size(IconSize::Small)
1104 .tooltip(Tooltip::text(format!("View on {}", provider_name)))
1105 .on_click(move |_, _, cx| cx.open_url(&url))
1106 }))
1107 })
1108 }
1109}
1110
1111impl ToolbarItemView for CommitViewToolbar {
1112 fn set_active_pane_item(
1113 &mut self,
1114 active_pane_item: Option<&dyn ItemHandle>,
1115 _: &mut Window,
1116 cx: &mut Context<Self>,
1117 ) -> ToolbarItemLocation {
1118 if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx)) {
1119 self.commit_view = Some(entity.downgrade());
1120 return ToolbarItemLocation::PrimaryRight;
1121 }
1122 self.commit_view = None;
1123 ToolbarItemLocation::Hidden
1124 }
1125
1126 fn pane_focus_update(
1127 &mut self,
1128 _pane_focused: bool,
1129 _window: &mut Window,
1130 _cx: &mut Context<Self>,
1131 ) {
1132 }
1133}
1134
1135fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool {
1136 repo.stash_entries
1137 .entries
1138 .get(stash_index)
1139 .map(|entry| entry.oid.to_string() == sha)
1140 .unwrap_or(false)
1141}