commit_view.rs

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