1use anyhow::{Context as _, Result};
2use buffer_diff::{BufferDiff, BufferDiffSnapshot};
3use editor::{Editor, EditorEvent, MultiBuffer, SelectionEffects};
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 .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(SelectionEffects::no_scroll(), 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 .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, &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 to_proto(&self, _cx: &App) -> language::proto::File {
248 unimplemented!()
249 }
250
251 fn is_private(&self) -> bool {
252 false
253 }
254}
255
256impl language::File for CommitMetadataFile {
257 fn as_local(&self) -> Option<&dyn language::LocalFile> {
258 None
259 }
260
261 fn disk_state(&self) -> DiskState {
262 DiskState::New
263 }
264
265 fn path(&self) -> &Arc<Path> {
266 &self.title
267 }
268
269 fn full_path(&self, _: &App) -> PathBuf {
270 self.title.as_ref().into()
271 }
272
273 fn file_name<'a>(&'a self, _: &'a App) -> &'a OsStr {
274 self.title.file_name().unwrap()
275 }
276
277 fn worktree_id(&self, _: &App) -> WorktreeId {
278 self.worktree_id
279 }
280
281 fn to_proto(&self, _: &App) -> language::proto::File {
282 unimplemented!()
283 }
284
285 fn is_private(&self) -> bool {
286 false
287 }
288}
289
290async fn build_buffer(
291 mut text: String,
292 blob: Arc<dyn File>,
293 language_registry: &Arc<language::LanguageRegistry>,
294 cx: &mut AsyncApp,
295) -> Result<Entity<Buffer>> {
296 let line_ending = LineEnding::detect(&text);
297 LineEnding::normalize(&mut text);
298 let text = Rope::from(text);
299 let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx))?;
300 let language = if let Some(language) = language {
301 language_registry
302 .load_language(&language)
303 .await
304 .ok()
305 .and_then(|e| e.log_err())
306 } else {
307 None
308 };
309 let buffer = cx.new(|cx| {
310 let buffer = TextBuffer::new_normalized(
311 0,
312 cx.entity_id().as_non_zero_u64().into(),
313 line_ending,
314 text,
315 );
316 let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite);
317 buffer.set_language(language, cx);
318 buffer
319 })?;
320 Ok(buffer)
321}
322
323async fn build_buffer_diff(
324 mut old_text: Option<String>,
325 buffer: &Entity<Buffer>,
326 language_registry: &Arc<LanguageRegistry>,
327 cx: &mut AsyncApp,
328) -> Result<Entity<BufferDiff>> {
329 if let Some(old_text) = &mut old_text {
330 LineEnding::normalize(old_text);
331 }
332
333 let buffer = cx.update(|cx| buffer.read(cx).snapshot())?;
334
335 let base_buffer = cx
336 .update(|cx| {
337 Buffer::build_snapshot(
338 old_text.as_deref().unwrap_or("").into(),
339 buffer.language().cloned(),
340 Some(language_registry.clone()),
341 cx,
342 )
343 })?
344 .await;
345
346 let diff_snapshot = cx
347 .update(|cx| {
348 BufferDiffSnapshot::new_with_base_buffer(
349 buffer.text.clone(),
350 old_text.map(Arc::new),
351 base_buffer,
352 cx,
353 )
354 })?
355 .await;
356
357 cx.new(|cx| {
358 let mut diff = BufferDiff::new(&buffer.text, cx);
359 diff.set_snapshot(diff_snapshot, &buffer.text, cx);
360 diff
361 })
362}
363
364fn format_commit(commit: &CommitDetails) -> String {
365 let mut result = String::new();
366 writeln!(&mut result, "commit {}", commit.sha).unwrap();
367 writeln!(
368 &mut result,
369 "Author: {} <{}>",
370 commit.author_name, commit.author_email
371 )
372 .unwrap();
373 writeln!(
374 &mut result,
375 "Date: {}",
376 time_format::format_local_timestamp(
377 time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp).unwrap(),
378 time::OffsetDateTime::now_utc(),
379 time_format::TimestampFormat::MediumAbsolute,
380 ),
381 )
382 .unwrap();
383 result.push('\n');
384 for line in commit.message.split('\n') {
385 if line.is_empty() {
386 result.push('\n');
387 } else {
388 writeln!(&mut result, " {}", line).unwrap();
389 }
390 }
391 if result.ends_with("\n\n") {
392 result.pop();
393 }
394 result
395}
396
397impl EventEmitter<EditorEvent> for CommitView {}
398
399impl Focusable for CommitView {
400 fn focus_handle(&self, cx: &App) -> FocusHandle {
401 self.editor.focus_handle(cx)
402 }
403}
404
405impl Item for CommitView {
406 type Event = EditorEvent;
407
408 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
409 Some(Icon::new(IconName::GitBranch).color(Color::Muted))
410 }
411
412 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
413 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
414 .color(if params.selected {
415 Color::Default
416 } else {
417 Color::Muted
418 })
419 .into_any_element()
420 }
421
422 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
423 let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
424 let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
425 format!("{short_sha} - {subject}").into()
426 }
427
428 fn tab_tooltip_text(&self, _: &App) -> Option<ui::SharedString> {
429 let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
430 let subject = self.commit.message.split('\n').next().unwrap();
431 Some(format!("{short_sha} - {subject}").into())
432 }
433
434 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
435 Editor::to_item_events(event, f)
436 }
437
438 fn telemetry_event_text(&self) -> Option<&'static str> {
439 Some("Commit View Opened")
440 }
441
442 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
443 self.editor
444 .update(cx, |editor, cx| editor.deactivated(window, cx));
445 }
446
447 fn is_singleton(&self, _: &App) -> bool {
448 false
449 }
450
451 fn act_as_type<'a>(
452 &'a self,
453 type_id: TypeId,
454 self_handle: &'a Entity<Self>,
455 _: &'a App,
456 ) -> Option<AnyView> {
457 if type_id == TypeId::of::<Self>() {
458 Some(self_handle.to_any())
459 } else if type_id == TypeId::of::<Editor>() {
460 Some(self.editor.to_any())
461 } else {
462 None
463 }
464 }
465
466 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
467 Some(Box::new(self.editor.clone()))
468 }
469
470 fn for_each_project_item(
471 &self,
472 cx: &App,
473 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
474 ) {
475 self.editor.for_each_project_item(cx, f)
476 }
477
478 fn set_nav_history(
479 &mut self,
480 nav_history: ItemNavHistory,
481 _: &mut Window,
482 cx: &mut Context<Self>,
483 ) {
484 self.editor.update(cx, |editor, _| {
485 editor.set_nav_history(Some(nav_history));
486 });
487 }
488
489 fn navigate(
490 &mut self,
491 data: Box<dyn Any>,
492 window: &mut Window,
493 cx: &mut Context<Self>,
494 ) -> bool {
495 self.editor
496 .update(cx, |editor, cx| editor.navigate(data, window, cx))
497 }
498
499 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
500 ToolbarItemLocation::PrimaryLeft
501 }
502
503 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
504 self.editor.breadcrumbs(theme, cx)
505 }
506
507 fn added_to_workspace(
508 &mut self,
509 workspace: &mut Workspace,
510 window: &mut Window,
511 cx: &mut Context<Self>,
512 ) {
513 self.editor.update(cx, |editor, cx| {
514 editor.added_to_workspace(workspace, window, cx)
515 });
516 }
517}
518
519impl Render for CommitView {
520 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
521 self.editor.clone()
522 }
523}