commit_view.rs

   1use anyhow::{Context as _, Result};
   2use buffer_diff::BufferDiff;
   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<_> =
 199                            buffer_diff.read(cx).snapshot(cx).hunks(&snapshot).collect();
 200
 201                        let excerpt_ranges = if hunks.is_empty() {
 202                            vec![language::Point::zero()..snapshot.max_point()]
 203                        } else {
 204                            hunks
 205                                .into_iter()
 206                                .map(|hunk| {
 207                                    let start = hunk.range.start.max(language::Point::new(
 208                                        hunk.range.start.row.saturating_sub(3),
 209                                        0,
 210                                    ));
 211                                    let end_row =
 212                                        (hunk.range.end.row + 3).min(snapshot.max_point().row);
 213                                    let end =
 214                                        language::Point::new(end_row, snapshot.line_len(end_row));
 215                                    start..end
 216                                })
 217                                .collect()
 218                        };
 219
 220                        let _is_newly_added = multibuffer.set_excerpts_for_path(
 221                            PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path),
 222                            buffer,
 223                            excerpt_ranges,
 224                            0,
 225                            cx,
 226                        );
 227                        multibuffer.add_diff(buffer_diff, cx);
 228                    });
 229                })?;
 230            }
 231            anyhow::Ok(())
 232        })
 233        .detach();
 234
 235        let snapshot = repository.read(cx).snapshot();
 236        let remote_url = snapshot
 237            .remote_upstream_url
 238            .as_ref()
 239            .or(snapshot.remote_origin_url.as_ref());
 240
 241        let remote = remote_url.and_then(|url| {
 242            let provider_registry = GitHostingProviderRegistry::default_global(cx);
 243            parse_git_remote_url(provider_registry, url).map(|(host, parsed)| GitRemote {
 244                host,
 245                owner: parsed.owner.into(),
 246                repo: parsed.repo.into(),
 247            })
 248        });
 249
 250        let processed_message = if let Some(ref remote) = remote {
 251            Self::process_github_issues(&commit.message, remote)
 252        } else {
 253            commit.message.to_string()
 254        };
 255
 256        let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx));
 257
 258        Self {
 259            commit,
 260            editor,
 261            multibuffer,
 262            stash,
 263            repository,
 264            remote,
 265            markdown,
 266        }
 267    }
 268
 269    fn fallback_commit_avatar() -> AnyElement {
 270        Icon::new(IconName::Person)
 271            .color(Color::Muted)
 272            .size(IconSize::Medium)
 273            .into_element()
 274            .into_any()
 275    }
 276
 277    fn render_commit_avatar(
 278        &self,
 279        sha: &SharedString,
 280        size: impl Into<gpui::AbsoluteLength>,
 281        window: &mut Window,
 282        cx: &mut App,
 283    ) -> AnyElement {
 284        let remote = self.remote.as_ref().filter(|r| r.host_supports_avatars());
 285
 286        if let Some(remote) = remote {
 287            let avatar_asset = CommitAvatarAsset::new(remote.clone(), sha.clone());
 288            if let Some(Some(url)) = window.use_asset::<CommitAvatarAsset>(&avatar_asset, cx) {
 289                Avatar::new(url.to_string())
 290                    .size(size)
 291                    .into_element()
 292                    .into_any()
 293            } else {
 294                Self::fallback_commit_avatar()
 295            }
 296        } else {
 297            Self::fallback_commit_avatar()
 298        }
 299    }
 300
 301    fn render_header(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 302        let commit = &self.commit;
 303        let author_name = commit.author_name.clone();
 304        let commit_date = time::OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
 305            .unwrap_or_else(|_| time::OffsetDateTime::now_utc());
 306        let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
 307        let date_string = time_format::format_localized_timestamp(
 308            commit_date,
 309            time::OffsetDateTime::now_utc(),
 310            local_offset,
 311            time_format::TimestampFormat::MediumAbsolute,
 312        );
 313
 314        let github_url = self.remote.as_ref().map(|remote| {
 315            format!(
 316                "{}/{}/{}/commit/{}",
 317                remote.host.base_url(),
 318                remote.owner,
 319                remote.repo,
 320                commit.sha
 321            )
 322        });
 323
 324        v_flex()
 325            .p_4()
 326            .gap_4()
 327            .border_b_1()
 328            .border_color(cx.theme().colors().border)
 329            .child(
 330                h_flex()
 331                    .items_start()
 332                    .gap_3()
 333                    .child(self.render_commit_avatar(&commit.sha, gpui::rems(3.0), window, cx))
 334                    .child(
 335                        v_flex()
 336                            .gap_1()
 337                            .child(
 338                                h_flex()
 339                                    .gap_3()
 340                                    .items_baseline()
 341                                    .child(Label::new(author_name).color(Color::Default))
 342                                    .child(
 343                                        Label::new(format!("commit {}", commit.sha))
 344                                            .color(Color::Muted),
 345                                    ),
 346                            )
 347                            .child(Label::new(date_string).color(Color::Muted)),
 348                    )
 349                    .child(div().flex_grow())
 350                    .children(github_url.map(|url| {
 351                        Button::new("view_on_github", "View on GitHub")
 352                            .icon(IconName::Github)
 353                            .style(ui::ButtonStyle::Subtle)
 354                            .on_click(move |_, _, cx| cx.open_url(&url))
 355                    })),
 356            )
 357            .child(self.render_commit_message(window, cx))
 358    }
 359
 360    fn process_github_issues(message: &str, remote: &GitRemote) -> String {
 361        let mut result = String::new();
 362        let chars: Vec<char> = message.chars().collect();
 363        let mut i = 0;
 364
 365        while i < chars.len() {
 366            if chars[i] == '#' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
 367                let mut j = i + 1;
 368                while j < chars.len() && chars[j].is_ascii_digit() {
 369                    j += 1;
 370                }
 371                let issue_number = &message[i + 1..i + (j - i)];
 372                let url = format!(
 373                    "{}/{}/{}/issues/{}",
 374                    remote.host.base_url().as_str().trim_end_matches('/'),
 375                    remote.owner,
 376                    remote.repo,
 377                    issue_number
 378                );
 379                result.push_str(&format!("[#{}]({})", issue_number, url));
 380                i = j;
 381            } else if i + 3 < chars.len()
 382                && chars[i] == 'G'
 383                && chars[i + 1] == 'H'
 384                && chars[i + 2] == '-'
 385                && chars[i + 3].is_ascii_digit()
 386            {
 387                let mut j = i + 3;
 388                while j < chars.len() && chars[j].is_ascii_digit() {
 389                    j += 1;
 390                }
 391                let issue_number = &message[i + 3..i + (j - i)];
 392                let url = format!(
 393                    "{}/{}/{}/issues/{}",
 394                    remote.host.base_url().as_str().trim_end_matches('/'),
 395                    remote.owner,
 396                    remote.repo,
 397                    issue_number
 398                );
 399                result.push_str(&format!("[GH-{}]({})", issue_number, url));
 400                i = j;
 401            } else {
 402                result.push(chars[i]);
 403                i += 1;
 404            }
 405        }
 406
 407        result
 408    }
 409
 410    fn render_commit_message(
 411        &self,
 412        window: &mut Window,
 413        cx: &mut Context<Self>,
 414    ) -> impl IntoElement {
 415        let style = hover_markdown_style(window, cx);
 416        MarkdownElement::new(self.markdown.clone(), style)
 417    }
 418
 419    fn apply_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
 420        Self::stash_action(
 421            workspace,
 422            "Apply",
 423            window,
 424            cx,
 425            async move |repository, sha, stash, commit_view, workspace, cx| {
 426                let result = repository.update(cx, |repo, cx| {
 427                    if !stash_matches_index(&sha, stash, repo) {
 428                        return Err(anyhow::anyhow!("Stash has changed, not applying"));
 429                    }
 430                    Ok(repo.stash_apply(Some(stash), cx))
 431                })?;
 432
 433                match result {
 434                    Ok(task) => task.await?,
 435                    Err(err) => {
 436                        Self::close_commit_view(commit_view, workspace, cx).await?;
 437                        return Err(err);
 438                    }
 439                };
 440                Self::close_commit_view(commit_view, workspace, cx).await?;
 441                anyhow::Ok(())
 442            },
 443        );
 444    }
 445
 446    fn pop_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
 447        Self::stash_action(
 448            workspace,
 449            "Pop",
 450            window,
 451            cx,
 452            async move |repository, sha, stash, commit_view, workspace, cx| {
 453                let result = repository.update(cx, |repo, cx| {
 454                    if !stash_matches_index(&sha, stash, repo) {
 455                        return Err(anyhow::anyhow!("Stash has changed, pop aborted"));
 456                    }
 457                    Ok(repo.stash_pop(Some(stash), cx))
 458                })?;
 459
 460                match result {
 461                    Ok(task) => task.await?,
 462                    Err(err) => {
 463                        Self::close_commit_view(commit_view, workspace, cx).await?;
 464                        return Err(err);
 465                    }
 466                };
 467                Self::close_commit_view(commit_view, workspace, cx).await?;
 468                anyhow::Ok(())
 469            },
 470        );
 471    }
 472
 473    fn remove_stash(workspace: &mut Workspace, window: &mut Window, cx: &mut App) {
 474        Self::stash_action(
 475            workspace,
 476            "Drop",
 477            window,
 478            cx,
 479            async move |repository, sha, stash, commit_view, workspace, cx| {
 480                let result = repository.update(cx, |repo, cx| {
 481                    if !stash_matches_index(&sha, stash, repo) {
 482                        return Err(anyhow::anyhow!("Stash has changed, drop aborted"));
 483                    }
 484                    Ok(repo.stash_drop(Some(stash), cx))
 485                })?;
 486
 487                match result {
 488                    Ok(task) => task.await??,
 489                    Err(err) => {
 490                        Self::close_commit_view(commit_view, workspace, cx).await?;
 491                        return Err(err);
 492                    }
 493                };
 494                Self::close_commit_view(commit_view, workspace, cx).await?;
 495                anyhow::Ok(())
 496            },
 497        );
 498    }
 499
 500    fn stash_action<AsyncFn>(
 501        workspace: &mut Workspace,
 502        str_action: &str,
 503        window: &mut Window,
 504        cx: &mut App,
 505        callback: AsyncFn,
 506    ) where
 507        AsyncFn: AsyncFnOnce(
 508                Entity<Repository>,
 509                &SharedString,
 510                usize,
 511                Entity<CommitView>,
 512                WeakEntity<Workspace>,
 513                &mut AsyncWindowContext,
 514            ) -> anyhow::Result<()>
 515            + 'static,
 516    {
 517        let Some(commit_view) = workspace.active_item_as::<CommitView>(cx) else {
 518            return;
 519        };
 520        let Some(stash) = commit_view.read(cx).stash else {
 521            return;
 522        };
 523        let sha = commit_view.read(cx).commit.sha.clone();
 524        let answer = window.prompt(
 525            PromptLevel::Info,
 526            &format!("{} stash@{{{}}}?", str_action, stash),
 527            None,
 528            &[str_action, "Cancel"],
 529            cx,
 530        );
 531
 532        let workspace_weak = workspace.weak_handle();
 533        let commit_view_entity = commit_view;
 534
 535        window
 536            .spawn(cx, async move |cx| {
 537                if answer.await != Ok(0) {
 538                    return anyhow::Ok(());
 539                }
 540
 541                let Some(workspace) = workspace_weak.upgrade() else {
 542                    return Ok(());
 543                };
 544
 545                let repo = workspace.update(cx, |workspace, cx| {
 546                    workspace
 547                        .panel::<GitPanel>(cx)
 548                        .and_then(|p| p.read(cx).active_repository.clone())
 549                })?;
 550
 551                let Some(repo) = repo else {
 552                    return Ok(());
 553                };
 554
 555                callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
 556                anyhow::Ok(())
 557            })
 558            .detach_and_notify_err(window, cx);
 559    }
 560
 561    async fn close_commit_view(
 562        commit_view: Entity<CommitView>,
 563        workspace: WeakEntity<Workspace>,
 564        cx: &mut AsyncWindowContext,
 565    ) -> anyhow::Result<()> {
 566        workspace
 567            .update_in(cx, |workspace, window, cx| {
 568                let active_pane = workspace.active_pane();
 569                let commit_view_id = commit_view.entity_id();
 570                active_pane.update(cx, |pane, cx| {
 571                    pane.close_item_by_id(commit_view_id, SaveIntent::Skip, window, cx)
 572                })
 573            })?
 574            .await?;
 575        anyhow::Ok(())
 576    }
 577}
 578
 579#[derive(Clone, Debug)]
 580struct CommitAvatarAsset {
 581    sha: SharedString,
 582    remote: GitRemote,
 583}
 584
 585impl std::hash::Hash for CommitAvatarAsset {
 586    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
 587        self.sha.hash(state);
 588        self.remote.host.name().hash(state);
 589    }
 590}
 591
 592impl CommitAvatarAsset {
 593    fn new(remote: GitRemote, sha: SharedString) -> Self {
 594        Self { remote, sha }
 595    }
 596}
 597
 598impl Asset for CommitAvatarAsset {
 599    type Source = Self;
 600    type Output = Option<SharedString>;
 601
 602    fn load(
 603        source: Self::Source,
 604        cx: &mut App,
 605    ) -> impl Future<Output = Self::Output> + Send + 'static {
 606        let client = cx.http_client();
 607        async move {
 608            match source
 609                .remote
 610                .host
 611                .commit_author_avatar_url(
 612                    &source.remote.owner,
 613                    &source.remote.repo,
 614                    source.sha.clone(),
 615                    client,
 616                )
 617                .await
 618            {
 619                Ok(Some(url)) => Some(SharedString::from(url.to_string())),
 620                Ok(None) => None,
 621                Err(_) => None,
 622            }
 623        }
 624    }
 625}
 626
 627impl language::File for GitBlob {
 628    fn as_local(&self) -> Option<&dyn language::LocalFile> {
 629        None
 630    }
 631
 632    fn disk_state(&self) -> DiskState {
 633        if self.is_deleted {
 634            DiskState::Deleted
 635        } else {
 636            DiskState::New
 637        }
 638    }
 639
 640    fn path_style(&self, _: &App) -> PathStyle {
 641        PathStyle::Posix
 642    }
 643
 644    fn path(&self) -> &Arc<RelPath> {
 645        self.path.as_ref()
 646    }
 647
 648    fn full_path(&self, _: &App) -> PathBuf {
 649        self.path.as_std_path().to_path_buf()
 650    }
 651
 652    fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
 653        self.path.file_name().unwrap()
 654    }
 655
 656    fn worktree_id(&self, _: &App) -> WorktreeId {
 657        self.worktree_id
 658    }
 659
 660    fn to_proto(&self, _cx: &App) -> language::proto::File {
 661        unimplemented!()
 662    }
 663
 664    fn is_private(&self) -> bool {
 665        false
 666    }
 667}
 668
 669// No longer needed since metadata buffer is not created
 670// impl language::File for CommitMetadataFile {
 671//     fn as_local(&self) -> Option<&dyn language::LocalFile> {
 672//         None
 673//     }
 674//
 675//     fn disk_state(&self) -> DiskState {
 676//         DiskState::New
 677//     }
 678//
 679//     fn path_style(&self, _: &App) -> PathStyle {
 680//         PathStyle::Posix
 681//     }
 682//
 683//     fn path(&self) -> &Arc<RelPath> {
 684//         &self.title
 685//     }
 686//
 687//     fn full_path(&self, _: &App) -> PathBuf {
 688//         self.title.as_std_path().to_path_buf()
 689//     }
 690//
 691//     fn file_name<'a>(&'a self, _: &'a App) -> &'a str {
 692//         self.title.file_name().unwrap_or("commit")
 693//     }
 694//
 695//     fn worktree_id(&self, _: &App) -> WorktreeId {
 696//         self.worktree_id
 697//     }
 698//
 699//     fn to_proto(&self, _cx: &App) -> language::proto::File {
 700//         unimplemented!()
 701//     }
 702//
 703//     fn is_private(&self) -> bool {
 704//         false
 705//     }
 706// }
 707
 708struct CommitViewAddon {
 709    multibuffer: WeakEntity<MultiBuffer>,
 710}
 711
 712impl Addon for CommitViewAddon {
 713    fn render_buffer_header_controls(
 714        &self,
 715        excerpt: &ExcerptInfo,
 716        _window: &Window,
 717        cx: &App,
 718    ) -> Option<AnyElement> {
 719        let multibuffer = self.multibuffer.upgrade()?;
 720        let snapshot = multibuffer.read(cx).snapshot(cx);
 721        let excerpts = snapshot.excerpts().collect::<Vec<_>>();
 722        let current_idx = excerpts.iter().position(|(id, _, _)| *id == excerpt.id)?;
 723        let (_, _, current_range) = &excerpts[current_idx];
 724
 725        let start_row = current_range.context.start.to_point(&excerpt.buffer).row;
 726
 727        let prev_end_row = if current_idx > 0 {
 728            let (_, prev_buffer, prev_range) = &excerpts[current_idx - 1];
 729            if prev_buffer.remote_id() == excerpt.buffer_id {
 730                prev_range.context.end.to_point(&excerpt.buffer).row
 731            } else {
 732                0
 733            }
 734        } else {
 735            0
 736        };
 737
 738        let skipped_lines = start_row.saturating_sub(prev_end_row);
 739
 740        if skipped_lines > 0 {
 741            Some(
 742                Label::new(format!("{} unchanged lines", skipped_lines))
 743                    .color(Color::Muted)
 744                    .size(LabelSize::Small)
 745                    .into_any_element(),
 746            )
 747        } else {
 748            None
 749        }
 750    }
 751
 752    fn to_any(&self) -> &dyn Any {
 753        self
 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                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    .ok();
 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::GitBranch).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_text(&self, _: &App) -> Option<ui::SharedString> {
 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        Some(format!("{short_sha} - {subject}").into())
 861    }
 862
 863    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
 864        Editor::to_item_events(event, f)
 865    }
 866
 867    fn telemetry_event_text(&self) -> Option<&'static str> {
 868        Some("Commit View Opened")
 869    }
 870
 871    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
 872        self.editor
 873            .update(cx, |editor, cx| editor.deactivated(window, cx));
 874    }
 875
 876    fn act_as_type<'a>(
 877        &'a self,
 878        type_id: TypeId,
 879        self_handle: &'a Entity<Self>,
 880        _: &'a App,
 881    ) -> Option<gpui::AnyEntity> {
 882        if type_id == TypeId::of::<Self>() {
 883            Some(self_handle.clone().into())
 884        } else if type_id == TypeId::of::<Editor>() {
 885            Some(self.editor.clone().into())
 886        } else {
 887            None
 888        }
 889    }
 890
 891    fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
 892        Some(Box::new(self.editor.clone()))
 893    }
 894
 895    fn for_each_project_item(
 896        &self,
 897        cx: &App,
 898        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
 899    ) {
 900        self.editor.for_each_project_item(cx, f)
 901    }
 902
 903    fn set_nav_history(
 904        &mut self,
 905        nav_history: ItemNavHistory,
 906        _: &mut Window,
 907        cx: &mut Context<Self>,
 908    ) {
 909        self.editor.update(cx, |editor, _| {
 910            editor.set_nav_history(Some(nav_history));
 911        });
 912    }
 913
 914    fn navigate(
 915        &mut self,
 916        data: Box<dyn Any>,
 917        window: &mut Window,
 918        cx: &mut Context<Self>,
 919    ) -> bool {
 920        self.editor
 921            .update(cx, |editor, cx| editor.navigate(data, window, cx))
 922    }
 923
 924    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
 925        ToolbarItemLocation::Hidden
 926    }
 927
 928    fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option<Vec<BreadcrumbText>> {
 929        None
 930    }
 931
 932    fn added_to_workspace(
 933        &mut self,
 934        workspace: &mut Workspace,
 935        window: &mut Window,
 936        cx: &mut Context<Self>,
 937    ) {
 938        self.editor.update(cx, |editor, cx| {
 939            editor.added_to_workspace(workspace, window, cx)
 940        });
 941    }
 942
 943    fn can_split(&self) -> bool {
 944        true
 945    }
 946
 947    fn clone_on_split(
 948        &self,
 949        _workspace_id: Option<workspace::WorkspaceId>,
 950        window: &mut Window,
 951        cx: &mut Context<Self>,
 952    ) -> Task<Option<Entity<Self>>>
 953    where
 954        Self: Sized,
 955    {
 956        Task::ready(Some(cx.new(|cx| {
 957            let editor = cx.new(|cx| {
 958                self.editor
 959                    .update(cx, |editor, cx| editor.clone(window, cx))
 960            });
 961            let multibuffer = editor.read(cx).buffer().clone();
 962            let processed_message = if let Some(ref remote) = self.remote {
 963                Self::process_github_issues(&self.commit.message, remote)
 964            } else {
 965                self.commit.message.to_string()
 966            };
 967            let markdown = cx.new(|cx| Markdown::new(processed_message.into(), None, None, cx));
 968            Self {
 969                editor,
 970                multibuffer,
 971                commit: self.commit.clone(),
 972                stash: self.stash,
 973                repository: self.repository.clone(),
 974                remote: self.remote.clone(),
 975                markdown,
 976            }
 977        })))
 978    }
 979}
 980
 981impl Render for CommitView {
 982    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 983        let is_stash = self.stash.is_some();
 984        div()
 985            .key_context(if is_stash { "StashDiff" } else { "CommitDiff" })
 986            .bg(cx.theme().colors().editor_background)
 987            .flex()
 988            .flex_col()
 989            .size_full()
 990            .child(self.render_header(window, cx))
 991            .child(div().flex_grow().child(self.editor.clone()))
 992    }
 993}
 994
 995pub struct CommitViewToolbar {
 996    commit_view: Option<WeakEntity<CommitView>>,
 997}
 998
 999impl CommitViewToolbar {
1000    pub fn new() -> Self {
1001        Self { commit_view: None }
1002    }
1003}
1004
1005impl EventEmitter<ToolbarItemEvent> for CommitViewToolbar {}
1006
1007impl Render for CommitViewToolbar {
1008    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1009        div()
1010    }
1011}
1012
1013impl ToolbarItemView for CommitViewToolbar {
1014    fn set_active_pane_item(
1015        &mut self,
1016        active_pane_item: Option<&dyn ItemHandle>,
1017        _: &mut Window,
1018        cx: &mut Context<Self>,
1019    ) -> ToolbarItemLocation {
1020        if let Some(entity) = active_pane_item.and_then(|i| i.act_as::<CommitView>(cx))
1021            && entity.read(cx).stash.is_some()
1022        {
1023            self.commit_view = Some(entity.downgrade());
1024            return ToolbarItemLocation::PrimaryRight;
1025        }
1026        ToolbarItemLocation::Hidden
1027    }
1028
1029    fn pane_focus_update(
1030        &mut self,
1031        _pane_focused: bool,
1032        _window: &mut Window,
1033        _cx: &mut Context<Self>,
1034    ) {
1035    }
1036}
1037
1038fn stash_matches_index(sha: &str, stash_index: usize, repo: &Repository) -> bool {
1039    repo.stash_entries
1040        .entries
1041        .get(stash_index)
1042        .map(|entry| entry.oid.to_string() == sha)
1043        .unwrap_or(false)
1044}
1045
1046fn hover_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
1047    let colors = cx.theme().colors();
1048    let mut style = MarkdownStyle::default();
1049    style.base_text_style = window.text_style();
1050    style.syntax = cx.theme().syntax().clone();
1051    style.selection_background_color = colors.element_selection_background;
1052    style.link = TextStyleRefinement {
1053        color: Some(colors.text_accent),
1054        underline: Some(UnderlineStyle {
1055            thickness: px(1.0),
1056            color: Some(colors.text_accent),
1057            wavy: false,
1058        }),
1059        ..Default::default()
1060    };
1061    style
1062}
1063
1064#[cfg(test)]
1065mod tests {
1066    use super::*;
1067    use git_hosting_providers::Github;
1068
1069    fn create_test_remote() -> GitRemote {
1070        GitRemote {
1071            host: Arc::new(Github::public_instance()),
1072            owner: "zed-industries".into(),
1073            repo: "zed".into(),
1074        }
1075    }
1076
1077    #[test]
1078    fn test_process_github_issues_simple_issue_number() {
1079        let remote = create_test_remote();
1080        let message = "Fix bug #123";
1081        let result = CommitView::process_github_issues(message, &remote);
1082        assert_eq!(
1083            result,
1084            "Fix bug [#123](https://github.com/zed-industries/zed/issues/123)"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_process_github_issues_multiple_issue_numbers() {
1090        let remote = create_test_remote();
1091        let message = "Fix #123 and #456";
1092        let result = CommitView::process_github_issues(message, &remote);
1093        assert_eq!(
1094            result,
1095            "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [#456](https://github.com/zed-industries/zed/issues/456)"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_process_github_issues_gh_format() {
1101        let remote = create_test_remote();
1102        let message = "Fix GH-789";
1103        let result = CommitView::process_github_issues(message, &remote);
1104        assert_eq!(
1105            result,
1106            "Fix [GH-789](https://github.com/zed-industries/zed/issues/789)"
1107        );
1108    }
1109
1110    #[test]
1111    fn test_process_github_issues_mixed_formats() {
1112        let remote = create_test_remote();
1113        let message = "Fix #123 and GH-456";
1114        let result = CommitView::process_github_issues(message, &remote);
1115        assert_eq!(
1116            result,
1117            "Fix [#123](https://github.com/zed-industries/zed/issues/123) and [GH-456](https://github.com/zed-industries/zed/issues/456)"
1118        );
1119    }
1120
1121    #[test]
1122    fn test_process_github_issues_no_issues() {
1123        let remote = create_test_remote();
1124        let message = "This is a commit message without any issues";
1125        let result = CommitView::process_github_issues(message, &remote);
1126        assert_eq!(result, message);
1127    }
1128
1129    #[test]
1130    fn test_process_github_issues_hash_without_number() {
1131        let remote = create_test_remote();
1132        let message = "Use # for comments";
1133        let result = CommitView::process_github_issues(message, &remote);
1134        assert_eq!(result, message);
1135    }
1136
1137    #[test]
1138    fn test_process_github_issues_consecutive_issues() {
1139        let remote = create_test_remote();
1140        let message = "#123#456";
1141        let result = CommitView::process_github_issues(message, &remote);
1142        assert_eq!(
1143            result,
1144            "[#123](https://github.com/zed-industries/zed/issues/123)[#456](https://github.com/zed-industries/zed/issues/456)"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_process_github_issues_multiline() {
1150        let remote = create_test_remote();
1151        let message = "Fix #123\n\nThis also fixes #456";
1152        let result = CommitView::process_github_issues(message, &remote);
1153        assert_eq!(
1154            result,
1155            "Fix [#123](https://github.com/zed-industries/zed/issues/123)\n\nThis also fixes [#456](https://github.com/zed-industries/zed/issues/456)"
1156        );
1157    }
1158}