1use anyhow::{Context as _, Result};
2use buffer_diff::BufferDiff;
3use collections::HashMap;
4use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
5use editor::scroll::Autoscroll;
6use editor::{
7 Addon, BufferOffset, Editor, EditorEvent, ExcerptRange, MultiBuffer, MultiBufferOffset,
8 SelectionEffects, ToOffset, multibuffer_context_lines,
9};
10use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
11use git::status::{FileStatus, StatusCode, TrackedStatus};
12use git::{
13 BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote,
14 parse_git_remote_url,
15};
16use gpui::{
17 AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context, Entity,
18 EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
19 PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
20};
21use language::{
22 Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
23 Point, ReplicaId, Rope, TextBuffer,
24};
25use multi_buffer::{MultiBufferRow, PathKey};
26use project::{Project, WorktreeId, git_store::Repository};
27use std::{
28 any::{Any, TypeId},
29 collections::HashSet,
30 path::PathBuf,
31 sync::Arc,
32};
33use theme::ActiveTheme;
34use ui::{DiffStat, Divider, Tooltip, prelude::*};
35use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
36use workspace::item::TabTooltipContent;
37use workspace::{
38 Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
39 Workspace,
40 item::{ItemEvent, TabContentParams},
41 notifications::NotifyTaskExt,
42 pane::SaveIntent,
43 searchable::SearchableItemHandle,
44};
45
46use crate::commit_tooltip::CommitAvatar;
47use crate::git_panel::GitPanel;
48
49actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]);
50
51pub fn init(cx: &mut App) {
52 cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
53 workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| {
54 CommitView::apply_stash(workspace, window, cx);
55 });
56 workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| {
57 CommitView::remove_stash(workspace, window, cx);
58 });
59 workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| {
60 CommitView::pop_stash(workspace, window, cx);
61 });
62 })
63 .detach();
64}
65
66pub struct CommitView {
67 commit: CommitDetails,
68 editor: Entity<Editor>,
69 stash: Option<usize>,
70 multibuffer: Entity<MultiBuffer>,
71 repository: Entity<Repository>,
72 remote: Option<GitRemote>,
73}
74
75struct GitBlob {
76 path: RepoPath,
77 worktree_id: WorktreeId,
78 is_deleted: bool,
79 is_binary: bool,
80 display_name: String,
81}
82
83struct CommitDiffAddon {
84 file_statuses: HashMap<language::BufferId, FileStatus>,
85}
86
87impl Addon for CommitDiffAddon {
88 fn to_any(&self) -> &dyn std::any::Any {
89 self
90 }
91
92 fn override_status_for_buffer_id(
93 &self,
94 buffer_id: language::BufferId,
95 _cx: &App,
96 ) -> Option<FileStatus> {
97 self.file_statuses.get(&buffer_id).copied()
98 }
99}
100
101const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0;
102const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
103
104impl CommitView {
105 pub fn open(
106 commit_sha: String,
107 repo: WeakEntity<Repository>,
108 workspace: WeakEntity<Workspace>,
109 stash: Option<usize>,
110 file_filter: Option<RepoPath>,
111 clicked_point: Option<gpui::Point<Pixels>>,
112 window: &mut Window,
113 cx: &mut App,
114 ) {
115 let commit_diff = repo
116 .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone()))
117 .ok();
118 let commit_details = repo
119 .update(cx, |repo, _| repo.show(commit_sha.clone()))
120 .ok();
121
122 window
123 .spawn(cx, async move |cx| {
124 let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?);
125 let mut commit_diff = commit_diff.log_err()?.log_err()?;
126 let commit_details = commit_details.log_err()?.log_err()?;
127
128 // Filter to specific file if requested
129 if let Some(ref filter_path) = file_filter {
130 commit_diff.files.retain(|f| &f.path == filter_path);
131 }
132
133 let repo = repo.upgrade()?;
134
135 workspace
136 .update_in(cx, |workspace, window, cx| {
137 let pane = workspace.active_pane();
138 pane.update(cx, |pane, cx| {
139 let target_buffer = pane
140 .active_item()
141 .and_then(|item| item.act_as::<Editor>(cx))
142 .zip(clicked_point)
143 .and_then(|(e, clicked_point)| {
144 e.update(cx, |e, cx| {
145 let snapshot = e.snapshot(window, cx);
146 e.point_for_position(clicked_point)
147 .and_then(|p| p.as_valid())
148 .map(|a| a.to_point(&snapshot))
149 .and_then(|multi_buffer_point| {
150 snapshot.buffer().buffer_line_for_row(
151 MultiBufferRow(multi_buffer_point.row),
152 )
153 })
154 .and_then(|(buffer_snasphot, buffer_range)| {
155 // TODO kb consider remotes either!
156 Some((
157 buffer_range.start.row,
158 buffer_snasphot
159 .file()?
160 .as_local()?
161 .abs_path(cx),
162 ))
163 })
164 })
165 });
166
167 let ix = pane.items().position(|item| {
168 let commit_view = item.downcast::<CommitView>();
169 // TODO kb later, generic code
170 commit_view
171 .is_some_and(|view| view.read(cx).commit.sha == commit_sha)
172 });
173 if let Some(ix) = ix {
174 pane.activate_item(ix, true, true, window, cx);
175 } else {
176 let project = workspace.project();
177 let commit_view = cx.new(|cx| {
178 CommitView::new(
179 commit_details,
180 commit_diff,
181 repo,
182 project.clone(),
183 stash,
184 window,
185 cx,
186 )
187 });
188
189 if let Some((target_buffer_row, target_buffer_path)) =
190 dbg!(target_buffer)
191 {
192 let commit_view_editor = commit_view.read(cx).editor.clone();
193 cx.defer_in(window, move |_, window, cx| {
194 commit_view_editor.update(cx, |editor, cx| {
195 let multi_buffer_snapshot =
196 editor.buffer().read(cx).snapshot(cx);
197 let target_anchors = multi_buffer_snapshot
198 .map_excerpt_ranges(
199 MultiBufferOffset(0)
200 ..multi_buffer_snapshot.len(),
201 |buffer, _, buffer_range| {
202 dbg!(
203 // TODO kb what to do next??? this is None for the git-history ones!!
204 buffer.file().is_some(),
205 &buffer_range
206 );
207 if buffer.file().and_then(|f| {
208 Some(dbg!(f.as_local()?.abs_path(cx)))
209 }) == Some(target_buffer_path)
210 {
211 let excerpt_point_range =
212 buffer_range.to_point(buffer);
213 dbg!(&excerpt_point_range);
214 if excerpt_point_range.start.row
215 >= target_buffer_row
216 && excerpt_point_range.end.row
217 < target_buffer_row
218 {
219 let buffer_offset = BufferOffset(
220 buffer.point_to_offset(
221 Point::new(
222 target_buffer_row,
223 0,
224 ),
225 ),
226 );
227 return vec![(
228 buffer_offset..buffer_offset,
229 (),
230 )];
231 }
232 }
233 Vec::new()
234 },
235 )
236 .unwrap_or_default();
237 if let Some((target_multi_buffer_offset, _)) =
238 dbg!(target_anchors.first())
239 {
240 let target_point_start = multi_buffer_snapshot
241 .offset_to_point(
242 target_multi_buffer_offset.start,
243 );
244 let target_point_end = multi_buffer_snapshot
245 .offset_to_point(
246 target_multi_buffer_offset.end,
247 );
248 editor.change_selections(
249 SelectionEffects::scroll(Autoscroll::center()),
250 window,
251 cx,
252 |s| {
253 s.select_ranges([
254 target_point_start..target_point_end
255 ])
256 },
257 );
258 }
259 });
260 });
261 }
262
263 pane.add_item(Box::new(commit_view), true, true, None, window, cx);
264 }
265 })
266 })
267 .log_err()
268 })
269 .detach();
270 }
271
272 fn new(
273 commit: CommitDetails,
274 commit_diff: CommitDiff,
275 repository: Entity<Repository>,
276 project: Entity<Project>,
277 stash: Option<usize>,
278 window: &mut Window,
279 cx: &mut Context<Self>,
280 ) -> Self {
281 let language_registry = project.read(cx).languages().clone();
282 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly));
283
284 let message_buffer = cx.new(|cx| {
285 let mut buffer = Buffer::local(commit.message.clone(), cx);
286 buffer.set_capability(Capability::ReadOnly, cx);
287 buffer
288 });
289
290 multibuffer.update(cx, |multibuffer, cx| {
291 let snapshot = message_buffer.read(cx).snapshot();
292 let full_range = Point::zero()..snapshot.max_point();
293 let range = ExcerptRange {
294 context: full_range.clone(),
295 primary: full_range,
296 };
297 multibuffer.set_excerpt_ranges_for_path(
298 PathKey::with_sort_prefix(
299 COMMIT_MESSAGE_SORT_PREFIX,
300 RelPath::unix("commit message").unwrap().into(),
301 ),
302 message_buffer.clone(),
303 &snapshot,
304 vec![range],
305 cx,
306 )
307 });
308
309 let editor = cx.new(|cx| {
310 let mut editor =
311 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
312
313 editor.disable_inline_diagnostics();
314 editor.set_show_breakpoints(false, cx);
315 editor.set_show_diff_review_button(true, cx);
316 editor.set_expand_all_diff_hunks(cx);
317 editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx);
318 editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx);
319
320 editor.insert_blocks(
321 [BlockProperties {
322 placement: BlockPlacement::Above(editor::Anchor::Min),
323 height: Some(1),
324 style: BlockStyle::Sticky,
325 render: Arc::new(|_| gpui::Empty.into_any_element()),
326 priority: 0,
327 }]
328 .into_iter()
329 .chain(
330 editor
331 .buffer()
332 .read(cx)
333 .snapshot(cx)
334 .anchor_in_buffer(Anchor::max_for_buffer(
335 message_buffer.read(cx).remote_id(),
336 ))
337 .map(|anchor| BlockProperties {
338 placement: BlockPlacement::Below(anchor),
339 height: Some(1),
340 style: BlockStyle::Sticky,
341 render: Arc::new(|_| gpui::Empty.into_any_element()),
342 priority: 0,
343 }),
344 ),
345 None,
346 cx,
347 );
348
349 editor
350 });
351
352 let commit_sha = Arc::<str>::from(commit.sha.as_ref());
353
354 let first_worktree_id = project
355 .read(cx)
356 .worktrees(cx)
357 .next()
358 .map(|worktree| worktree.read(cx).id());
359
360 let repository_clone = repository.clone();
361
362 cx.spawn(async move |this, cx| {
363 let mut binary_buffer_ids: HashSet<language::BufferId> = HashSet::default();
364 let mut file_statuses: HashMap<language::BufferId, FileStatus> = HashMap::default();
365
366 for file in commit_diff.files {
367 let is_created = file.old_text.is_none();
368 let is_deleted = file.new_text.is_none();
369 let raw_new_text = file.new_text.unwrap_or_default();
370 let raw_old_text = file.old_text;
371
372 let is_binary = file.is_binary
373 || is_binary_content(raw_new_text.as_bytes())
374 || raw_old_text
375 .as_ref()
376 .is_some_and(|text| is_binary_content(text.as_bytes()));
377
378 let new_text = if is_binary {
379 "(binary file not shown)".to_string()
380 } else {
381 raw_new_text
382 };
383 let old_text = if is_binary { None } else { raw_old_text };
384 let worktree_id = repository_clone
385 .update(cx, |repository, cx| {
386 repository
387 .repo_path_to_project_path(&file.path, cx)
388 .map(|path| path.worktree_id)
389 .or(first_worktree_id)
390 })
391 .context("project has no worktrees")?;
392 let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha);
393 let file_name = file
394 .path
395 .file_name()
396 .map(|name| name.to_string())
397 .unwrap_or_else(|| file.path.display(PathStyle::local()).to_string());
398 let display_name = format!("{short_sha} - {file_name}");
399
400 let file = Arc::new(GitBlob {
401 path: file.path.clone(),
402 is_deleted,
403 is_binary,
404 worktree_id,
405 display_name,
406 }) as Arc<dyn language::File>;
407
408 let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
409 let buffer_id = cx.update(|cx| buffer.read(cx).remote_id());
410
411 let status_code = if is_created {
412 StatusCode::Added
413 } else if is_deleted {
414 StatusCode::Deleted
415 } else {
416 StatusCode::Modified
417 };
418 file_statuses.insert(
419 buffer_id,
420 FileStatus::Tracked(TrackedStatus {
421 index_status: status_code,
422 worktree_status: StatusCode::Unmodified,
423 }),
424 );
425
426 if is_binary {
427 binary_buffer_ids.insert(buffer_id);
428 }
429
430 let buffer_diff = if is_binary {
431 None
432 } else {
433 Some(build_buffer_diff(old_text, &buffer, &language_registry, cx).await?)
434 };
435
436 this.update(cx, |this, cx| {
437 this.multibuffer.update(cx, |multibuffer, cx| {
438 let snapshot = buffer.read(cx).snapshot();
439 let path = snapshot.file().unwrap().path().clone();
440 let excerpt_ranges = if is_binary {
441 vec![language::Point::zero()..snapshot.max_point()]
442 } else if let Some(buffer_diff) = &buffer_diff {
443 let diff_snapshot = buffer_diff.read(cx).snapshot(cx);
444 let mut hunks = diff_snapshot.hunks(&snapshot).peekable();
445 if hunks.peek().is_none() {
446 vec![language::Point::zero()..snapshot.max_point()]
447 } else {
448 hunks
449 .map(|hunk| hunk.buffer_range.to_point(&snapshot))
450 .collect::<Vec<_>>()
451 }
452 } else {
453 vec![language::Point::zero()..snapshot.max_point()]
454 };
455
456 let _is_newly_added = multibuffer.set_excerpts_for_path(
457 PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
458 buffer,
459 excerpt_ranges,
460 multibuffer_context_lines(cx),
461 cx,
462 );
463 if let Some(buffer_diff) = buffer_diff {
464 multibuffer.add_diff(buffer_diff, cx);
465 }
466 });
467 })?;
468 }
469
470 this.update(cx, |this, cx| {
471 this.editor.update(cx, |editor, _cx| {
472 editor.register_addon(CommitDiffAddon { file_statuses });
473 });
474 if !binary_buffer_ids.is_empty() {
475 this.editor.update(cx, |editor, cx| {
476 editor.fold_buffers(binary_buffer_ids, cx);
477 });
478 }
479 })?;
480
481 anyhow::Ok(())
482 })
483 .detach();
484
485 let snapshot = repository.read(cx).snapshot();
486 let remote_url = snapshot
487 .remote_upstream_url
488 .as_ref()
489 .or(snapshot.remote_origin_url.as_ref());
490
491 let remote = remote_url.and_then(|url| {
492 let provider_registry = GitHostingProviderRegistry::default_global(cx);
493 parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
494 host,
495 owner: parsed.owner.into(),
496 repo: parsed.repo.into(),
497 })
498 });
499
500 Self {
501 commit,
502 editor,
503 multibuffer,
504 stash,
505 repository,
506 remote,
507 }
508 }
509
510 fn render_commit_avatar(
511 &self,
512 sha: &SharedString,
513 size: impl Into<gpui::AbsoluteLength>,
514 window: &mut Window,
515 cx: &mut App,
516 ) -> AnyElement {
517 CommitAvatar::new(
518 sha,
519 Some(self.commit.author_email.clone()),
520 self.remote.as_ref(),
521 )
522 .size(size)
523 .render(window, cx)
524 }
525
526 fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
527 self.multibuffer.read(cx).snapshot(cx).total_changed_lines()
528 }
529
530 fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
531 let commit = &self.commit;
532 let author_name = commit.author_name.clone();
533 let author_email = commit.author_email.clone();
534 let commit_sha = commit.sha.clone();
535 let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
536 .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
537 let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
538 let date_string = time_format::format_localized_timestamp(
539 commit_date,
540 time::OffsetDateTime::now_utc(),
541 local_offset,
542 time_format::TimestampFormat::MediumAbsolute,
543 );
544
545 let gutter_width = self.editor.update(cx, |editor, cx| {
546 let snapshot = editor.snapshot(window, cx);
547 let style = editor.style(cx);
548 let font_id = window.text_system().resolve_font(&style.text.font());
549 let font_size = style.text.font_size.to_pixels(window.rem_size());
550 snapshot
551 .gutter_dimensions(font_id, font_size, style, window, cx)
552 .full_width()
553 });
554
555 let clipboard_has_sha = cx
556 .read_from_clipboard()
557 .and_then(|entry| entry.text())
558 .map_or(false, |clipboard_text| {
559 clipboard_text.trim() == commit_sha.as_ref()
560 });
561
562 let (copy_icon, copy_icon_color) = if clipboard_has_sha {
563 (IconName::Check, Color::Success)
564 } else {
565 (IconName::Copy, Color::Muted)
566 };
567
568 h_flex()
569 .py_2()
570 .pr_2p5()
571 .w_full()
572 .justify_between()
573 .border_b_1()
574 .border_color(cx.theme().colors().border_variant)
575 .child(
576 h_flex()
577 .child(h_flex().w(gutter_width).justify_center().child(
578 self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx),
579 ))
580 .child(
581 v_flex().child(Label::new(author_name)).child(
582 h_flex()
583 .gap_1p5()
584 .child(
585 Label::new(date_string)
586 .color(Color::Muted)
587 .size(LabelSize::Small),
588 )
589 .child(
590 Label::new("•")
591 .size(LabelSize::Small)
592 .color(Color::Muted)
593 .alpha(0.5),
594 )
595 .child(
596 Label::new(author_email)
597 .color(Color::Muted)
598 .size(LabelSize::Small),
599 ),
600 ),
601 ),
602 )
603 .when(self.stash.is_none(), |this| {
604 this.child(
605 Button::new("sha", "Commit SHA")
606 .start_icon(
607 Icon::new(copy_icon)
608 .size(IconSize::Small)
609 .color(copy_icon_color),
610 )
611 .tooltip({
612 let commit_sha = commit_sha.clone();
613 move |_, cx| {
614 Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx)
615 }
616 })
617 .on_click(move |_, _, cx| {
618 cx.stop_propagation();
619 cx.write_to_clipboard(ClipboardItem::new_string(
620 commit_sha.to_string(),
621 ));
622 }),
623 )
624 })
625 }
626
627 fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
628 Self::stash_action(
629 workspace,
630 "Apply",
631 window,
632 cx,
633 async move |repository, sha, stash, commit_view, workspace, cx| {
634 let result = repository.update(cx, |repo, cx| {
635 if !stash_matches_index(&sha, stash, repo) {
636 return Err(anyhow::anyhow!("Stash has changed, not applying"));
637 }
638 Ok(repo.stash_apply(Some(stash), cx))
639 });
640
641 match result {
642 Ok(task) => task.await?,
643 Err(err) => {
644 Self::close_commit_view(commit_view, workspace, cx).await?;
645 return Err(err);
646 }
647 };
648 Self::close_commit_view(commit_view, workspace, cx).await?;
649 anyhow::Ok(())
650 },
651 );
652 }
653
654 fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
655 Self::stash_action(
656 workspace,
657 "Pop",
658 window,
659 cx,
660 async move |repository, sha, stash, commit_view, workspace, cx| {
661 let result = repository.update(cx, |repo, cx| {
662 if !stash_matches_index(&sha, stash, repo) {
663 return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
664 }
665 Ok(repo.stash_pop(Some(stash), cx))
666 });
667
668 match result {
669 Ok(task) => task.await?,
670 Err(err) => {
671 Self::close_commit_view(commit_view, workspace, cx).await?;
672 return Err(err);
673 }
674 };
675 Self::close_commit_view(commit_view, workspace, cx).await?;
676 anyhow::Ok(())
677 },
678 );
679 }
680
681 fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
682 Self::stash_action(
683 workspace,
684 "Drop",
685 window,
686 cx,
687 async move |repository, sha, stash, commit_view, workspace, cx| {
688 let result = repository.update(cx, |repo, cx| {
689 if !stash_matches_index(&sha, stash, repo) {
690 return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
691 }
692 Ok(repo.stash_drop(Some(stash), cx))
693 });
694
695 match result {
696 Ok(task) => task.await??,
697 Err(err) => {
698 Self::close_commit_view(commit_view, workspace, cx).await?;
699 return Err(err);
700 }
701 };
702 Self::close_commit_view(commit_view, workspace, cx).await?;
703 anyhow::Ok(())
704 },
705 );
706 }
707
708 fn stash_action<AsyncFn>(
709 workspace: &mut Workspace,
710 str_action: &str,
711 window: &mut Window,
712 cx: &mut App,
713 callback: AsyncFn,
714 ) where
715 AsyncFn: AsyncFnOnce(
716 Entity<Repository>,
717 &SharedString,
718 usize,
719 Entity<CommitView>,
720 WeakEntity<Workspace>,
721 &mut AsyncWindowContext,
722 ) -> anyhow::Result<()>
723 + 'static,
724 {
725 let Some(commit_view) = workspace.active_item_as::<CommitView>(cx) else {
726 return;
727 };
728 let Some(stash) = commit_view.read(cx).stash else {
729 return;
730 };
731 let sha = commit_view.read(cx).commit.sha.clone();
732 let answer = window.prompt(
733 PromptLevel::Info,
734 &format!("{} stash@{{{}}}?", str_action, stash),
735 None,
736 &[str_action, "Cancel"],
737 cx,
738 );
739
740 let workspace_weak = workspace.weak_handle();
741 let commit_view_entity = commit_view;
742
743 window
744 .spawn(cx, async move |cx| {
745 if answer.await != Ok(0) {
746 return anyhow::Ok(());
747 }
748
749 let Some(workspace) = workspace_weak.upgrade() else {
750 return Ok(());
751 };
752
753 let repo = workspace.update(cx, |workspace, cx| {
754 workspace
755 .panel::<GitPanel>(cx)
756 .and_then(|p| p.read(cx).active_repository.clone())
757 });
758
759 let Some(repo) = repo else {
760 return Ok(());
761 };
762
763 callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
764 anyhow::Ok(())
765 })
766 .detach_and_notify_err(workspace.weak_handle(), window, cx);
767 }
768
769 async fn close_commit_view(
770 commit_view: Entity<CommitView>,
771 workspace: WeakEntity<Workspace>,
772 cx: &mut AsyncWindowContext,
773 ) -> anyhow::Result<()> {
774 workspace
775 .update_in(cx, |workspace, window, cx| {
776 let active_pane = workspace.active_pane();
777 let commit_view_id = commit_view.entity_id();
778 active_pane.update(cx, |pane, cx| {
779 pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
780 })
781 })?
782 .await?;
783 anyhow::Ok(())
784 }
785}
786
787impl language::File for GitBlob {
788 fn as_local(&self) -> Option<&dyn language::LocalFile> {
789 None
790 }
791
792 fn disk_state(&self) -> DiskState {
793 DiskState::Historic {
794 was_deleted: self.is_deleted,
795 }
796 }
797
798 fn path_style(&self, _: &App) -> PathStyle {
799 PathStyle::local()
800 }
801
802 fn path(&self) -> &Arc<RelPath> {
803 self.path.as_ref()
804 }
805
806 fn full_path(&self, _: &App) -> PathBuf {
807 self.path.as_std_path().to_path_buf()
808 }
809
810 fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
811 self.display_name.as_ref()
812 }
813
814 fn worktree_id(&self, _: &App) -> WorktreeId {
815 self.worktree_id
816 }
817
818 fn to_proto(&self, _cx: &App) -> language::proto::File {
819 unimplemented!()
820 }
821
822 fn is_private(&self) -> bool {
823 false
824 }
825
826 fn can_open(&self) -> bool {
827 !self.is_binary
828 }
829}
830
831async fn build_buffer(
832 mut text: String,
833 blob: Arc<dyn File>,
834 language_registry: &Arc<language::LanguageRegistry>,
835 cx: &mut AsyncApp,
836) -> Result<Entity<Buffer>> {
837 let line_ending = LineEnding::detect(&text);
838 LineEnding::normalize(&mut text);
839 let text = Rope::from(text);
840 let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx));
841 let language = if let Some(language) = language {
842 language_registry
843 .load_language(&language)
844 .await
845 .ok()
846 .and_then(|e| e.log_err())
847 } else {
848 None
849 };
850 let buffer = cx.new(|cx| {
851 let buffer = TextBuffer::new_normalized(
852 ReplicaId::LOCAL,
853 cx.entity_id().as_non_zero_u64().into(),
854 line_ending,
855 text,
856 );
857 let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite);
858 buffer.set_language_async(language, cx);
859 buffer
860 });
861 Ok(buffer)
862}
863
864async fn build_buffer_diff(
865 mut old_text: Option<String>,
866 buffer: &Entity<Buffer>,
867 language_registry: &Arc<LanguageRegistry>,
868 cx: &mut AsyncApp,
869) -> Result<Entity<BufferDiff>> {
870 if let Some(old_text) = &mut old_text {
871 LineEnding::normalize(old_text);
872 }
873
874 let language = cx.update(|cx| buffer.read(cx).language().cloned());
875 let buffer = cx.update(|cx| buffer.read(cx).snapshot());
876
877 let diff = cx.new(|cx| BufferDiff::new(&buffer.text, cx));
878
879 let update = diff
880 .update(cx, |diff, cx| {
881 diff.update_diff(
882 buffer.text.clone(),
883 old_text.map(|old_text| Arc::from(old_text.as_str())),
884 Some(true),
885 language.clone(),
886 cx,
887 )
888 })
889 .await;
890
891 diff.update(cx, |diff, cx| {
892 diff.language_changed(language, Some(language_registry.clone()), cx);
893 diff.set_snapshot(update, &buffer.text, cx)
894 })
895 .await;
896
897 Ok(diff)
898}
899
900impl EventEmitter<EditorEvent> for CommitView {}
901
902impl Focusable for CommitView {
903 fn focus_handle(&self, cx: &App) -> FocusHandle {
904 self.editor.focus_handle(cx)
905 }
906}
907
908impl Item for CommitView {
909 type Event = EditorEvent;
910
911 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
912 Some(Icon::new(IconName::GitCommit).color(Color::Muted))
913 }
914
915 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
916 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
917 .color(if params.selected {
918 Color::Default
919 } else {
920 Color::Muted
921 })
922 .into_any_element()
923 }
924
925 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
926 let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
927 let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
928 format!("{short_sha} — {subject}").into()
929 }
930
931 fn tab_tooltip_content(&self, _: &App) -> Option<TabTooltipContent> {
932 let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
933 let subject = self.commit.message.split('\n').next().unwrap();
934
935 Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
936 let subject = subject.to_string();
937 let short_sha = short_sha.to_string();
938
939 move |_, _| {
940 v_flex()
941 .child(Label::new(subject.clone()))
942 .child(
943 Label::new(short_sha.clone())
944 .color(Color::Muted)
945 .size(LabelSize::Small),
946 )
947 .into_any_element()
948 }
949 }))))
950 }
951
952 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
953 Editor::to_item_events(event, f)
954 }
955
956 fn telemetry_event_text(&self) -> Option<&'static str> {
957 Some("Commit View Opened")
958 }
959
960 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
961 self.editor
962 .update(cx, |editor, cx| editor.deactivated(window, cx));
963 }
964
965 fn act_as_type<'a>(
966 &'a self,
967 type_id: TypeId,
968 self_handle: &'a Entity<Self>,
969 _: &'a App,
970 ) -> Option<gpui::AnyEntity> {
971 if type_id == TypeId::of::<Self>() {
972 Some(self_handle.clone().into())
973 } else if type_id == TypeId::of::<Editor>() {
974 Some(self.editor.clone().into())
975 } else {
976 None
977 }
978 }
979
980 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
981 Some(Box::new(self.editor.clone()))
982 }
983
984 fn for_each_project_item(
985 &self,
986 cx: &App,
987 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
988 ) {
989 self.editor.for_each_project_item(cx, f)
990 }
991
992 fn set_nav_history(
993 &mut self,
994 nav_history: ItemNavHistory,
995 _: &mut Window,
996 cx: &mut Context<Self>,
997 ) {
998 self.editor.update(cx, |editor, _| {
999 editor.set_nav_history(Some(nav_history));
1000 });
1001 }
1002
1003 fn navigate(
1004 &mut self,
1005 data: Arc<dyn Any + Send>,
1006 window: &mut Window,
1007 cx: &mut Context<Self>,
1008 ) -> bool {
1009 self.editor
1010 .update(cx, |editor, cx| editor.navigate(data, window, cx))
1011 }
1012
1013 fn added_to_workspace(
1014 &mut self,
1015 workspace: &mut Workspace,
1016 window: &mut Window,
1017 cx: &mut Context<Self>,
1018 ) {
1019 self.editor.update(cx, |editor, cx| {
1020 editor.added_to_workspace(workspace, window, cx)
1021 });
1022 }
1023
1024 fn can_split(&self) -> bool {
1025 true
1026 }
1027
1028 fn clone_on_split(
1029 &self,
1030 _workspace_id: Option<workspace::WorkspaceId>,
1031 window: &mut Window,
1032 cx: &mut Context<Self>,
1033 ) -> Task<Option<Entity<Self>>>
1034 where
1035 Self: Sized,
1036 {
1037 let file_statuses = self
1038 .editor
1039 .read(cx)
1040 .addon::<CommitDiffAddon>()
1041 .map(|addon| addon.file_statuses.clone())
1042 .unwrap_or_default();
1043 Task::ready(Some(cx.new(|cx| {
1044 let editor = cx.new({
1045 let file_statuses = file_statuses.clone();
1046 |cx| {
1047 let mut editor = self
1048 .editor
1049 .update(cx, |editor, cx| editor.clone(window, cx));
1050 editor.register_addon(CommitDiffAddon { file_statuses });
1051 editor
1052 }
1053 });
1054 let multibuffer = editor.read(cx).buffer().clone();
1055 Self {
1056 editor,
1057 multibuffer,
1058 commit: self.commit.clone(),
1059 stash: self.stash,
1060 repository: self.repository.clone(),
1061 remote: self.remote.clone(),
1062 }
1063 })))
1064 }
1065}
1066
1067impl Render for CommitView {
1068 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1069 let is_stash = self.stash.is_some();
1070
1071 v_flex()
1072 .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
1073 .size_full()
1074 .bg(cx.theme().colors().editor_background)
1075 .child(self.render_header(window, cx))
1076 .when(!self.editor.read(cx).is_empty(cx), |this| {
1077 this.child(div().flex_grow().child(self.editor.clone()))
1078 })
1079 }
1080}
1081
1082pub struct CommitViewToolbar {
1083 commit_view: Option<WeakEntity<CommitView>>,
1084}
1085
1086impl CommitViewToolbar {
1087 pub fn new() -> Self {
1088 Self { commit_view: None }
1089 }
1090}
1091
1092impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
1093
1094impl Render for CommitViewToolbar {
1095 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1096 let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else {
1097 return div();
1098 };
1099
1100 let commit_view_ref = commit_view.read(cx);
1101 let is_stash = commit_view_ref.stash.is_some();
1102
1103 let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx);
1104
1105 let commit_sha = commit_view_ref.commit.sha.clone();
1106
1107 let remote_info = commit_view_ref.remote.as_ref().map(|remote| {
1108 let provider = remote.host.name();
1109 let parsed_remote = ParsedGitRemote {
1110 owner: remote.owner.as_ref().into(),
1111 repo: remote.repo.as_ref().into(),
1112 };
1113 let params = BuildCommitPermalinkParams { sha: &commit_sha };
1114 let url = remote
1115 .host
1116 .build_commit_permalink(&parsed_remote, params)
1117 .to_string();
1118 (provider, url)
1119 });
1120
1121 let sha_for_graph = commit_sha.to_string();
1122
1123 h_flex()
1124 .gap_1()
1125 .when(additions > 0 || deletions > 0, |this| {
1126 this.child(
1127 h_flex()
1128 .gap_2()
1129 .child(DiffStat::new(
1130 "toolbar-diff-stat",
1131 additions as usize,
1132 deletions as usize,
1133 ))
1134 .child(Divider::vertical()),
1135 )
1136 })
1137 .child(
1138 IconButton::new("buffer-search", IconName::MagnifyingGlass)
1139 .icon_size(IconSize::Small)
1140 .tooltip(move |_, cx| {
1141 Tooltip::for_action(
1142 "Buffer Search",
1143 &zed_actions::buffer_search::Deploy::find(),
1144 cx,
1145 )
1146 })
1147 .on_click(|_, window, cx| {
1148 window.dispatch_action(
1149 Box::new(zed_actions::buffer_search::Deploy::find()),
1150 cx,
1151 );
1152 }),
1153 )
1154 .when(!is_stash, |this| {
1155 this.child(
1156 IconButton::new("show-in-git-graph", IconName::GitGraph)
1157 .icon_size(IconSize::Small)
1158 .tooltip(Tooltip::text("Show in Git Graph"))
1159 .on_click(move |_, window, cx| {
1160 window.dispatch_action(
1161 Box::new(crate::git_panel::OpenAtCommit {
1162 sha: sha_for_graph.clone(),
1163 }),
1164 cx,
1165 );
1166 }),
1167 )
1168 .children(remote_info.map(|(provider_name, url)| {
1169 let icon = match provider_name.as_str() {
1170 "GitHub" => IconName::Github,
1171 _ => IconName::Link,
1172 };
1173
1174 IconButton::new("view_on_provider", icon)
1175 .icon_size(IconSize::Small)
1176 .tooltip(Tooltip::text(format!("View on {}", provider_name)))
1177 .on_click(move |_, _, cx| cx.open_url(&url))
1178 }))
1179 })
1180 }
1181}
1182
1183impl ToolbarItemView for CommitViewToolbar {
1184 fn set_active_pane_item(
1185 &mut self,
1186 active_pane_item: Option<&dyn ItemHandle>,
1187 _: &mut Window,
1188 cx: &mut Context<Self>,
1189 ) -> ToolbarItemLocation {
1190 if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx)) {
1191 self.commit_view = Some(entity.downgrade());
1192 return ToolbarItemLocation::PrimaryRight;
1193 }
1194 self.commit_view = None;
1195 ToolbarItemLocation::Hidden
1196 }
1197
1198 fn pane_focus_update(
1199 &mut self,
1200 _pane_focused: bool,
1201 _window: &mut Window,
1202 _cx: &mut Context<Self>,
1203 ) {
1204 }
1205}
1206
1207fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool {
1208 repo.stash_entries
1209 .entries
1210 .get(stash_index)
1211 .map(|entry| entry.oid.to_string() == sha)
1212 .unwrap_or(false)
1213}