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