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