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