commit_view.rs

  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}