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