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