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