commit_view.rs

   1use anyhow::{Context as _, Result};
   2use buffer_diff::BufferDiff;
   3use collections::HashMap;
   4use editor::display_map::{BlockPlacement, BlockProperties, BlockStyle};
   5use editor::{Addon, Editor, EditorEvent, ExcerptRange, MultiBuffer, multibuffer_context_lines};
   6use feature_flags::{FeatureFlagAppExt as _, GitGraphFeatureFlag};
   7use git::repository::{CommitDetails, CommitDiff, RepoPath, is_binary_content};
   8use git::status::{FileStatus, StatusCode, TrackedStatus};
   9use git::{
  10    BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote,
  11    parse_git_remote_url,
  12};
  13use gpui::{
  14    AnyElement, App, AppContext as _, AsyncApp, AsyncWindowContext, ClipboardItem, Context, Entity,
  15    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
  16    PromptLevel, Render, Styled, Task, WeakEntity, Window, actions,
  17};
  18use language::{
  19    Anchor, Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, OffsetRangeExt as _,
  20    Point, ReplicaId, Rope, TextBuffer,
  21};
  22use multi_buffer::PathKey;
  23use project::{Project, WorktreeId, git_store::Repository};
  24use std::{
  25    any::{Any, TypeId},
  26    collections::HashSet,
  27    path::PathBuf,
  28    sync::Arc,
  29};
  30use theme::ActiveTheme;
  31use ui::{DiffStat, Divider, Tooltip, prelude::*};
  32use util::{ResultExt, paths::PathStyle, rel_path::RelPath, truncate_and_trailoff};
  33use workspace::item::TabTooltipContent;
  34use workspace::{
  35    Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
  36    Workspace,
  37    item::{ItemEvent, TabContentParams},
  38    notifications::NotifyTaskExt,
  39    pane::SaveIntent,
  40    searchable::SearchableItemHandle,
  41};
  42
  43use crate::commit_tooltip::CommitAvatar;
  44use crate::git_panel::GitPanel;
  45
  46actions!(git, [ApplyCurrentStash, PopCurrentStash, DropCurrentStash,]);
  47
  48pub fn init(cx: &mut App) {
  49    cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
  50        workspace.register_action(|workspace, _: &ApplyCurrentStash, window, cx| {
  51            CommitView::apply_stash(workspace, window, cx);
  52        });
  53        workspace.register_action(|workspace, _: &DropCurrentStash, window, cx| {
  54            CommitView::remove_stash(workspace, window, cx);
  55        });
  56        workspace.register_action(|workspace, _: &PopCurrentStash, window, cx| {
  57            CommitView::pop_stash(workspace, window, cx);
  58        });
  59    })
  60    .detach();
  61}
  62
  63pub struct CommitView {
  64    commit: CommitDetails,
  65    editor: Entity<Editor>,
  66    stash: Option<usize>,
  67    multibuffer: Entity<MultiBuffer>,
  68    repository: Entity<Repository>,
  69    remote: Option<GitRemote>,
  70}
  71
  72struct GitBlob {
  73    path: RepoPath,
  74    worktree_id: WorktreeId,
  75    is_deleted: bool,
  76    is_binary: bool,
  77    display_name: String,
  78}
  79
  80struct CommitDiffAddon {
  81    file_statuses: HashMap<language::BufferId, FileStatus>,
  82}
  83
  84impl Addon for CommitDiffAddon {
  85    fn to_any(&self) -> &dyn std::any::Any {
  86        self
  87    }
  88
  89    fn override_status_for_buffer_id(
  90        &self,
  91        buffer_id: language::BufferId,
  92        _cx: &App,
  93    ) -> Option<FileStatus> {
  94        self.file_statuses.get(&buffer_id).copied()
  95    }
  96}
  97
  98const COMMIT_MESSAGE_SORT_PREFIX: u64 = 0;
  99const FILE_NAMESPACE_SORT_PREFIX: u64 = 1;
 100
 101impl CommitView {
 102    pub fn open(
 103        commit_sha: String,
 104        repo: WeakEntity<Repository>,
 105        workspace: WeakEntity<Workspace>,
 106        stash: Option<usize>,
 107        file_filter: Option<RepoPath>,
 108        window: &mut Window,
 109        cx: &mut App,
 110    ) {
 111        let commit_diff = repo
 112            .update(cx, |repo, _| repo.load_commit_diff(commit_sha.clone()))
 113            .ok();
 114        let commit_details = repo
 115            .update(cx, |repo, _| repo.show(commit_sha.clone()))
 116            .ok();
 117
 118        window
 119            .spawn(cx, async move |cx| {
 120                let (commit_diff, commit_details) = futures::join!(commit_diff?, commit_details?);
 121                let mut commit_diff = commit_diff.log_err()?.log_err()?;
 122                let commit_details = commit_details.log_err()?.log_err()?;
 123
 124                // Filter to specific file if requested
 125                if let Some(ref filter_path) = file_filter {
 126                    commit_diff.files.retain(|f| &f.path == filter_path);
 127                }
 128
 129                let repo = repo.upgrade()?;
 130
 131                workspace
 132                    .update_in(cx, |workspace, window, cx| {
 133                        let project = workspace.project();
 134                        let commit_view = cx.new(|cx| {
 135                            CommitView::new(
 136                                commit_details,
 137                                commit_diff,
 138                                repo,
 139                                project.clone(),
 140                                stash,
 141                                window,
 142                                cx,
 143                            )
 144                        });
 145
 146                        let pane = workspace.active_pane();
 147                        pane.update(cx, |pane, cx| {
 148                            let ix = pane.items().position(|item| {
 149                                let commit_view = item.downcast::<CommitView>();
 150                                commit_view
 151                                    .is_some_and(|view| view.read(cx).commit.sha == commit_sha)
 152                            });
 153                            if let Some(ix) = ix {
 154                                pane.activate_item(ix, true, true, window, cx);
 155                            } else {
 156                                pane.add_item(Box::new(commit_view), true, true, None, window, cx);
 157                            }
 158                        })
 159                    })
 160                    .log_err()
 161            })
 162            .detach();
 163    }
 164
 165    fn new(
 166        commit: CommitDetails,
 167        commit_diff: CommitDiff,
 168        repository: Entity<Repository>,
 169        project: Entity<Project>,
 170        stash: Option<usize>,
 171        window: &mut Window,
 172        cx: &mut Context<Self>,
 173    ) -> Self {
 174        let language_registry = project.read(cx).languages().clone();
 175        let multibuffer = cx.new(|_| MultiBuffer::new(Capability::ReadOnly));
 176
 177        let message_buffer = cx.new(|cx| {
 178            let mut buffer = Buffer::local(commit.message.clone(), cx);
 179            buffer.set_capability(Capability::ReadOnly, cx);
 180            buffer
 181        });
 182
 183        multibuffer.update(cx, |multibuffer, cx| {
 184            let snapshot = message_buffer.read(cx).snapshot();
 185            let full_range = Point::zero()..snapshot.max_point();
 186            let range = ExcerptRange {
 187                context: full_range.clone(),
 188                primary: full_range,
 189            };
 190            multibuffer.set_excerpt_ranges_for_path(
 191                PathKey::with_sort_prefix(
 192                    COMMIT_MESSAGE_SORT_PREFIX,
 193                    RelPath::unix("commit message").unwrap().into(),
 194                ),
 195                message_buffer.clone(),
 196                &snapshot,
 197                vec![range],
 198                cx,
 199            )
 200        });
 201
 202        let editor = cx.new(|cx| {
 203            let mut editor =
 204                Editor::for_multibuffer(multibuffer.clone(), Some(project.clone()), window, cx);
 205
 206            editor.disable_inline_diagnostics();
 207            editor.set_show_breakpoints(false, cx);
 208            editor.set_show_diff_review_button(true, cx);
 209            editor.set_expand_all_diff_hunks(cx);
 210            editor.disable_header_for_buffer(message_buffer.read(cx).remote_id(), cx);
 211            editor.disable_indent_guides_for_buffer(message_buffer.read(cx).remote_id(), cx);
 212
 213            editor.insert_blocks(
 214                [BlockProperties {
 215                    placement: BlockPlacement::Above(editor::Anchor::min()),
 216                    height: Some(1),
 217                    style: BlockStyle::Sticky,
 218                    render: Arc::new(|_| gpui::Empty.into_any_element()),
 219                    priority: 0,
 220                }]
 221                .into_iter()
 222                .chain(
 223                    editor
 224                        .buffer()
 225                        .read(cx)
 226                        .buffer_anchor_to_anchor(
 227                            &message_buffer,
 228                            Anchor::max_for_buffer(&message_buffer.read(cx).remote_id()),
 229                            cx,
 230                        )
 231                        .map(|anchor| BlockProperties {
 232                            placement: BlockPlacement::Below(anchor),
 233                            height: Some(1),
 234                            style: BlockStyle::Sticky,
 235                            render: Arc::new(|_| gpui::Empty.into_any_element()),
 236                            priority: 0,
 237                        }),
 238                ),
 239                None,
 240                cx,
 241            );
 242
 243            editor
 244        });
 245
 246        let commit_sha = Arc::<str>::from(commit.sha.as_ref());
 247
 248        let first_worktree_id = project
 249            .read(cx)
 250            .worktrees(cx)
 251            .next()
 252            .map(|worktree| worktree.read(cx).id());
 253
 254        let repository_clone = repository.clone();
 255
 256        cx.spawn(async move |this, cx| {
 257            let mut binary_buffer_ids: HashSet<language::BufferId> = HashSet::default();
 258            let mut file_statuses: HashMap<language::BufferId, FileStatus> = HashMap::default();
 259
 260            for file in commit_diff.files {
 261                let is_created = file.old_text.is_none();
 262                let is_deleted = file.new_text.is_none();
 263                let raw_new_text = file.new_text.unwrap_or_default();
 264                let raw_old_text = file.old_text;
 265
 266                let is_binary = file.is_binary
 267                    || is_binary_content(raw_new_text.as_bytes())
 268                    || raw_old_text
 269                        .as_ref()
 270                        .is_some_and(|text| is_binary_content(text.as_bytes()));
 271
 272                let new_text = if is_binary {
 273                    "(binary file not shown)".to_string()
 274                } else {
 275                    raw_new_text
 276                };
 277                let old_text = if is_binary { None } else { raw_old_text };
 278                let worktree_id = repository_clone
 279                    .update(cx, |repository, cx| {
 280                        repository
 281                            .repo_path_to_project_path(&file.path, cx)
 282                            .map(|path| path.worktree_id)
 283                            .or(first_worktree_id)
 284                    })
 285                    .context("project has no worktrees")?;
 286                let short_sha = commit_sha.get(0..7).unwrap_or(&commit_sha);
 287                let file_name = file
 288                    .path
 289                    .file_name()
 290                    .map(|name| name.to_string())
 291                    .unwrap_or_else(|| file.path.display(PathStyle::local()).to_string());
 292                let display_name = format!("{short_sha} - {file_name}");
 293
 294                let file = Arc::new(GitBlob {
 295                    path: file.path.clone(),
 296                    is_deleted,
 297                    is_binary,
 298                    worktree_id,
 299                    display_name,
 300                }) as Arc<dyn language::File>;
 301
 302                let buffer = build_buffer(new_text, file, &language_registry, cx).await?;
 303                let buffer_id = cx.update(|cx| buffer.read(cx).remote_id());
 304
 305                let status_code = if is_created {
 306                    StatusCode::Added
 307                } else if is_deleted {
 308                    StatusCode::Deleted
 309                } else {
 310                    StatusCode::Modified
 311                };
 312                file_statuses.insert(
 313                    buffer_id,
 314                    FileStatus::Tracked(TrackedStatus {
 315                        index_status: status_code,
 316                        worktree_status: StatusCode::Unmodified,
 317                    }),
 318                );
 319
 320                if is_binary {
 321                    binary_buffer_ids.insert(buffer_id);
 322                }
 323
 324                let buffer_diff = if is_binary {
 325                    None
 326                } else {
 327                    Some(build_buffer_diff(old_text, &buffer, &language_registry, cx).await?)
 328                };
 329
 330                this.update(cx, |this, cx| {
 331                    this.multibuffer.update(cx, |multibuffer, cx| {
 332                        let snapshot = buffer.read(cx).snapshot();
 333                        let path = snapshot.file().unwrap().path().clone();
 334                        let excerpt_ranges = if is_binary {
 335                            vec![language::Point::zero()..snapshot.max_point()]
 336                        } else if let Some(buffer_diff) = &buffer_diff {
 337                            let diff_snapshot = buffer_diff.read(cx).snapshot(cx);
 338                            let mut hunks = diff_snapshot.hunks(&snapshot).peekable();
 339                            if hunks.peek().is_none() {
 340                                vec![language::Point::zero()..snapshot.max_point()]
 341                            } else {
 342                                hunks
 343                                    .map(|hunk| hunk.buffer_range.to_point(&snapshot))
 344                                    .collect::<Vec<_>>()
 345                            }
 346                        } else {
 347                            vec![language::Point::zero()..snapshot.max_point()]
 348                        };
 349
 350                        let _is_newly_added = multibuffer.set_excerpts_for_path(
 351                            PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
 352                            buffer,
 353                            excerpt_ranges,
 354                            multibuffer_context_lines(cx),
 355                            cx,
 356                        );
 357                        if let Some(buffer_diff) = buffer_diff {
 358                            multibuffer.add_diff(buffer_diff, cx);
 359                        }
 360                    });
 361                })?;
 362            }
 363
 364            this.update(cx, |this, cx| {
 365                this.editor.update(cx, |editor, _cx| {
 366                    editor.register_addon(CommitDiffAddon { file_statuses });
 367                });
 368                if !binary_buffer_ids.is_empty() {
 369                    this.editor.update(cx, |editor, cx| {
 370                        editor.fold_buffers(binary_buffer_ids, cx);
 371                    });
 372                }
 373            })?;
 374
 375            anyhow::Ok(())
 376        })
 377        .detach();
 378
 379        let snapshot = repository.read(cx).snapshot();
 380        let remote_url = snapshot
 381            .remote_upstream_url
 382            .as_ref()
 383            .or(snapshot.remote_origin_url.as_ref());
 384
 385        let remote = remote_url.and_then(|url| {
 386            let provider_registry = GitHostingProviderRegistry::default_global(cx);
 387            parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
 388                host,
 389                owner: parsed.owner.into(),
 390                repo: parsed.repo.into(),
 391            })
 392        });
 393
 394        Self {
 395            commit,
 396            editor,
 397            multibuffer,
 398            stash,
 399            repository,
 400            remote,
 401        }
 402    }
 403
 404    fn render_commit_avatar(
 405        &self,
 406        sha: &SharedString,
 407        size: impl Into<gpui::AbsoluteLength>,
 408        window: &mut Window,
 409        cx: &mut App,
 410    ) -> AnyElement {
 411        CommitAvatar::new(
 412            sha,
 413            Some(self.commit.author_email.clone()),
 414            self.remote.as_ref(),
 415        )
 416        .size(size)
 417        .render(window, cx)
 418    }
 419
 420    fn calculate_changed_lines(&self, cx: &App) -> (u32, u32) {
 421        let snapshot = self.multibuffer.read(cx).snapshot(cx);
 422        let mut total_additions = 0u32;
 423        let mut total_deletions = 0u32;
 424
 425        let mut seen_buffers = std::collections::HashSet::new();
 426        for (_, buffer, _) in snapshot.excerpts() {
 427            let buffer_id = buffer.remote_id();
 428            if !seen_buffers.insert(buffer_id) {
 429                continue;
 430            }
 431
 432            let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else {
 433                continue;
 434            };
 435
 436            let base_text = diff.base_text();
 437
 438            for hunk in
 439                diff.hunks_intersecting_range(Anchor::min_max_range_for_buffer(buffer_id), buffer)
 440            {
 441                let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row);
 442                total_additions += added_rows;
 443
 444                let base_start = base_text
 445                    .offset_to_point(hunk.diff_base_byte_range.start)
 446                    .row;
 447                let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row;
 448                let deleted_rows = base_end.saturating_sub(base_start);
 449
 450                total_deletions += deleted_rows;
 451            }
 452        }
 453
 454        (total_additions, total_deletions)
 455    }
 456
 457    fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 458        let commit = &self.commit;
 459        let author_name = commit.author_name.clone();
 460        let author_email = commit.author_email.clone();
 461        let commit_sha = commit.sha.clone();
 462        let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
 463            .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
 464        let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
 465        let date_string = time_format::format_localized_timestamp(
 466            commit_date,
 467            time::OffsetDateTime::now_utc(),
 468            local_offset,
 469            time_format::TimestampFormat::MediumAbsolute,
 470        );
 471
 472        let gutter_width = self.editor.update(cx, |editor, cx| {
 473            let snapshot = editor.snapshot(window, cx);
 474            let style = editor.style(cx);
 475            let font_id = window.text_system().resolve_font(&style.text.font());
 476            let font_size = style.text.font_size.to_pixels(window.rem_size());
 477            snapshot
 478                .gutter_dimensions(font_id, font_size, style, window, cx)
 479                .full_width()
 480        });
 481
 482        let clipboard_has_sha = cx
 483            .read_from_clipboard()
 484            .and_then(|entry| entry.text())
 485            .map_or(false, |clipboard_text| {
 486                clipboard_text.trim() == commit_sha.as_ref()
 487            });
 488
 489        let (copy_icon, copy_icon_color) = if clipboard_has_sha {
 490            (IconName::Check, Color::Success)
 491        } else {
 492            (IconName::Copy, Color::Muted)
 493        };
 494
 495        h_flex()
 496            .py_2()
 497            .pr_2p5()
 498            .w_full()
 499            .justify_between()
 500            .border_b_1()
 501            .border_color(cx.theme().colors().border_variant)
 502            .child(
 503                h_flex()
 504                    .child(h_flex().w(gutter_width).justify_center().child(
 505                        self.render_commit_avatar(&commit.sha, rems_from_px(40.), window, cx),
 506                    ))
 507                    .child(
 508                        v_flex().child(Label::new(author_name)).child(
 509                            h_flex()
 510                                .gap_1p5()
 511                                .child(
 512                                    Label::new(date_string)
 513                                        .color(Color::Muted)
 514                                        .size(LabelSize::Small),
 515                                )
 516                                .child(
 517                                    Label::new("")
 518                                        .size(LabelSize::Small)
 519                                        .color(Color::Muted)
 520                                        .alpha(0.5),
 521                                )
 522                                .child(
 523                                    Label::new(author_email)
 524                                        .color(Color::Muted)
 525                                        .size(LabelSize::Small),
 526                                ),
 527                        ),
 528                    ),
 529            )
 530            .when(self.stash.is_none(), |this| {
 531                this.child(
 532                    Button::new("sha", "Commit SHA")
 533                        .icon(copy_icon)
 534                        .icon_color(copy_icon_color)
 535                        .icon_position(IconPosition::Start)
 536                        .icon_size(IconSize::Small)
 537                        .tooltip({
 538                            let commit_sha = commit_sha.clone();
 539                            move |_, cx| {
 540                                Tooltip::with_meta("Copy Commit SHA", None, commit_sha.clone(), cx)
 541                            }
 542                        })
 543                        .on_click(move |_, _, cx| {
 544                            cx.stop_propagation();
 545                            cx.write_to_clipboard(ClipboardItem::new_string(
 546                                commit_sha.to_string(),
 547                            ));
 548                        }),
 549                )
 550            })
 551    }
 552
 553    fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
 554        Self::stash_action(
 555            workspace,
 556            "Apply",
 557            window,
 558            cx,
 559            async move |repository, sha, stash, commit_view, workspace, cx| {
 560                let result = repository.update(cx, |repo, cx| {
 561                    if !stash_matches_index(&sha, stash, repo) {
 562                        return Err(anyhow::anyhow!("Stash has changed, not applying"));
 563                    }
 564                    Ok(repo.stash_apply(Some(stash), cx))
 565                });
 566
 567                match result {
 568                    Ok(task) => task.await?,
 569                    Err(err) => {
 570                        Self::close_commit_view(commit_view, workspace, cx).await?;
 571                        return Err(err);
 572                    }
 573                };
 574                Self::close_commit_view(commit_view, workspace, cx).await?;
 575                anyhow::Ok(())
 576            },
 577        );
 578    }
 579
 580    fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
 581        Self::stash_action(
 582            workspace,
 583            "Pop",
 584            window,
 585            cx,
 586            async move |repository, sha, stash, commit_view, workspace, cx| {
 587                let result = repository.update(cx, |repo, cx| {
 588                    if !stash_matches_index(&sha, stash, repo) {
 589                        return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
 590                    }
 591                    Ok(repo.stash_pop(Some(stash), cx))
 592                });
 593
 594                match result {
 595                    Ok(task) => task.await?,
 596                    Err(err) => {
 597                        Self::close_commit_view(commit_view, workspace, cx).await?;
 598                        return Err(err);
 599                    }
 600                };
 601                Self::close_commit_view(commit_view, workspace, cx).await?;
 602                anyhow::Ok(())
 603            },
 604        );
 605    }
 606
 607    fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
 608        Self::stash_action(
 609            workspace,
 610            "Drop",
 611            window,
 612            cx,
 613            async move |repository, sha, stash, commit_view, workspace, cx| {
 614                let result = repository.update(cx, |repo, cx| {
 615                    if !stash_matches_index(&sha, stash, repo) {
 616                        return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
 617                    }
 618                    Ok(repo.stash_drop(Some(stash), cx))
 619                });
 620
 621                match result {
 622                    Ok(task) => task.await??,
 623                    Err(err) => {
 624                        Self::close_commit_view(commit_view, workspace, cx).await?;
 625                        return Err(err);
 626                    }
 627                };
 628                Self::close_commit_view(commit_view, workspace, cx).await?;
 629                anyhow::Ok(())
 630            },
 631        );
 632    }
 633
 634    fn stash_action<AsyncFn>(
 635        workspace: &mut Workspace,
 636        str_action: &str,
 637        window: &mut Window,
 638        cx: &mut App,
 639        callback: AsyncFn,
 640    ) where
 641        AsyncFn: AsyncFnOnce(
 642                Entity<Repository>,
 643                &SharedString,
 644                usize,
 645                Entity<CommitView>,
 646                WeakEntity<Workspace>,
 647                &mut AsyncWindowContext,
 648            ) -> anyhow::Result<()>
 649            + 'static,
 650    {
 651        let Some(commit_view) = workspace.active_item_as::<CommitView>(cx) else {
 652            return;
 653        };
 654        let Some(stash) = commit_view.read(cx).stash else {
 655            return;
 656        };
 657        let sha = commit_view.read(cx).commit.sha.clone();
 658        let answer = window.prompt(
 659            PromptLevel::Info,
 660            &format!("{} stash@{{{}}}?", str_action, stash),
 661            None,
 662            &[str_action, "Cancel"],
 663            cx,
 664        );
 665
 666        let workspace_weak = workspace.weak_handle();
 667        let commit_view_entity = commit_view;
 668
 669        window
 670            .spawn(cx, async move |cx| {
 671                if answer.await != Ok(0) {
 672                    return anyhow::Ok(());
 673                }
 674
 675                let Some(workspace) = workspace_weak.upgrade() else {
 676                    return Ok(());
 677                };
 678
 679                let repo = workspace.update(cx, |workspace, cx| {
 680                    workspace
 681                        .panel::<GitPanel>(cx)
 682                        .and_then(|p| p.read(cx).active_repository.clone())
 683                });
 684
 685                let Some(repo) = repo else {
 686                    return Ok(());
 687                };
 688
 689                callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
 690                anyhow::Ok(())
 691            })
 692            .detach_and_notify_err(workspace.weak_handle(), window, cx);
 693    }
 694
 695    async fn close_commit_view(
 696        commit_view: Entity<CommitView>,
 697        workspace: WeakEntity<Workspace>,
 698        cx: &mut AsyncWindowContext,
 699    ) -> anyhow::Result<()> {
 700        workspace
 701            .update_in(cx, |workspace, window, cx| {
 702                let active_pane = workspace.active_pane();
 703                let commit_view_id = commit_view.entity_id();
 704                active_pane.update(cx, |pane, cx| {
 705                    pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
 706                })
 707            })?
 708            .await?;
 709        anyhow::Ok(())
 710    }
 711}
 712
 713impl language::File for GitBlob {
 714    fn as_local(&self) -> Option<&dyn language::LocalFile> {
 715        None
 716    }
 717
 718    fn disk_state(&self) -> DiskState {
 719        DiskState::Historic {
 720            was_deleted: self.is_deleted,
 721        }
 722    }
 723
 724    fn path_style(&self, _: &App) -> PathStyle {
 725        PathStyle::local()
 726    }
 727
 728    fn path(&self) -> &Arc<RelPath> {
 729        self.path.as_ref()
 730    }
 731
 732    fn full_path(&self, _: &App) -> PathBuf {
 733        self.path.as_std_path().to_path_buf()
 734    }
 735
 736    fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
 737        self.display_name.as_ref()
 738    }
 739
 740    fn worktree_id(&self, _: &App) -> WorktreeId {
 741        self.worktree_id
 742    }
 743
 744    fn to_proto(&self, _cx: &App) -> language::proto::File {
 745        unimplemented!()
 746    }
 747
 748    fn is_private(&self) -> bool {
 749        false
 750    }
 751
 752    fn can_open(&self) -> bool {
 753        !self.is_binary
 754    }
 755}
 756
 757async fn build_buffer(
 758    mut text: String,
 759    blob: Arc<dyn File>,
 760    language_registry: &Arc<language::LanguageRegistry>,
 761    cx: &mut AsyncApp,
 762) -> Result<Entity<Buffer>> {
 763    let line_ending = LineEnding::detect(&text);
 764    LineEnding::normalize(&mut text);
 765    let text = Rope::from(text);
 766    let language = cx.update(|cx| language_registry.language_for_file(&blob, Some(&text), cx));
 767    let language = if let Some(language) = language {
 768        language_registry
 769            .load_language(&language)
 770            .await
 771            .ok()
 772            .and_then(|e| e.log_err())
 773    } else {
 774        None
 775    };
 776    let buffer = cx.new(|cx| {
 777        let buffer = TextBuffer::new_normalized(
 778            ReplicaId::LOCAL,
 779            cx.entity_id().as_non_zero_u64().into(),
 780            line_ending,
 781            text,
 782        );
 783        let mut buffer = Buffer::build(buffer, Some(blob), Capability::ReadWrite);
 784        buffer.set_language_async(language, cx);
 785        buffer
 786    });
 787    Ok(buffer)
 788}
 789
 790async fn build_buffer_diff(
 791    mut old_text: Option<String>,
 792    buffer: &Entity<Buffer>,
 793    language_registry: &Arc<LanguageRegistry>,
 794    cx: &mut AsyncApp,
 795) -> Result<Entity<BufferDiff>> {
 796    if let Some(old_text) = &mut old_text {
 797        LineEnding::normalize(old_text);
 798    }
 799
 800    let language = cx.update(|cx| buffer.read(cx).language().cloned());
 801    let buffer = cx.update(|cx| buffer.read(cx).snapshot());
 802
 803    let diff = cx.new(|cx| BufferDiff::new(&buffer.text, cx));
 804
 805    let update = diff
 806        .update(cx, |diff, cx| {
 807            diff.update_diff(
 808                buffer.text.clone(),
 809                old_text.map(|old_text| Arc::from(old_text.as_str())),
 810                Some(true),
 811                language.clone(),
 812                cx,
 813            )
 814        })
 815        .await;
 816
 817    diff.update(cx, |diff, cx| {
 818        diff.language_changed(language, Some(language_registry.clone()), cx);
 819        diff.set_snapshot(update, &buffer.text, cx)
 820    })
 821    .await;
 822
 823    Ok(diff)
 824}
 825
 826impl EventEmitter<EditorEvent> for CommitView {}
 827
 828impl Focusable for CommitView {
 829    fn focus_handle(&self, cx: &App) -> FocusHandle {
 830        self.editor.focus_handle(cx)
 831    }
 832}
 833
 834impl Item for CommitView {
 835    type Event = EditorEvent;
 836
 837    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
 838        Some(Icon::new(IconName::GitCommit).color(Color::Muted))
 839    }
 840
 841    fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
 842        Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
 843            .color(if params.selected {
 844                Color::Default
 845            } else {
 846                Color::Muted
 847            })
 848            .into_any_element()
 849    }
 850
 851    fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
 852        let short_sha = self.commit.sha.get(0..7).unwrap_or(&*self.commit.sha);
 853        let subject = truncate_and_trailoff(self.commit.message.split('\n').next().unwrap(), 20);
 854        format!("{short_sha}{subject}").into()
 855    }
 856
 857    fn tab_tooltip_content(&self, _: &App) -> Option<TabTooltipContent> {
 858        let short_sha = self.commit.sha.get(0..16).unwrap_or(&*self.commit.sha);
 859        let subject = self.commit.message.split('\n').next().unwrap();
 860
 861        Some(TabTooltipContent::Custom(Box::new(Tooltip::element({
 862            let subject = subject.to_string();
 863            let short_sha = short_sha.to_string();
 864
 865            move |_, _| {
 866                v_flex()
 867                    .child(Label::new(subject.clone()))
 868                    .child(
 869                        Label::new(short_sha.clone())
 870                            .color(Color::Muted)
 871                            .size(LabelSize::Small),
 872                    )
 873                    .into_any_element()
 874            }
 875        }))))
 876    }
 877
 878    fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
 879        Editor::to_item_events(event, f)
 880    }
 881
 882    fn telemetry_event_text(&self) -> Option<&'static str> {
 883        Some("Commit View Opened")
 884    }
 885
 886    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 887        self.editor
 888            .update(cx, |editor, cx| editor.deactivated(window, cx));
 889    }
 890
 891    fn act_as_type<'a>(
 892        &'a self,
 893        type_id: TypeId,
 894        self_handle: &'a Entity<Self>,
 895        _: &'a App,
 896    ) -> Option<gpui::AnyEntity> {
 897        if type_id == TypeId::of::<Self>() {
 898            Some(self_handle.clone().into())
 899        } else if type_id == TypeId::of::<Editor>() {
 900            Some(self.editor.clone().into())
 901        } else {
 902            None
 903        }
 904    }
 905
 906    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
 907        Some(Box::new(self.editor.clone()))
 908    }
 909
 910    fn for_each_project_item(
 911        &self,
 912        cx: &App,
 913        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 914    ) {
 915        self.editor.for_each_project_item(cx, f)
 916    }
 917
 918    fn set_nav_history(
 919        &mut self,
 920        nav_history: ItemNavHistory,
 921        _: &mut Window,
 922        cx: &mut Context<Self>,
 923    ) {
 924        self.editor.update(cx, |editor, _| {
 925            editor.set_nav_history(Some(nav_history));
 926        });
 927    }
 928
 929    fn navigate(
 930        &mut self,
 931        data: Arc<dyn Any + Send>,
 932        window: &mut Window,
 933        cx: &mut Context<Self>,
 934    ) -> bool {
 935        self.editor
 936            .update(cx, |editor, cx| editor.navigate(data, window, cx))
 937    }
 938
 939    fn added_to_workspace(
 940        &mut self,
 941        workspace: &mut Workspace,
 942        window: &mut Window,
 943        cx: &mut Context<Self>,
 944    ) {
 945        self.editor.update(cx, |editor, cx| {
 946            editor.added_to_workspace(workspace, window, cx)
 947        });
 948    }
 949
 950    fn can_split(&self) -> bool {
 951        true
 952    }
 953
 954    fn clone_on_split(
 955        &self,
 956        _workspace_id: Option<workspace::WorkspaceId>,
 957        window: &mut Window,
 958        cx: &mut Context<Self>,
 959    ) -> Task<Option<Entity<Self>>>
 960    where
 961        Self: Sized,
 962    {
 963        let file_statuses = self
 964            .editor
 965            .read(cx)
 966            .addon::<CommitDiffAddon>()
 967            .map(|addon| addon.file_statuses.clone())
 968            .unwrap_or_default();
 969        Task::ready(Some(cx.new(|cx| {
 970            let editor = cx.new({
 971                let file_statuses = file_statuses.clone();
 972                |cx| {
 973                    let mut editor = self
 974                        .editor
 975                        .update(cx, |editor, cx| editor.clone(window, cx));
 976                    editor.register_addon(CommitDiffAddon { file_statuses });
 977                    editor
 978                }
 979            });
 980            let multibuffer = editor.read(cx).buffer().clone();
 981            Self {
 982                editor,
 983                multibuffer,
 984                commit: self.commit.clone(),
 985                stash: self.stash,
 986                repository: self.repository.clone(),
 987                remote: self.remote.clone(),
 988            }
 989        })))
 990    }
 991}
 992
 993impl Render for CommitView {
 994    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 995        let is_stash = self.stash.is_some();
 996
 997        v_flex()
 998            .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
 999            .size_full()
1000            .bg(cx.theme().colors().editor_background)
1001            .child(self.render_header(window, cx))
1002            .when(!self.editor.read(cx).is_empty(cx), |this| {
1003                this.child(div().flex_grow().child(self.editor.clone()))
1004            })
1005    }
1006}
1007
1008pub struct CommitViewToolbar {
1009    commit_view: Option<WeakEntity<CommitView>>,
1010}
1011
1012impl CommitViewToolbar {
1013    pub fn new() -> Self {
1014        Self { commit_view: None }
1015    }
1016}
1017
1018impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
1019
1020impl Render for CommitViewToolbar {
1021    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1022        let Some(commit_view) = self.commit_view.as_ref().and_then(|w| w.upgrade()) else {
1023            return div();
1024        };
1025
1026        let commit_view_ref = commit_view.read(cx);
1027        let is_stash = commit_view_ref.stash.is_some();
1028
1029        let (additions, deletions) = commit_view_ref.calculate_changed_lines(cx);
1030
1031        let commit_sha = commit_view_ref.commit.sha.clone();
1032
1033        let remote_info = commit_view_ref.remote.as_ref().map(|remote| {
1034            let provider = remote.host.name();
1035            let parsed_remote = ParsedGitRemote {
1036                owner: remote.owner.as_ref().into(),
1037                repo: remote.repo.as_ref().into(),
1038            };
1039            let params = BuildCommitPermalinkParams { sha: &commit_sha };
1040            let url = remote
1041                .host
1042                .build_commit_permalink(&parsed_remote, params)
1043                .to_string();
1044            (provider, url)
1045        });
1046
1047        let sha_for_graph = commit_sha.to_string();
1048
1049        h_flex()
1050            .gap_1()
1051            .when(additions > 0 || deletions > 0, |this| {
1052                this.child(
1053                    h_flex()
1054                        .gap_2()
1055                        .child(DiffStat::new(
1056                            "toolbar-diff-stat",
1057                            additions as usize,
1058                            deletions as usize,
1059                        ))
1060                        .child(Divider::vertical()),
1061                )
1062            })
1063            .child(
1064                IconButton::new("buffer-search", IconName::MagnifyingGlass)
1065                    .icon_size(IconSize::Small)
1066                    .tooltip(move |_, cx| {
1067                        Tooltip::for_action(
1068                            "Buffer Search",
1069                            &zed_actions::buffer_search::Deploy::find(),
1070                            cx,
1071                        )
1072                    })
1073                    .on_click(|_, window, cx| {
1074                        window.dispatch_action(
1075                            Box::new(zed_actions::buffer_search::Deploy::find()),
1076                            cx,
1077                        );
1078                    }),
1079            )
1080            .when(!is_stash, |this| {
1081                this.when(cx.has_flag::<GitGraphFeatureFlag>(), |this| {
1082                    this.child(
1083                        IconButton::new("show-in-git-graph", IconName::GitGraph)
1084                            .icon_size(IconSize::Small)
1085                            .tooltip(Tooltip::text("Show in Git Graph"))
1086                            .on_click(move |_, window, cx| {
1087                                window.dispatch_action(
1088                                    Box::new(crate::git_panel::OpenAtCommit {
1089                                        sha: sha_for_graph.clone(),
1090                                    }),
1091                                    cx,
1092                                );
1093                            }),
1094                    )
1095                })
1096                .children(remote_info.map(|(provider_name, url)| {
1097                    let icon = match provider_name.as_str() {
1098                        "GitHub" => IconName::Github,
1099                        _ => IconName::Link,
1100                    };
1101
1102                    IconButton::new("view_on_provider", icon)
1103                        .icon_size(IconSize::Small)
1104                        .tooltip(Tooltip::text(format!("View on {}", provider_name)))
1105                        .on_click(move |_, _, cx| cx.open_url(&url))
1106                }))
1107            })
1108    }
1109}
1110
1111impl ToolbarItemView for CommitViewToolbar {
1112    fn set_active_pane_item(
1113        &mut self,
1114        active_pane_item: Option<&dyn ItemHandle>,
1115        _: &mut Window,
1116        cx: &mut Context<Self>,
1117    ) -> ToolbarItemLocation {
1118        if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx)) {
1119            self.commit_view = Some(entity.downgrade());
1120            return ToolbarItemLocation::PrimaryRight;
1121        }
1122        self.commit_view = None;
1123        ToolbarItemLocation::Hidden
1124    }
1125
1126    fn pane_focus_update(
1127        &mut self,
1128        _pane_focused: bool,
1129        _window: &mut Window,
1130        _cx: &mut Context<Self>,
1131    ) {
1132    }
1133}
1134
1135fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool {
1136    repo.stash_entries
1137        .entries
1138        .get(stash_index)
1139        .map(|entry| entry.oid.to_string() == sha)
1140        .unwrap_or(false)
1141}