1use anyhow::{Result, anyhow};
2use buffer_diff::{BufferDiff, BufferDiffSnapshot};
3use editor::{Editor, EditorEvent, MultiBuffer};
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 _};
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 .map_or(false, |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 return;
96 } else {
97 pane.add_item(Box::new(commit_view), true, true, None, window, cx);
98 }
99 })
100 })
101 .log_err()
102 })
103 .detach();
104 }
105
106 fn new(
107 commit: CommitDetails,
108 commit_diff: CommitDiff,
109 repository: Entity<Repository>,
110 project: Entity<Project>,
111 window: &mut Window,
112 cx: &mut Context<Self>,
113 ) -> Self {
114 let language_registry = project.read(cx).languages().clone();
115 let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly));
116 let editor = cx.new(|cx| {
117 let mut editor =
118 Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
119 editor.disable_inline_diagnostics();
120 editor.set_expand_all_diff_hunks(cx);
121 editor
122 });
123
124 let first_worktree_id = project
125 .read(cx)
126 .worktrees(cx)
127 .next()
128 .map(|worktree| worktree.read(cx).id());
129
130 let mut metadata_buffer_id = None;
131 if let Some(worktree_id) = first_worktree_id {
132 let file = Arc::new(CommitMetadataFile {
133 title: PathBuf::from(format!("commit {}", commit.sha)).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(None, window, cx, |selections| {
158 selections.select_ranges(vec![0..0]);
159 });
160 });
161 }
162
163 cx.spawn(async move |this, mut 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 .ok_or_else(|| anyhow!("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, &mut cx).await?;
183 let buffer_diff =
184 build_buffer_diff(old_text, &buffer, &language_registry, &mut 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 editor::DEFAULT_MULTIBUFFER_CONTEXT,
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(&self) -> &Arc<Path> {
232 &self.path.0
233 }
234
235 fn full_path(&self, _: &App) -> PathBuf {
236 self.path.to_path_buf()
237 }
238
239 fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr {
240 self.path.file_name().unwrap()
241 }
242
243 fn worktree_id(&self, _: &App) -> WorktreeId {
244 self.worktree_id
245 }
246
247 fn as_any(&self) -> &dyn Any {
248 self
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(&self) -> &Arc<Path> {
270 &self.title
271 }
272
273 fn full_path(&self, _: &App) -> PathBuf {
274 self.title.as_ref().into()
275 }
276
277 fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr {
278 self.title.file_name().unwrap()
279 }
280
281 fn worktree_id(&self, _: &App) -> WorktreeId {
282 self.worktree_id
283 }
284
285 fn as_any(&self) -> &dyn Any {
286 self
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, None, 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, _: &App) -> AnyElement {
421 let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
422 let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
423 Label::new(format!("{short_sha} - {subject}",))
424 .color(if params.selected {
425 Color::Default
426 } else {
427 Color::Muted
428 })
429 .into_any_element()
430 }
431
432 fn tab_tooltip_text(&self, _: &App) -> Option<ui::SharedString> {
433 let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
434 let subject = self.commit.message.split('\n').next().unwrap();
435 Some(format!("{short_sha} - {subject}").into())
436 }
437
438 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
439 Editor::to_item_events(event, f)
440 }
441
442 fn telemetry_event_text(&self) -> Option<&'static str> {
443 Some("Commit View Opened")
444 }
445
446 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
447 self.editor
448 .update(cx, |editor, cx| editor.deactivated(window, cx));
449 }
450
451 fn is_singleton(&self, _: &App) -> bool {
452 false
453 }
454
455 fn act_as_type<'a>(
456 &'a self,
457 type_id: TypeId,
458 self_handle: &'a Entity<Self>,
459 _: &'a App,
460 ) -> Option<AnyView> {
461 if type_id == TypeId::of::<Self>() {
462 Some(self_handle.to_any())
463 } else if type_id == TypeId::of::<Editor>() {
464 Some(self.editor.to_any())
465 } else {
466 None
467 }
468 }
469
470 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
471 Some(Box::new(self.editor.clone()))
472 }
473
474 fn for_each_project_item(
475 &self,
476 cx: &App,
477 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
478 ) {
479 self.editor.for_each_project_item(cx, f)
480 }
481
482 fn set_nav_history(
483 &mut self,
484 nav_history: ItemNavHistory,
485 _: &mut Window,
486 cx: &mut Context<Self>,
487 ) {
488 self.editor.update(cx, |editor, _| {
489 editor.set_nav_history(Some(nav_history));
490 });
491 }
492
493 fn navigate(
494 &mut self,
495 data: Box<dyn Any>,
496 window: &mut Window,
497 cx: &mut Context<Self>,
498 ) -> bool {
499 self.editor
500 .update(cx, |editor, cx| editor.navigate(data, window, cx))
501 }
502
503 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
504 ToolbarItemLocation::PrimaryLeft
505 }
506
507 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
508 self.editor.breadcrumbs(theme, cx)
509 }
510
511 fn added_to_workspace(
512 &mut self,
513 workspace: &mut Workspace,
514 window: &mut Window,
515 cx: &mut Context<Self>,
516 ) {
517 self.editor.update(cx, |editor, cx| {
518 editor.added_to_workspace(workspace, window, cx)
519 });
520 }
521}
522
523impl Render for CommitView {
524 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
525 self.editor.clone()
526 }
527}