commit_view.rs

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