commit_view.rs

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