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, ReplicaId, 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_SORT_PREFIX: u64 = 0;
 47const FILE_NAMESPACE_SORT_PREFIX: 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                    ReplicaId::LOCAL,
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::with_sort_prefix(COMMIT_METADATA_SORT_PREFIX, 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::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, 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            ReplicaId::LOCAL,
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 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    fn clone_on_split(
523        &self,
524        _workspace_id: Option<workspace::WorkspaceId>,
525        window: &mut Window,
526        cx: &mut Context<Self>,
527    ) -> Option<Entity<Self>>
528    where
529        Self: Sized,
530    {
531        Some(cx.new(|cx| {
532            let editor = cx.new(|cx| {
533                self.editor
534                    .update(cx, |editor, cx| editor.clone(window, cx))
535            });
536            let multibuffer = editor.read(cx).buffer().clone();
537            Self {
538                editor,
539                multibuffer,
540                commit: self.commit.clone(),
541            }
542        }))
543    }
544}
545
546impl Render for CommitView {
547    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
548        self.editor.clone()
549    }
550}