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