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