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