commit_view.rs

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