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