1use anyhow::{Context as _, Result};
2use buffer_diff::{BufferDiff, BufferDiffSnapshot};
3use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects, multibuffer_context_lines};
4use git::repository::{CommitDetails, CommitDiff, CommitSummary, RepoPath};
5use gpui::{
6 AnyElement, AnyView, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter,
7 FocusHandle, Focusable, IntoElement, Render, WeakEntity, Window,
8};
9use language::{
10 Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
11 Point, Rope, TextBuffer,
12};
13use multi_buffer::PathKey;
14use project::{Project, WorktreeId, git_store::Repository};
15use std::{
16 any::{Any, TypeId},
17 fmt::Write as _,
18 path::PathBuf,
19 sync::Arc,
20};
21use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
22use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
23use workspace::{
24 Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
25 item::{BreadcrumbText, ItemEvent, TabContentParams},
26 searchable::SearchableItemHandle,
27};
28
29pub struct CommitView {
30 commit: CommitDetails,
31 editor: Entity<Editor>,
32 multibuffer: Entity<MultiBuffer>,
33}
34
35struct GitBlob {
36 path: RepoPath,
37 worktree_id: WorktreeId,
38 is_deleted: bool,
39}
40
41struct CommitMetadataFile {
42 title: Arc<RelPath>,
43 worktree_id: WorktreeId,
44}
45
46const COMMIT_METADATA_NAMESPACE: u64 = 0;
47const FILE_NAMESPACE: u64 = 1;
48
49impl CommitView {
50 pub fn open(
51 commit: CommitSummary,
52 repo: WeakEntity<Repository>,
53 workspace: WeakEntity<Workspace>,
54 window: &mut Window,
55 cx: &mut App,
56 ) {
57 let commit_diff = repo
58 .update(cx, |repo, _| repo.load_commit_diff(commit.sha.to_string()))
59 .ok();
60 let commit_details = repo
61 .update(cx, |repo, _| repo.show(commit.sha.to_string()))
62 .ok();
63
64 window
65 .spawn(cx, async move |cx| {
66 let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?);
67 let commit_diff = commit_diff.log_err()?.log_err()?;
68 let commit_details = commit_details.log_err()?.log_err()?;
69 let repo = repo.upgrade()?;
70
71 workspace
72 .update_in(cx, |workspace, window, cx| {
73 let project = workspace.project();
74 let commit_view = cx.new(|cx| {
75 CommitView::new(
76 commit_details,
77 commit_diff,
78 repo,
79 project.clone(),
80 window,
81 cx,
82 )
83 });
84
85 let pane = workspace.active_pane();
86 pane.update(cx, |pane, cx| {
87 let ix = pane.items().position(|item| {
88 let commit_view = item.downcast::<CommitView>();
89 commit_view
90 .is_some_and(|view| view.read(cx).commit.sha == commit.sha)
91 });
92 if let Some(ix) = ix {
93 pane.activate_item(ix, true, true, window, cx);
94 } else {
95 pane.add_item(Box::new(commit_view), true, true, None, window, cx);
96 }
97 })
98 })
99 .log_err()
100 })
101 .detach();
102 }
103
104 fn new(
105 commit: CommitDetails,
106 commit_diff: CommitDiff,
107 repository: Entity<Repository>,
108 project: Entity<Project>,
109 window: &mut Window,
110 cx: &mut Context<Self>,
111 ) -> Self {
112 let language_registry = project.read(cx).languages().clone();
113 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly));
114 let editor = cx.new(|cx| {
115 let mut editor =
116 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
117 editor.disable_inline_diagnostics();
118 editor.set_expand_all_diff_hunks(cx);
119 editor
120 });
121
122 let first_worktree_id = project
123 .read(cx)
124 .worktrees(cx)
125 .next()
126 .map(|worktree| worktree.read(cx).id());
127
128 let mut metadata_buffer_id = None;
129 if let Some(worktree_id) = first_worktree_id {
130 let file = Arc::new(CommitMetadataFile {
131 title: RelPath::unix(&format!("commit {}", commit.sha))
132 .unwrap()
133 .into(),
134 worktree_id,
135 });
136 let buffer = cx.new(|cx| {
137 let buffer = TextBuffer::new_normalized(
138 0,
139 cx.entity_id().as_non_zero_u64().into(),
140 LineEnding::default(),
141 format_commit(&commit).into(),
142 );
143 metadata_buffer_id = Some(buffer.remote_id());
144 Buffer::build(buffer, Some(file.clone()), Capability::ReadWrite)
145 });
146 multibuffer.update(cx, |multibuffer, cx| {
147 multibuffer.set_excerpts_for_path(
148 PathKey::namespaced(COMMIT_METADATA_NAMESPACE, file.title.clone()),
149 buffer.clone(),
150 vec![Point::zero()..buffer.read(cx).max_point()],
151 0,
152 cx,
153 );
154 });
155 editor.update(cx, |editor, cx| {
156 editor.disable_header_for_buffer(metadata_buffer_id.unwrap(), cx);
157 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
158 selections.select_ranges(vec![0..0]);
159 });
160 });
161 }
162
163 cx.spawn(async move |this, cx| {
164 for file in commit_diff.files {
165 let is_deleted = file.new_text.is_none();
166 let new_text = file.new_text.unwrap_or_default();
167 let old_text = file.old_text;
168 let worktree_id = repository
169 .update(cx, |repository, cx| {
170 repository
171 .repo_path_to_project_path(&file.path, cx)
172 .map(|path| path.worktree_id)
173 .or(first_worktree_id)
174 })?
175 .context("project has no worktrees")?;
176 let file = Arc::new(GitBlob {
177 path: file.path.clone(),
178 is_deleted,
179 worktree_id,
180 }) as Arc<dyn language::File>;
181
182 let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
183 let buffer_diff =
184 build_buffer_diff(old_text, &buffer, &language_registry, cx).await?;
185
186 this.update(cx, |this, cx| {
187 this.multibuffer.update(cx, |multibuffer, cx| {
188 let snapshot = buffer.read(cx).snapshot();
189 let diff = buffer_diff.read(cx);
190 let diff_hunk_ranges = diff
191 .hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot, cx)
192 .map(|diff_hunk| diff_hunk.buffer_range.to_point(&snapshot))
193 .collect::<Vec<_>>();
194 let path = snapshot.file().unwrap().path().clone();
195 let _is_newly_added = multibuffer.set_excerpts_for_path(
196 PathKey::namespaced(FILE_NAMESPACE, path),
197 buffer,
198 diff_hunk_ranges,
199 multibuffer_context_lines(cx),
200 cx,
201 );
202 multibuffer.add_diff(buffer_diff, cx);
203 });
204 })?;
205 }
206 anyhow::Ok(())
207 })
208 .detach();
209
210 Self {
211 commit,
212 editor,
213 multibuffer,
214 }
215 }
216}
217
218impl language::File for GitBlob {
219 fn as_local(&self) -> Option<&dyn language::LocalFile> {
220 None
221 }
222
223 fn disk_state(&self) -> DiskState {
224 if self.is_deleted {
225 DiskState::Deleted
226 } else {
227 DiskState::New
228 }
229 }
230
231 fn path_style(&self, _: &App) -> PathStyle {
232 PathStyle::Posix
233 }
234
235 fn path(&self) -> &Arc<RelPath> {
236 &self.path.0
237 }
238
239 fn full_path(&self, _: &App) -> PathBuf {
240 self.path.as_std_path().to_path_buf()
241 }
242
243 fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
244 self.path.file_name().unwrap()
245 }
246
247 fn worktree_id(&self, _: &App) -> WorktreeId {
248 self.worktree_id
249 }
250
251 fn to_proto(&self, _cx: &App) -> language::proto::File {
252 unimplemented!()
253 }
254
255 fn is_private(&self) -> bool {
256 false
257 }
258}
259
260impl language::File for CommitMetadataFile {
261 fn as_local(&self) -> Option<&dyn language::LocalFile> {
262 None
263 }
264
265 fn disk_state(&self) -> DiskState {
266 DiskState::New
267 }
268
269 fn path_style(&self, _: &App) -> PathStyle {
270 PathStyle::Posix
271 }
272
273 fn path(&self) -> &Arc<RelPath> {
274 &self.title
275 }
276
277 fn full_path(&self, _: &App) -> PathBuf {
278 PathBuf::from(self.title.as_unix_str().to_owned())
279 }
280
281 fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
282 self.title.file_name().unwrap()
283 }
284
285 fn worktree_id(&self, _: &App) -> WorktreeId {
286 self.worktree_id
287 }
288
289 fn to_proto(&self, _: &App) -> language::proto::File {
290 unimplemented!()
291 }
292
293 fn is_private(&self) -> bool {
294 false
295 }
296}
297
298async fn build_buffer(
299 mut text: String,
300 blob: Arc<dyn File>,
301 language_registry: &Arc<language::LanguageRegistry>,
302 cx: &mut AsyncApp,
303) -> Result<Entity<Buffer>> {
304 let line_ending = LineEnding::detect(&text);
305 LineEnding::normalize(&mut text);
306 let text = Rope::from(text);
307 let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx))?;
308 let language = if let Some(language) = language {
309 language_registry
310 .load_language(&language)
311 .await
312 .ok()
313 .and_then(|e| e.log_err())
314 } else {
315 None
316 };
317 let buffer = cx.new(|cx| {
318 let buffer = TextBuffer::new_normalized(
319 0,
320 cx.entity_id().as_non_zero_u64().into(),
321 line_ending,
322 text,
323 );
324 let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite);
325 buffer.set_language(language, cx);
326 buffer
327 })?;
328 Ok(buffer)
329}
330
331async fn build_buffer_diff(
332 mut old_text: Option<String>,
333 buffer: &Entity<Buffer>,
334 language_registry: &Arc<LanguageRegistry>,
335 cx: &mut AsyncApp,
336) -> Result<Entity<BufferDiff>> {
337 if let Some(old_text) = &mut old_text {
338 LineEnding::normalize(old_text);
339 }
340
341 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
342
343 let base_buffer = cx
344 .update(|cx| {
345 Buffer::build_snapshot(
346 old_text.as_deref().unwrap_or("").into(),
347 buffer.language().cloned(),
348 Some(language_registry.clone()),
349 cx,
350 )
351 })?
352 .await;
353
354 let diff_snapshot = cx
355 .update(|cx| {
356 BufferDiffSnapshot::new_with_base_buffer(
357 buffer.text.clone(),
358 old_text.map(Arc::new),
359 base_buffer,
360 cx,
361 )
362 })?
363 .await;
364
365 cx.new(|cx| {
366 let mut diff = BufferDiff::new(&buffer.text, cx);
367 diff.set_snapshot(diff_snapshot, &buffer.text, cx);
368 diff
369 })
370}
371
372fn format_commit(commit: &CommitDetails) -> String {
373 let mut result = String::new();
374 writeln!(&mut result, "commit {}", commit.sha).unwrap();
375 writeln!(
376 &mut result,
377 "Author: {} <{}>",
378 commit.author_name, commit.author_email
379 )
380 .unwrap();
381 writeln!(
382 &mut result,
383 "Date: {}",
384 time_format::format_local_timestamp(
385 time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(),
386 time::OffsetDateTime::now_utc(),
387 time_format::TimestampFormat::MediumAbsolute,
388 ),
389 )
390 .unwrap();
391 result.push('\n');
392 for line in commit.message.split('\n') {
393 if line.is_empty() {
394 result.push('\n');
395 } else {
396 writeln!(&mut result, " {}", line).unwrap();
397 }
398 }
399 if result.ends_with("\n\n") {
400 result.pop();
401 }
402 result
403}
404
405impl EventEmitter<EditorEvent> for CommitView {}
406
407impl Focusable for CommitView {
408 fn focus_handle(&self, cx: &App) -> FocusHandle {
409 self.editor.focus_handle(cx)
410 }
411}
412
413impl Item for CommitView {
414 type Event = EditorEvent;
415
416 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
417 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
418 }
419
420 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
421 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
422 .color(if params.selected {
423 Color::Default
424 } else {
425 Color::Muted
426 })
427 .into_any_element()
428 }
429
430 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
431 let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
432 let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
433 format!("{short_sha} - {subject}").into()
434 }
435
436 fn tab_tooltip_text(&self, _: &App) -> Option<ui::SharedString> {
437 let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
438 let subject = self.commit.message.split('\n').next().unwrap();
439 Some(format!("{short_sha} - {subject}").into())
440 }
441
442 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
443 Editor::to_item_events(event, f)
444 }
445
446 fn telemetry_event_text(&self) -> Option<&'static str> {
447 Some("Commit View Opened")
448 }
449
450 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
451 self.editor
452 .update(cx, |editor, cx| editor.deactivated(window, cx));
453 }
454
455 fn is_singleton(&self, _: &App) -> bool {
456 false
457 }
458
459 fn act_as_type<'a>(
460 &'a self,
461 type_id: TypeId,
462 self_handle: &'a Entity<Self>,
463 _: &'a App,
464 ) -> Option<AnyView> {
465 if type_id == TypeId::of::<Self>() {
466 Some(self_handle.to_any())
467 } else if type_id == TypeId::of::<Editor>() {
468 Some(self.editor.to_any())
469 } else {
470 None
471 }
472 }
473
474 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
475 Some(Box::new(self.editor.clone()))
476 }
477
478 fn for_each_project_item(
479 &self,
480 cx: &App,
481 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
482 ) {
483 self.editor.for_each_project_item(cx, f)
484 }
485
486 fn set_nav_history(
487 &mut self,
488 nav_history: ItemNavHistory,
489 _: &mut Window,
490 cx: &mut Context<Self>,
491 ) {
492 self.editor.update(cx, |editor, _| {
493 editor.set_nav_history(Some(nav_history));
494 });
495 }
496
497 fn navigate(
498 &mut self,
499 data: Box<dyn Any>,
500 window: &mut Window,
501 cx: &mut Context<Self>,
502 ) -> bool {
503 self.editor
504 .update(cx, |editor, cx| editor.navigate(data, window, cx))
505 }
506
507 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
508 ToolbarItemLocation::PrimaryLeft
509 }
510
511 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
512 self.editor.breadcrumbs(theme, cx)
513 }
514
515 fn added_to_workspace(
516 &mut self,
517 workspace: &mut Workspace,
518 window: &mut Window,
519 cx: &mut Context<Self>,
520 ) {
521 self.editor.update(cx, |editor, cx| {
522 editor.added_to_workspace(workspace, window, cx)
523 });
524 }
525
526 fn clone_on_split(
527 &self,
528 _workspace_id: Option<workspace::WorkspaceId>,
529 window: &mut Window,
530 cx: &mut Context<Self>,
531 ) -> Option<Entity<Self>>
532 where
533 Self: Sized,
534 {
535 Some(cx.new(|cx| {
536 let editor = cx.new(|cx| {
537 self.editor
538 .update(cx, |editor, cx| editor.clone(window, cx))
539 });
540 let multibuffer = editor.read(cx).buffer().clone();
541 Self {
542 editor,
543 multibuffer,
544 commit: self.commit.clone(),
545 }
546 }))
547 }
548}
549
550impl Render for CommitView {
551 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
552 self.editor.clone()
553 }
554}