git_panel.rs

   1use crate::askpass_modal::AskPassModal;
   2use crate::commit_modal::CommitModal;
   3use crate::commit_tooltip::CommitTooltip;
   4use crate::commit_view::CommitView;
   5use crate::project_diff::{self, BranchDiff, Diff, ProjectDiff};
   6use crate::remote_output::{self, RemoteAction, SuccessMessage};
   7use crate::{branch_picker, picker_prompt, render_remote_button};
   8use crate::{
   9    file_history_view::FileHistoryView, git_panel_settings::GitPanelSettings, git_status_icon,
  10    repository_selector::RepositorySelector,
  11};
  12use agent_settings::AgentSettings;
  13use anyhow::Context as _;
  14use askpass::AskPassDelegate;
  15use cloud_llm_client::CompletionIntent;
  16use collections::{BTreeMap, HashMap, HashSet};
  17use db::kvp::KEY_VALUE_STORE;
  18use editor::{
  19    Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
  20    actions::ExpandAllDiffHunks,
  21};
  22use editor::{EditorStyle, RewrapOptions};
  23use futures::StreamExt as _;
  24use git::commit::ParsedCommitMessage;
  25use git::repository::{
  26    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
  27    PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
  28    UpstreamTrackingStatus, get_git_committer,
  29};
  30use git::stash::GitStash;
  31use git::status::{DiffStat, StageStatus};
  32use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
  33use git::{
  34    ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
  35    StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
  36};
  37use gpui::{
  38    Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Empty, Entity,
  39    EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
  40    PromptLevel, ScrollStrategy, Subscription, Task, TextStyle, UniformListScrollHandle,
  41    WeakEntity, actions, anchored, deferred, point, size, uniform_list,
  42};
  43use itertools::Itertools;
  44use language::{Buffer, BufferEvent, File};
  45use language_model::{
  46    ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
  47};
  48use menu;
  49use multi_buffer::ExcerptInfo;
  50use notifications::status_toast::{StatusToast, ToastIcon};
  51use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
  52use project::{
  53    Fs, Project, ProjectPath,
  54    buffer_store::BufferStoreEvent,
  55    git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
  56    project_settings::{GitPathStyle, ProjectSettings},
  57};
  58use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
  59use proto::RpcError;
  60use serde::{Deserialize, Serialize};
  61use settings::{Settings, SettingsStore, StatusStyle};
  62use smallvec::SmallVec;
  63use std::future::Future;
  64use std::ops::Range;
  65use std::path::Path;
  66use std::{sync::Arc, time::Duration, usize};
  67use strum::{IntoEnumIterator, VariantNames};
  68use theme::ThemeSettings;
  69use time::OffsetDateTime;
  70use ui::{
  71    ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors,
  72    PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar,
  73    prelude::*,
  74};
  75use util::paths::PathStyle;
  76use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath};
  77use workspace::SERIALIZATION_THROTTLE_TIME;
  78use workspace::{
  79    Workspace,
  80    dock::{DockPosition, Panel, PanelEvent},
  81    notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
  82};
  83
  84actions!(
  85    git_panel,
  86    [
  87        /// Closes the git panel.
  88        Close,
  89        /// Toggles the git panel.
  90        Toggle,
  91        /// Toggles focus on the git panel.
  92        ToggleFocus,
  93        /// Opens the git panel menu.
  94        OpenMenu,
  95        /// Focuses on the commit message editor.
  96        FocusEditor,
  97        /// Focuses on the changes list.
  98        FocusChanges,
  99        /// Select next git panel menu item, and show it in the diff view
 100        NextEntry,
 101        /// Select previous git panel menu item, and show it in the diff view
 102        PreviousEntry,
 103        /// Select first git panel menu item, and show it in the diff view
 104        FirstEntry,
 105        /// Select last git panel menu item, and show it in the diff view
 106        LastEntry,
 107        /// Toggles automatic co-author suggestions.
 108        ToggleFillCoAuthors,
 109        /// Toggles sorting entries by path vs status.
 110        ToggleSortByPath,
 111        /// Toggles showing entries in tree vs flat view.
 112        ToggleTreeView,
 113        /// Expands the selected entry to show its children.
 114        ExpandSelectedEntry,
 115        /// Collapses the selected entry to hide its children.
 116        CollapseSelectedEntry,
 117    ]
 118);
 119
 120actions!(
 121    git_graph,
 122    [
 123        /// Opens the Git Graph Tab.
 124        Open,
 125    ]
 126);
 127
 128/// Opens the Git Graph Tab at a specific commit.
 129#[derive(Clone, PartialEq, serde::Deserialize, schemars::JsonSchema, gpui::Action)]
 130#[action(namespace = git_graph)]
 131pub struct OpenAtCommit {
 132    pub sha: String,
 133}
 134
 135fn prompt<T>(
 136    msg: &str,
 137    detail: Option<&str>,
 138    window: &mut Window,
 139    cx: &mut App,
 140) -> Task<anyhow::Result<T>>
 141where
 142    T: IntoEnumIterator + VariantNames + 'static,
 143{
 144    let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx);
 145    cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
 146}
 147
 148#[derive(strum::EnumIter, strum::VariantNames)]
 149#[strum(serialize_all = "title_case")]
 150enum TrashCancel {
 151    Trash,
 152    Cancel,
 153}
 154
 155struct GitMenuState {
 156    has_tracked_changes: bool,
 157    has_staged_changes: bool,
 158    has_unstaged_changes: bool,
 159    has_new_changes: bool,
 160    sort_by_path: bool,
 161    has_stash_items: bool,
 162    tree_view: bool,
 163}
 164
 165fn git_panel_context_menu(
 166    focus_handle: FocusHandle,
 167    state: GitMenuState,
 168    window: &mut Window,
 169    cx: &mut App,
 170) -> Entity<ContextMenu> {
 171    ContextMenu::build(window, cx, move |context_menu, _, _| {
 172        context_menu
 173            .context(focus_handle)
 174            .action_disabled_when(
 175                !state.has_unstaged_changes,
 176                "Stage All",
 177                StageAll.boxed_clone(),
 178            )
 179            .action_disabled_when(
 180                !state.has_staged_changes,
 181                "Unstage All",
 182                UnstageAll.boxed_clone(),
 183            )
 184            .separator()
 185            .action_disabled_when(
 186                !(state.has_new_changes || state.has_tracked_changes),
 187                "Stash All",
 188                StashAll.boxed_clone(),
 189            )
 190            .action_disabled_when(!state.has_stash_items, "Stash Pop", StashPop.boxed_clone())
 191            .action("View Stash", zed_actions::git::ViewStash.boxed_clone())
 192            .separator()
 193            .action("Open Diff", project_diff::Diff.boxed_clone())
 194            .separator()
 195            .action_disabled_when(
 196                !state.has_tracked_changes,
 197                "Discard Tracked Changes",
 198                RestoreTrackedFiles.boxed_clone(),
 199            )
 200            .action_disabled_when(
 201                !state.has_new_changes,
 202                "Trash Untracked Files",
 203                TrashUntrackedFiles.boxed_clone(),
 204            )
 205            .separator()
 206            .entry(
 207                if state.tree_view {
 208                    "Flat View"
 209                } else {
 210                    "Tree View"
 211                },
 212                Some(Box::new(ToggleTreeView)),
 213                move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx),
 214            )
 215            .when(!state.tree_view, |this| {
 216                this.entry(
 217                    if state.sort_by_path {
 218                        "Sort by Status"
 219                    } else {
 220                        "Sort by Path"
 221                    },
 222                    Some(Box::new(ToggleSortByPath)),
 223                    move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx),
 224                )
 225            })
 226    })
 227}
 228
 229const GIT_PANEL_KEY: &str = "GitPanel";
 230
 231const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
 232// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
 233const TREE_INDENT: f32 = 16.0;
 234
 235pub fn register(workspace: &mut Workspace) {
 236    workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
 237        workspace.toggle_panel_focus::<GitPanel>(window, cx);
 238    });
 239    workspace.register_action(|workspace, _: &Toggle, window, cx| {
 240        if !workspace.toggle_panel_focus::<GitPanel>(window, cx) {
 241            workspace.close_panel::<GitPanel>(window, cx);
 242        }
 243    });
 244    workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
 245        CommitModal::toggle(workspace, None, window, cx)
 246    });
 247    workspace.register_action(|workspace, _: &git::Init, window, cx| {
 248        if let Some(panel) = workspace.panel::<GitPanel>(cx) {
 249            panel.update(cx, |panel, cx| panel.git_init(window, cx));
 250        }
 251    });
 252}
 253
 254#[derive(Debug, Clone)]
 255pub enum Event {
 256    Focus,
 257}
 258
 259#[derive(Serialize, Deserialize)]
 260struct SerializedGitPanel {
 261    width: Option<Pixels>,
 262    #[serde(default)]
 263    amend_pending: bool,
 264    #[serde(default)]
 265    signoff_enabled: bool,
 266}
 267
 268#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
 269enum Section {
 270    Conflict,
 271    Tracked,
 272    New,
 273}
 274
 275#[derive(Debug, PartialEq, Eq, Clone)]
 276struct GitHeaderEntry {
 277    header: Section,
 278}
 279
 280impl GitHeaderEntry {
 281    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
 282        let this = &self.header;
 283        let status = status_entry.status;
 284        match this {
 285            Section::Conflict => {
 286                repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
 287            }
 288            Section::Tracked => !status.is_created(),
 289            Section::New => status.is_created(),
 290        }
 291    }
 292    pub fn title(&self) -> &'static str {
 293        match self.header {
 294            Section::Conflict => "Conflicts",
 295            Section::Tracked => "Tracked",
 296            Section::New => "Untracked",
 297        }
 298    }
 299}
 300
 301#[derive(Debug, PartialEq, Eq, Clone)]
 302enum GitListEntry {
 303    Status(GitStatusEntry),
 304    TreeStatus(GitTreeStatusEntry),
 305    Directory(GitTreeDirEntry),
 306    Header(GitHeaderEntry),
 307}
 308
 309impl GitListEntry {
 310    fn status_entry(&self) -> Option<&GitStatusEntry> {
 311        match self {
 312            GitListEntry::Status(entry) => Some(entry),
 313            GitListEntry::TreeStatus(entry) => Some(&entry.entry),
 314            _ => None,
 315        }
 316    }
 317
 318    fn directory_entry(&self) -> Option<&GitTreeDirEntry> {
 319        match self {
 320            GitListEntry::Directory(entry) => Some(entry),
 321            _ => None,
 322        }
 323    }
 324
 325    /// Returns the tree indentation depth for this entry.
 326    fn depth(&self) -> usize {
 327        match self {
 328            GitListEntry::Directory(dir) => dir.depth,
 329            GitListEntry::TreeStatus(status) => status.depth,
 330            _ => 0,
 331        }
 332    }
 333}
 334
 335enum GitPanelViewMode {
 336    Flat,
 337    Tree(TreeViewState),
 338}
 339
 340impl GitPanelViewMode {
 341    fn from_settings(cx: &App) -> Self {
 342        if GitPanelSettings::get_global(cx).tree_view {
 343            GitPanelViewMode::Tree(TreeViewState::default())
 344        } else {
 345            GitPanelViewMode::Flat
 346        }
 347    }
 348
 349    fn tree_state(&self) -> Option<&TreeViewState> {
 350        match self {
 351            GitPanelViewMode::Tree(state) => Some(state),
 352            GitPanelViewMode::Flat => None,
 353        }
 354    }
 355
 356    fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> {
 357        match self {
 358            GitPanelViewMode::Tree(state) => Some(state),
 359            GitPanelViewMode::Flat => None,
 360        }
 361    }
 362}
 363
 364#[derive(Default)]
 365struct TreeViewState {
 366    // Maps visible index to actual entry index.
 367    // Length equals the number of visible entries.
 368    // This is needed because some entries (like collapsed directories) may be hidden.
 369    logical_indices: Vec<usize>,
 370    expanded_dirs: HashMap<TreeKey, bool>,
 371    directory_descendants: HashMap<TreeKey, Vec<GitStatusEntry>>,
 372}
 373
 374impl TreeViewState {
 375    fn build_tree_entries(
 376        &mut self,
 377        section: Section,
 378        mut entries: Vec<GitStatusEntry>,
 379        seen_directories: &mut HashSet<TreeKey>,
 380    ) -> Vec<(GitListEntry, bool)> {
 381        if entries.is_empty() {
 382            return Vec::new();
 383        }
 384
 385        entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
 386
 387        let mut root = TreeNode::default();
 388        for entry in entries {
 389            let components: Vec<&str> = entry.repo_path.components().collect();
 390            if components.is_empty() {
 391                root.files.push(entry);
 392                continue;
 393            }
 394
 395            let mut current = &mut root;
 396            let mut current_path = String::new();
 397
 398            for (ix, component) in components.iter().enumerate() {
 399                if ix == components.len() - 1 {
 400                    current.files.push(entry.clone());
 401                } else {
 402                    if !current_path.is_empty() {
 403                        current_path.push('/');
 404                    }
 405                    current_path.push_str(component);
 406                    let dir_path = RepoPath::new(&current_path)
 407                        .expect("repo path from status entry component");
 408
 409                    let component = SharedString::from(component.to_string());
 410
 411                    current = current
 412                        .children
 413                        .entry(component.clone())
 414                        .or_insert_with(|| TreeNode {
 415                            name: component,
 416                            path: Some(dir_path),
 417                            ..Default::default()
 418                        });
 419                }
 420            }
 421        }
 422
 423        let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories);
 424        flattened
 425    }
 426
 427    fn flatten_tree(
 428        &mut self,
 429        node: &TreeNode,
 430        section: Section,
 431        depth: usize,
 432        seen_directories: &mut HashSet<TreeKey>,
 433    ) -> (Vec<(GitListEntry, bool)>, Vec<GitStatusEntry>) {
 434        let mut all_statuses = Vec::new();
 435        let mut flattened = Vec::new();
 436
 437        for child in node.children.values() {
 438            let (terminal, name) = Self::compact_directory_chain(child);
 439            let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else {
 440                continue;
 441            };
 442            let (child_flattened, mut child_statuses) =
 443                self.flatten_tree(terminal, section, depth + 1, seen_directories);
 444            let key = TreeKey { section, path };
 445            let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true);
 446            self.expanded_dirs.entry(key.clone()).or_insert(true);
 447            seen_directories.insert(key.clone());
 448
 449            self.directory_descendants
 450                .insert(key.clone(), child_statuses.clone());
 451
 452            flattened.push((
 453                GitListEntry::Directory(GitTreeDirEntry {
 454                    key,
 455                    name,
 456                    depth,
 457                    expanded,
 458                }),
 459                true,
 460            ));
 461
 462            if expanded {
 463                flattened.extend(child_flattened);
 464            } else {
 465                flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false)));
 466            }
 467
 468            all_statuses.append(&mut child_statuses);
 469        }
 470
 471        for file in &node.files {
 472            all_statuses.push(file.clone());
 473            flattened.push((
 474                GitListEntry::TreeStatus(GitTreeStatusEntry {
 475                    entry: file.clone(),
 476                    depth,
 477                }),
 478                true,
 479            ));
 480        }
 481
 482        (flattened, all_statuses)
 483    }
 484
 485    fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) {
 486        let mut parts = vec![node.name.clone()];
 487        while node.files.is_empty() && node.children.len() == 1 {
 488            let Some(child) = node.children.values().next() else {
 489                continue;
 490            };
 491            if child.path.is_none() {
 492                break;
 493            }
 494            parts.push(child.name.clone());
 495            node = child;
 496        }
 497        let name = parts.join("/");
 498        (node, SharedString::from(name))
 499    }
 500}
 501
 502#[derive(Debug, PartialEq, Eq, Clone)]
 503struct GitTreeStatusEntry {
 504    entry: GitStatusEntry,
 505    depth: usize,
 506}
 507
 508#[derive(Debug, PartialEq, Eq, Clone, Hash)]
 509struct TreeKey {
 510    section: Section,
 511    path: RepoPath,
 512}
 513
 514#[derive(Debug, PartialEq, Eq, Clone)]
 515struct GitTreeDirEntry {
 516    key: TreeKey,
 517    name: SharedString,
 518    depth: usize,
 519    // staged_state: ToggleState,
 520    expanded: bool,
 521}
 522
 523#[derive(Default)]
 524struct TreeNode {
 525    name: SharedString,
 526    path: Option<RepoPath>,
 527    children: BTreeMap<SharedString, TreeNode>,
 528    files: Vec<GitStatusEntry>,
 529}
 530
 531#[derive(Debug, PartialEq, Eq, Clone)]
 532pub struct GitStatusEntry {
 533    pub(crate) repo_path: RepoPath,
 534    pub(crate) status: FileStatus,
 535    pub(crate) staging: StageStatus,
 536}
 537
 538impl GitStatusEntry {
 539    fn display_name(&self, path_style: PathStyle) -> String {
 540        self.repo_path
 541            .file_name()
 542            .map(|name| name.to_owned())
 543            .unwrap_or_else(|| self.repo_path.display(path_style).to_string())
 544    }
 545
 546    fn parent_dir(&self, path_style: PathStyle) -> Option<String> {
 547        self.repo_path
 548            .parent()
 549            .map(|parent| parent.display(path_style).to_string())
 550    }
 551}
 552
 553struct TruncatedPatch {
 554    header: String,
 555    hunks: Vec<String>,
 556    hunks_to_keep: usize,
 557}
 558
 559impl TruncatedPatch {
 560    fn from_unified_diff(patch_str: &str) -> Option<Self> {
 561        let lines: Vec<&str> = patch_str.lines().collect();
 562        if lines.len() < 2 {
 563            return None;
 564        }
 565        let header = format!("{}\n{}\n", lines[0], lines[1]);
 566        let mut hunks = Vec::new();
 567        let mut current_hunk = String::new();
 568        for line in &lines[2..] {
 569            if line.starts_with("@@") {
 570                if !current_hunk.is_empty() {
 571                    hunks.push(current_hunk);
 572                }
 573                current_hunk = format!("{}\n", line);
 574            } else if !current_hunk.is_empty() {
 575                current_hunk.push_str(line);
 576                current_hunk.push('\n');
 577            }
 578        }
 579        if !current_hunk.is_empty() {
 580            hunks.push(current_hunk);
 581        }
 582        if hunks.is_empty() {
 583            return None;
 584        }
 585        let hunks_to_keep = hunks.len();
 586        Some(TruncatedPatch {
 587            header,
 588            hunks,
 589            hunks_to_keep,
 590        })
 591    }
 592    fn calculate_size(&self) -> usize {
 593        let mut size = self.header.len();
 594        for (i, hunk) in self.hunks.iter().enumerate() {
 595            if i < self.hunks_to_keep {
 596                size += hunk.len();
 597            }
 598        }
 599        size
 600    }
 601    fn to_string(&self) -> String {
 602        let mut out = self.header.clone();
 603        for (i, hunk) in self.hunks.iter().enumerate() {
 604            if i < self.hunks_to_keep {
 605                out.push_str(hunk);
 606            }
 607        }
 608        let skipped_hunks = self.hunks.len() - self.hunks_to_keep;
 609        if skipped_hunks > 0 {
 610            out.push_str(&format!("[...skipped {} hunks...]\n", skipped_hunks));
 611        }
 612        out
 613    }
 614}
 615
 616pub struct GitPanel {
 617    pub(crate) active_repository: Option<Entity<Repository>>,
 618    pub(crate) commit_editor: Entity<Editor>,
 619    conflicted_count: usize,
 620    conflicted_staged_count: usize,
 621    add_coauthors: bool,
 622    generate_commit_message_task: Option<Task<Option<()>>>,
 623    entries: Vec<GitListEntry>,
 624    view_mode: GitPanelViewMode,
 625    entries_indices: HashMap<RepoPath, usize>,
 626    single_staged_entry: Option<GitStatusEntry>,
 627    single_tracked_entry: Option<GitStatusEntry>,
 628    focus_handle: FocusHandle,
 629    fs: Arc<dyn Fs>,
 630    new_count: usize,
 631    entry_count: usize,
 632    changes_count: usize,
 633    new_staged_count: usize,
 634    pending_commit: Option<Task<()>>,
 635    amend_pending: bool,
 636    original_commit_message: Option<String>,
 637    signoff_enabled: bool,
 638    pending_serialization: Task<()>,
 639    pub(crate) project: Entity<Project>,
 640    scroll_handle: UniformListScrollHandle,
 641    max_width_item_index: Option<usize>,
 642    selected_entry: Option<usize>,
 643    marked_entries: Vec<usize>,
 644    tracked_count: usize,
 645    tracked_staged_count: usize,
 646    update_visible_entries_task: Task<()>,
 647    width: Option<Pixels>,
 648    pub(crate) workspace: WeakEntity<Workspace>,
 649    context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
 650    modal_open: bool,
 651    show_placeholders: bool,
 652    local_committer: Option<GitCommitter>,
 653    local_committer_task: Option<Task<()>>,
 654    bulk_staging: Option<BulkStaging>,
 655    stash_entries: GitStash,
 656    diff_stats: HashMap<RepoPath, DiffStat>,
 657    diff_stats_task: Task<()>,
 658    _settings_subscription: Subscription,
 659}
 660
 661#[derive(Clone, Debug, PartialEq, Eq)]
 662struct BulkStaging {
 663    repo_id: RepositoryId,
 664    anchor: RepoPath,
 665}
 666
 667const MAX_PANEL_EDITOR_LINES: usize = 6;
 668
 669pub(crate) fn commit_message_editor(
 670    commit_message_buffer: Entity<Buffer>,
 671    placeholder: Option<SharedString>,
 672    project: Entity<Project>,
 673    in_panel: bool,
 674    window: &mut Window,
 675    cx: &mut Context<Editor>,
 676) -> Editor {
 677    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
 678    let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
 679    let mut commit_editor = Editor::new(
 680        EditorMode::AutoHeight {
 681            min_lines: max_lines,
 682            max_lines: Some(max_lines),
 683        },
 684        buffer,
 685        None,
 686        window,
 687        cx,
 688    );
 689    commit_editor.set_collaboration_hub(Box::new(project));
 690    commit_editor.set_use_autoclose(false);
 691    commit_editor.set_show_gutter(false, cx);
 692    commit_editor.set_use_modal_editing(true);
 693    commit_editor.set_show_wrap_guides(false, cx);
 694    commit_editor.set_show_indent_guides(false, cx);
 695    let placeholder = placeholder.unwrap_or("Enter commit message".into());
 696    commit_editor.set_placeholder_text(&placeholder, window, cx);
 697    commit_editor
 698}
 699
 700impl GitPanel {
 701    fn new(
 702        workspace: &mut Workspace,
 703        window: &mut Window,
 704        cx: &mut Context<Workspace>,
 705    ) -> Entity<Self> {
 706        let project = workspace.project().clone();
 707        let app_state = workspace.app_state().clone();
 708        let fs = app_state.fs.clone();
 709        let git_store = project.read(cx).git_store().clone();
 710        let active_repository = project.read(cx).active_repository(cx);
 711
 712        cx.new(|cx| {
 713            let focus_handle = cx.focus_handle();
 714            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
 715
 716            let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 717            let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
 718            let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats;
 719            cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
 720                let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
 721                let tree_view = GitPanelSettings::get_global(cx).tree_view;
 722                let diff_stats = GitPanelSettings::get_global(cx).diff_stats;
 723                if tree_view != was_tree_view {
 724                    this.view_mode = GitPanelViewMode::from_settings(cx);
 725                }
 726                if sort_by_path != was_sort_by_path || tree_view != was_tree_view {
 727                    this.bulk_staging.take();
 728                    this.update_visible_entries(window, cx);
 729                }
 730                if diff_stats != was_diff_stats {
 731                    if diff_stats {
 732                        this.fetch_diff_stats(cx);
 733                    } else {
 734                        this.diff_stats.clear();
 735                        this.diff_stats_task = Task::ready(());
 736                        cx.notify();
 737                    }
 738                }
 739                was_sort_by_path = sort_by_path;
 740                was_tree_view = tree_view;
 741                was_diff_stats = diff_stats;
 742            })
 743            .detach();
 744
 745            // just to let us render a placeholder editor.
 746            // Once the active git repo is set, this buffer will be replaced.
 747            let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
 748            let commit_editor = cx.new(|cx| {
 749                commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
 750            });
 751
 752            commit_editor.update(cx, |editor, cx| {
 753                editor.clear(window, cx);
 754            });
 755
 756            let scroll_handle = UniformListScrollHandle::new();
 757
 758            let mut was_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
 759            let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
 760                let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
 761                if was_ai_enabled != is_ai_enabled {
 762                    was_ai_enabled = is_ai_enabled;
 763                    cx.notify();
 764                }
 765            });
 766
 767            cx.subscribe_in(
 768                &git_store,
 769                window,
 770                move |this, _git_store, event, window, cx| match event {
 771                    GitStoreEvent::RepositoryUpdated(
 772                        _,
 773                        RepositoryEvent::StatusesChanged | RepositoryEvent::BranchChanged,
 774                        true,
 775                    )
 776                    | GitStoreEvent::RepositoryAdded
 777                    | GitStoreEvent::RepositoryRemoved(_)
 778                    | GitStoreEvent::ActiveRepositoryChanged(_) => {
 779                        this.schedule_update(window, cx);
 780                    }
 781                    GitStoreEvent::IndexWriteError(error) => {
 782                        this.workspace
 783                            .update(cx, |workspace, cx| {
 784                                workspace.show_error(error, cx);
 785                            })
 786                            .ok();
 787                    }
 788                    GitStoreEvent::RepositoryUpdated(_, _, _) => {}
 789                    GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
 790                },
 791            )
 792            .detach();
 793
 794            let buffer_store = project.read(cx).buffer_store().clone();
 795
 796            for buffer in project.read(cx).opened_buffers(cx) {
 797                cx.subscribe(&buffer, |this, _buffer, event, cx| {
 798                    if matches!(event, BufferEvent::Saved) {
 799                        if GitPanelSettings::get_global(cx).diff_stats {
 800                            this.fetch_diff_stats(cx);
 801                        }
 802                    }
 803                })
 804                .detach();
 805            }
 806
 807            cx.subscribe(&buffer_store, |_this, _store, event, cx| {
 808                if let BufferStoreEvent::BufferAdded(buffer) = event {
 809                    cx.subscribe(buffer, |this, _buffer, event, cx| {
 810                        if matches!(event, BufferEvent::Saved) {
 811                            if GitPanelSettings::get_global(cx).diff_stats {
 812                                this.fetch_diff_stats(cx);
 813                            }
 814                        }
 815                    })
 816                    .detach();
 817                }
 818            })
 819            .detach();
 820
 821            let mut this = Self {
 822                active_repository,
 823                commit_editor,
 824                conflicted_count: 0,
 825                conflicted_staged_count: 0,
 826                add_coauthors: true,
 827                generate_commit_message_task: None,
 828                entries: Vec::new(),
 829                view_mode: GitPanelViewMode::from_settings(cx),
 830                entries_indices: HashMap::default(),
 831                focus_handle: cx.focus_handle(),
 832                fs,
 833                new_count: 0,
 834                new_staged_count: 0,
 835                changes_count: 0,
 836                pending_commit: None,
 837                amend_pending: false,
 838                original_commit_message: None,
 839                signoff_enabled: false,
 840                pending_serialization: Task::ready(()),
 841                single_staged_entry: None,
 842                single_tracked_entry: None,
 843                project,
 844                scroll_handle,
 845                max_width_item_index: None,
 846                selected_entry: None,
 847                marked_entries: Vec::new(),
 848                tracked_count: 0,
 849                tracked_staged_count: 0,
 850                update_visible_entries_task: Task::ready(()),
 851                width: None,
 852                show_placeholders: false,
 853                local_committer: None,
 854                local_committer_task: None,
 855                context_menu: None,
 856                workspace: workspace.weak_handle(),
 857                modal_open: false,
 858                entry_count: 0,
 859                bulk_staging: None,
 860                stash_entries: Default::default(),
 861                diff_stats: HashMap::default(),
 862                diff_stats_task: Task::ready(()),
 863                _settings_subscription,
 864            };
 865
 866            this.schedule_update(window, cx);
 867            this
 868        })
 869    }
 870
 871    pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
 872        self.entries_indices.get(path).copied()
 873    }
 874
 875    pub fn select_entry_by_path(
 876        &mut self,
 877        path: ProjectPath,
 878        window: &mut Window,
 879        cx: &mut Context<Self>,
 880    ) {
 881        let Some(git_repo) = self.active_repository.as_ref() else {
 882            return;
 883        };
 884
 885        let (repo_path, section) = {
 886            let repo = git_repo.read(cx);
 887            let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
 888                return;
 889            };
 890
 891            let section = repo
 892                .status_for_path(&repo_path)
 893                .map(|status| status.status)
 894                .map(|status| {
 895                    if repo.had_conflict_on_last_merge_head_change(&repo_path) {
 896                        Section::Conflict
 897                    } else if status.is_created() {
 898                        Section::New
 899                    } else {
 900                        Section::Tracked
 901                    }
 902                });
 903
 904            (repo_path, section)
 905        };
 906
 907        let mut needs_rebuild = false;
 908        if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
 909            let mut current_dir = repo_path.parent();
 910            while let Some(dir) = current_dir {
 911                let key = TreeKey {
 912                    section,
 913                    path: RepoPath::from_rel_path(dir),
 914                };
 915
 916                if tree_state.expanded_dirs.get(&key) == Some(&false) {
 917                    tree_state.expanded_dirs.insert(key, true);
 918                    needs_rebuild = true;
 919                }
 920
 921                current_dir = dir.parent();
 922            }
 923        }
 924
 925        if needs_rebuild {
 926            self.update_visible_entries(window, cx);
 927        }
 928
 929        let Some(ix) = self.entry_by_path(&repo_path) else {
 930            return;
 931        };
 932
 933        self.selected_entry = Some(ix);
 934        self.scroll_to_selected_entry(cx);
 935    }
 936
 937    fn serialization_key(workspace: &Workspace) -> Option<String> {
 938        workspace
 939            .database_id()
 940            .map(|id| i64::from(id).to_string())
 941            .or(workspace.session_id())
 942            .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
 943    }
 944
 945    fn serialize(&mut self, cx: &mut Context<Self>) {
 946        let width = self.width;
 947        let amend_pending = self.amend_pending;
 948        let signoff_enabled = self.signoff_enabled;
 949
 950        self.pending_serialization = cx.spawn(async move |git_panel, cx| {
 951            cx.background_executor()
 952                .timer(SERIALIZATION_THROTTLE_TIME)
 953                .await;
 954            let Some(serialization_key) = git_panel
 955                .update(cx, |git_panel, cx| {
 956                    git_panel
 957                        .workspace
 958                        .read_with(cx, |workspace, _| Self::serialization_key(workspace))
 959                        .ok()
 960                        .flatten()
 961                })
 962                .ok()
 963                .flatten()
 964            else {
 965                return;
 966            };
 967            cx.background_spawn(
 968                async move {
 969                    KEY_VALUE_STORE
 970                        .write_kvp(
 971                            serialization_key,
 972                            serde_json::to_string(&SerializedGitPanel {
 973                                width,
 974                                amend_pending,
 975                                signoff_enabled,
 976                            })?,
 977                        )
 978                        .await?;
 979                    anyhow::Ok(())
 980                }
 981                .log_err(),
 982            )
 983            .await;
 984        });
 985    }
 986
 987    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
 988        self.modal_open = open;
 989        cx.notify();
 990    }
 991
 992    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
 993        let mut dispatch_context = KeyContext::new_with_defaults();
 994        dispatch_context.add("GitPanel");
 995
 996        if window
 997            .focused(cx)
 998            .is_some_and(|focused| self.focus_handle == focused)
 999        {
1000            dispatch_context.add("menu");
1001            dispatch_context.add("ChangesList");
1002        }
1003
1004        if self.commit_editor.read(cx).is_focused(window) {
1005            dispatch_context.add("CommitEditor");
1006        }
1007
1008        dispatch_context
1009    }
1010
1011    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
1012        cx.emit(PanelEvent::Close);
1013    }
1014
1015    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1016        if !self.focus_handle.contains_focused(window, cx) {
1017            cx.emit(Event::Focus);
1018        }
1019    }
1020
1021    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
1022        let Some(selected_entry) = self.selected_entry else {
1023            cx.notify();
1024            return;
1025        };
1026
1027        let visible_index = match &self.view_mode {
1028            GitPanelViewMode::Flat => Some(selected_entry),
1029            GitPanelViewMode::Tree(state) => state
1030                .logical_indices
1031                .iter()
1032                .position(|&ix| ix == selected_entry),
1033        };
1034
1035        if let Some(visible_index) = visible_index {
1036            self.scroll_handle
1037                .scroll_to_item(visible_index, ScrollStrategy::Center);
1038        }
1039
1040        cx.notify();
1041    }
1042
1043    fn expand_selected_entry(
1044        &mut self,
1045        _: &ExpandSelectedEntry,
1046        window: &mut Window,
1047        cx: &mut Context<Self>,
1048    ) {
1049        let Some(entry) = self.get_selected_entry().cloned() else {
1050            return;
1051        };
1052
1053        if let GitListEntry::Directory(dir_entry) = entry {
1054            if dir_entry.expanded {
1055                self.select_next(&menu::SelectNext, window, cx);
1056            } else {
1057                self.toggle_directory(&dir_entry.key, window, cx);
1058            }
1059        } else {
1060            self.select_next(&menu::SelectNext, window, cx);
1061        }
1062    }
1063
1064    fn collapse_selected_entry(
1065        &mut self,
1066        _: &CollapseSelectedEntry,
1067        window: &mut Window,
1068        cx: &mut Context<Self>,
1069    ) {
1070        let Some(entry) = self.get_selected_entry().cloned() else {
1071            return;
1072        };
1073
1074        if let GitListEntry::Directory(dir_entry) = entry {
1075            if dir_entry.expanded {
1076                self.toggle_directory(&dir_entry.key, window, cx);
1077            } else {
1078                self.select_previous(&menu::SelectPrevious, window, cx);
1079            }
1080        } else {
1081            self.select_previous(&menu::SelectPrevious, window, cx);
1082        }
1083    }
1084
1085    fn select_first(
1086        &mut self,
1087        _: &menu::SelectFirst,
1088        _window: &mut Window,
1089        cx: &mut Context<Self>,
1090    ) {
1091        let first_entry = match &self.view_mode {
1092            GitPanelViewMode::Flat => self
1093                .entries
1094                .iter()
1095                .position(|entry| entry.status_entry().is_some()),
1096            GitPanelViewMode::Tree(state) => {
1097                let index = self.entries.iter().position(|entry| {
1098                    entry.status_entry().is_some() || entry.directory_entry().is_some()
1099                });
1100
1101                index.map(|index| state.logical_indices[index])
1102            }
1103        };
1104
1105        if let Some(first_entry) = first_entry {
1106            self.selected_entry = Some(first_entry);
1107            self.scroll_to_selected_entry(cx);
1108        }
1109    }
1110
1111    fn select_previous(
1112        &mut self,
1113        _: &menu::SelectPrevious,
1114        _window: &mut Window,
1115        cx: &mut Context<Self>,
1116    ) {
1117        let item_count = self.entries.len();
1118        if item_count == 0 {
1119            return;
1120        }
1121
1122        let Some(selected_entry) = self.selected_entry else {
1123            return;
1124        };
1125
1126        let new_index = match &self.view_mode {
1127            GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
1128            GitPanelViewMode::Tree(state) => {
1129                let Some(current_logical_index) = state
1130                    .logical_indices
1131                    .iter()
1132                    .position(|&i| i == selected_entry)
1133                else {
1134                    return;
1135                };
1136
1137                state.logical_indices[current_logical_index.saturating_sub(1)]
1138            }
1139        };
1140
1141        if selected_entry == 0 && new_index == 0 {
1142            return;
1143        }
1144
1145        if matches!(
1146            self.entries.get(new_index.saturating_sub(1)),
1147            Some(GitListEntry::Header(..))
1148        ) && new_index == 0
1149        {
1150            return;
1151        }
1152
1153        if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
1154            self.selected_entry = Some(new_index.saturating_sub(1));
1155        } else {
1156            self.selected_entry = Some(new_index);
1157        }
1158
1159        self.scroll_to_selected_entry(cx);
1160    }
1161
1162    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1163        let item_count = self.entries.len();
1164        if item_count == 0 {
1165            return;
1166        }
1167
1168        let Some(selected_entry) = self.selected_entry else {
1169            return;
1170        };
1171
1172        let new_index = match &self.view_mode {
1173            GitPanelViewMode::Flat => {
1174                if selected_entry >= item_count.saturating_sub(1) {
1175                    return;
1176                }
1177
1178                selected_entry.saturating_add(1)
1179            }
1180            GitPanelViewMode::Tree(state) => {
1181                let Some(current_logical_index) = state
1182                    .logical_indices
1183                    .iter()
1184                    .position(|&i| i == selected_entry)
1185                else {
1186                    return;
1187                };
1188
1189                let Some(new_index) = state
1190                    .logical_indices
1191                    .get(current_logical_index.saturating_add(1))
1192                    .copied()
1193                else {
1194                    return;
1195                };
1196
1197                new_index
1198            }
1199        };
1200
1201        if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
1202            self.selected_entry = Some(new_index.saturating_add(1));
1203        } else {
1204            self.selected_entry = Some(new_index);
1205        }
1206
1207        self.scroll_to_selected_entry(cx);
1208    }
1209
1210    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1211        if self.entries.last().is_some() {
1212            self.selected_entry = Some(self.entries.len() - 1);
1213            self.scroll_to_selected_entry(cx);
1214        }
1215    }
1216
1217    /// Show diff view at selected entry, only if the diff view is open
1218    fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1219        maybe!({
1220            let workspace = self.workspace.upgrade()?;
1221
1222            if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
1223                let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1224
1225                project_diff.update(cx, |project_diff, cx| {
1226                    project_diff.move_to_entry(entry.clone(), window, cx);
1227                });
1228            }
1229
1230            Some(())
1231        });
1232    }
1233
1234    fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
1235        self.select_first(&menu::SelectFirst, window, cx);
1236        self.move_diff_to_entry(window, cx);
1237    }
1238
1239    fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
1240        self.select_last(&menu::SelectLast, window, cx);
1241        self.move_diff_to_entry(window, cx);
1242    }
1243
1244    fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
1245        self.select_next(&menu::SelectNext, window, cx);
1246        self.move_diff_to_entry(window, cx);
1247    }
1248
1249    fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
1250        self.select_previous(&menu::SelectPrevious, window, cx);
1251        self.move_diff_to_entry(window, cx);
1252    }
1253
1254    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
1255        self.commit_editor.update(cx, |editor, cx| {
1256            window.focus(&editor.focus_handle(cx), cx);
1257        });
1258        cx.notify();
1259    }
1260
1261    fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1262        let have_entries = self
1263            .active_repository
1264            .as_ref()
1265            .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
1266        if have_entries && self.selected_entry.is_none() {
1267            self.select_first(&menu::SelectFirst, window, cx);
1268        }
1269    }
1270
1271    fn focus_changes_list(
1272        &mut self,
1273        _: &FocusChanges,
1274        window: &mut Window,
1275        cx: &mut Context<Self>,
1276    ) {
1277        self.focus_handle.focus(window, cx);
1278        self.select_first_entry_if_none(window, cx);
1279    }
1280
1281    fn get_selected_entry(&self) -> Option<&GitListEntry> {
1282        self.selected_entry.and_then(|i| self.entries.get(i))
1283    }
1284
1285    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1286        maybe!({
1287            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1288            let workspace = self.workspace.upgrade()?;
1289            let git_repo = self.active_repository.as_ref()?;
1290
1291            if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
1292                && let Some(project_path) = project_diff.read(cx).active_path(cx)
1293                && Some(&entry.repo_path)
1294                    == git_repo
1295                        .read(cx)
1296                        .project_path_to_repo_path(&project_path, cx)
1297                        .as_ref()
1298            {
1299                project_diff.focus_handle(cx).focus(window, cx);
1300                project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
1301                return None;
1302            };
1303
1304            self.workspace
1305                .update(cx, |workspace, cx| {
1306                    ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
1307                })
1308                .ok();
1309            self.focus_handle.focus(window, cx);
1310
1311            Some(())
1312        });
1313    }
1314
1315    fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context<Self>) {
1316        maybe!({
1317            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1318            let active_repo = self.active_repository.as_ref()?;
1319            let repo_path = entry.repo_path.clone();
1320            let git_store = self.project.read(cx).git_store();
1321
1322            FileHistoryView::open(
1323                repo_path,
1324                git_store.downgrade(),
1325                active_repo.downgrade(),
1326                self.workspace.clone(),
1327                window,
1328                cx,
1329            );
1330
1331            Some(())
1332        });
1333    }
1334
1335    fn open_file(
1336        &mut self,
1337        _: &menu::SecondaryConfirm,
1338        window: &mut Window,
1339        cx: &mut Context<Self>,
1340    ) {
1341        maybe!({
1342            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1343            let active_repo = self.active_repository.as_ref()?;
1344            let path = active_repo
1345                .read(cx)
1346                .repo_path_to_project_path(&entry.repo_path, cx)?;
1347            if entry.status.is_deleted() {
1348                return None;
1349            }
1350
1351            let open_task = self
1352                .workspace
1353                .update(cx, |workspace, cx| {
1354                    workspace.open_path_preview(path, None, false, false, true, window, cx)
1355                })
1356                .ok()?;
1357
1358            let workspace = self.workspace.clone();
1359            cx.spawn_in(window, async move |_, mut cx| {
1360                let item = open_task
1361                    .await
1362                    .notify_workspace_async_err(workspace, &mut cx)
1363                    .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
1364                if let Some(active_editor) = item.downcast::<Editor>() {
1365                    if let Some(diff_task) =
1366                        active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())
1367                    {
1368                        diff_task.await;
1369                    }
1370
1371                    cx.update(|window, cx| {
1372                        active_editor.update(cx, |editor, cx| {
1373                            editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
1374
1375                            let snapshot = editor.snapshot(window, cx);
1376                            editor.go_to_hunk_before_or_after_position(
1377                                &snapshot,
1378                                language::Point::new(0, 0),
1379                                Direction::Next,
1380                                window,
1381                                cx,
1382                            );
1383                        })
1384                    })
1385                    .log_err();
1386                }
1387
1388                anyhow::Ok(())
1389            })
1390            .detach();
1391
1392            Some(())
1393        });
1394    }
1395
1396    fn revert_selected(
1397        &mut self,
1398        action: &git::RestoreFile,
1399        window: &mut Window,
1400        cx: &mut Context<Self>,
1401    ) {
1402        let path_style = self.project.read(cx).path_style(cx);
1403        maybe!({
1404            let list_entry = self.entries.get(self.selected_entry?)?.clone();
1405            let entry = list_entry.status_entry()?.to_owned();
1406            let skip_prompt = action.skip_prompt || entry.status.is_created();
1407
1408            let prompt = if skip_prompt {
1409                Task::ready(Ok(0))
1410            } else {
1411                let prompt = window.prompt(
1412                    PromptLevel::Warning,
1413                    &format!(
1414                        "Are you sure you want to discard changes to {}?",
1415                        entry
1416                            .repo_path
1417                            .file_name()
1418                            .unwrap_or(entry.repo_path.display(path_style).as_ref()),
1419                    ),
1420                    None,
1421                    &["Discard Changes", "Cancel"],
1422                    cx,
1423                );
1424                cx.background_spawn(prompt)
1425            };
1426
1427            let this = cx.weak_entity();
1428            window
1429                .spawn(cx, async move |cx| {
1430                    if prompt.await? != 0 {
1431                        return anyhow::Ok(());
1432                    }
1433
1434                    this.update_in(cx, |this, window, cx| {
1435                        this.revert_entry(&entry, window, cx);
1436                    })?;
1437
1438                    Ok(())
1439                })
1440                .detach();
1441            Some(())
1442        });
1443    }
1444
1445    fn add_to_gitignore(
1446        &mut self,
1447        _: &git::AddToGitignore,
1448        _window: &mut Window,
1449        cx: &mut Context<Self>,
1450    ) {
1451        maybe!({
1452            let list_entry = self.entries.get(self.selected_entry?)?.clone();
1453            let entry = list_entry.status_entry()?.to_owned();
1454
1455            if !entry.status.is_created() {
1456                return Some(());
1457            }
1458
1459            let project = self.project.downgrade();
1460            let repo_path = entry.repo_path;
1461            let active_repository = self.active_repository.as_ref()?.downgrade();
1462
1463            cx.spawn(async move |_, cx| {
1464                let file_path_str = repo_path.as_ref().display(PathStyle::Posix);
1465
1466                let repo_root = active_repository.read_with(cx, |repository, _| {
1467                    repository.snapshot().work_directory_abs_path
1468                })?;
1469
1470                let gitignore_abs_path = repo_root.join(".gitignore");
1471
1472                let buffer: Entity<Buffer> = project
1473                    .update(cx, |project, cx| {
1474                        project.open_local_buffer(gitignore_abs_path, cx)
1475                    })?
1476                    .await?;
1477
1478                let mut should_save = false;
1479                buffer.update(cx, |buffer, cx| {
1480                    let existing_content = buffer.text();
1481
1482                    if existing_content
1483                        .lines()
1484                        .any(|line: &str| line.trim() == file_path_str)
1485                    {
1486                        return;
1487                    }
1488
1489                    let insert_position = existing_content.len();
1490                    let new_entry = if existing_content.is_empty() {
1491                        format!("{}\n", file_path_str)
1492                    } else if existing_content.ends_with('\n') {
1493                        format!("{}\n", file_path_str)
1494                    } else {
1495                        format!("\n{}\n", file_path_str)
1496                    };
1497
1498                    buffer.edit([(insert_position..insert_position, new_entry)], None, cx);
1499                    should_save = true;
1500                });
1501
1502                if should_save {
1503                    project
1504                        .update(cx, |project, cx| project.save_buffer(buffer, cx))?
1505                        .await?;
1506                }
1507
1508                anyhow::Ok(())
1509            })
1510            .detach_and_log_err(cx);
1511
1512            Some(())
1513        });
1514    }
1515
1516    fn revert_entry(
1517        &mut self,
1518        entry: &GitStatusEntry,
1519        window: &mut Window,
1520        cx: &mut Context<Self>,
1521    ) {
1522        maybe!({
1523            let active_repo = self.active_repository.clone()?;
1524            let path = active_repo
1525                .read(cx)
1526                .repo_path_to_project_path(&entry.repo_path, cx)?;
1527            let workspace = self.workspace.clone();
1528
1529            if entry.status.staging().has_staged() {
1530                self.change_file_stage(false, vec![entry.clone()], cx);
1531            }
1532            let filename = path.path.file_name()?.to_string();
1533
1534            if !entry.status.is_created() {
1535                self.perform_checkout(vec![entry.clone()], window, cx);
1536            } else {
1537                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
1538                cx.spawn_in(window, async move |_, cx| {
1539                    match prompt.await? {
1540                        TrashCancel::Trash => {}
1541                        TrashCancel::Cancel => return Ok(()),
1542                    }
1543                    let task = workspace.update(cx, |workspace, cx| {
1544                        workspace
1545                            .project()
1546                            .update(cx, |project, cx| project.delete_file(path, true, cx))
1547                    })?;
1548                    if let Some(task) = task {
1549                        task.await?;
1550                    }
1551                    Ok(())
1552                })
1553                .detach_and_prompt_err(
1554                    "Failed to trash file",
1555                    window,
1556                    cx,
1557                    |e, _, _| Some(format!("{e}")),
1558                );
1559            }
1560            Some(())
1561        });
1562    }
1563
1564    fn perform_checkout(
1565        &mut self,
1566        entries: Vec<GitStatusEntry>,
1567        window: &mut Window,
1568        cx: &mut Context<Self>,
1569    ) {
1570        let workspace = self.workspace.clone();
1571        let Some(active_repository) = self.active_repository.clone() else {
1572            return;
1573        };
1574
1575        let task = cx.spawn_in(window, async move |this, cx| {
1576            let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
1577                workspace.project().update(cx, |project, cx| {
1578                    entries
1579                        .iter()
1580                        .filter_map(|entry| {
1581                            let path = active_repository
1582                                .read(cx)
1583                                .repo_path_to_project_path(&entry.repo_path, cx)?;
1584                            Some(project.open_buffer(path, cx))
1585                        })
1586                        .collect()
1587                })
1588            })?;
1589
1590            let buffers = futures::future::join_all(tasks).await;
1591
1592            this.update_in(cx, |this, window, cx| {
1593                let task = active_repository.update(cx, |repo, cx| {
1594                    repo.checkout_files(
1595                        "HEAD",
1596                        entries
1597                            .into_iter()
1598                            .map(|entries| entries.repo_path)
1599                            .collect(),
1600                        cx,
1601                    )
1602                });
1603                this.update_visible_entries(window, cx);
1604                cx.notify();
1605                task
1606            })?
1607            .await?;
1608
1609            let tasks: Vec<_> = cx.update(|_, cx| {
1610                buffers
1611                    .iter()
1612                    .filter_map(|buffer| {
1613                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
1614                            buffer.is_dirty().then(|| buffer.reload(cx))
1615                        })
1616                    })
1617                    .collect()
1618            })?;
1619
1620            futures::future::join_all(tasks).await;
1621
1622            Ok(())
1623        });
1624
1625        cx.spawn_in(window, async move |this, cx| {
1626            let result = task.await;
1627
1628            this.update_in(cx, |this, window, cx| {
1629                if let Err(err) = result {
1630                    this.update_visible_entries(window, cx);
1631                    this.show_error_toast("checkout", err, cx);
1632                }
1633            })
1634            .ok();
1635        })
1636        .detach();
1637    }
1638
1639    fn restore_tracked_files(
1640        &mut self,
1641        _: &RestoreTrackedFiles,
1642        window: &mut Window,
1643        cx: &mut Context<Self>,
1644    ) {
1645        let entries = self
1646            .entries
1647            .iter()
1648            .filter_map(|entry| entry.status_entry().cloned())
1649            .filter(|status_entry| !status_entry.status.is_created())
1650            .collect::<Vec<_>>();
1651
1652        match entries.len() {
1653            0 => return,
1654            1 => return self.revert_entry(&entries[0], window, cx),
1655            _ => {}
1656        }
1657        let mut details = entries
1658            .iter()
1659            .filter_map(|entry| entry.repo_path.as_ref().file_name())
1660            .map(|filename| filename.to_string())
1661            .take(5)
1662            .join("\n");
1663        if entries.len() > 5 {
1664            details.push_str(&format!("\nand {} more…", entries.len() - 5))
1665        }
1666
1667        #[derive(strum::EnumIter, strum::VariantNames)]
1668        #[strum(serialize_all = "title_case")]
1669        enum RestoreCancel {
1670            RestoreTrackedFiles,
1671            Cancel,
1672        }
1673        let prompt = prompt(
1674            "Discard changes to these files?",
1675            Some(&details),
1676            window,
1677            cx,
1678        );
1679        cx.spawn_in(window, async move |this, cx| {
1680            if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
1681                this.update_in(cx, |this, window, cx| {
1682                    this.perform_checkout(entries, window, cx);
1683                })
1684                .ok();
1685            }
1686        })
1687        .detach();
1688    }
1689
1690    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
1691        let workspace = self.workspace.clone();
1692        let Some(active_repo) = self.active_repository.clone() else {
1693            return;
1694        };
1695        let to_delete = self
1696            .entries
1697            .iter()
1698            .filter_map(|entry| entry.status_entry())
1699            .filter(|status_entry| status_entry.status.is_created())
1700            .cloned()
1701            .collect::<Vec<_>>();
1702
1703        match to_delete.len() {
1704            0 => return,
1705            1 => return self.revert_entry(&to_delete[0], window, cx),
1706            _ => {}
1707        };
1708
1709        let mut details = to_delete
1710            .iter()
1711            .map(|entry| {
1712                entry
1713                    .repo_path
1714                    .as_ref()
1715                    .file_name()
1716                    .map(|f| f.to_string())
1717                    .unwrap_or_default()
1718            })
1719            .take(5)
1720            .join("\n");
1721
1722        if to_delete.len() > 5 {
1723            details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
1724        }
1725
1726        let prompt = prompt("Trash these files?", Some(&details), window, cx);
1727        cx.spawn_in(window, async move |this, cx| {
1728            match prompt.await? {
1729                TrashCancel::Trash => {}
1730                TrashCancel::Cancel => return Ok(()),
1731            }
1732            let tasks = workspace.update(cx, |workspace, cx| {
1733                to_delete
1734                    .iter()
1735                    .filter_map(|entry| {
1736                        workspace.project().update(cx, |project, cx| {
1737                            let project_path = active_repo
1738                                .read(cx)
1739                                .repo_path_to_project_path(&entry.repo_path, cx)?;
1740                            project.delete_file(project_path, true, cx)
1741                        })
1742                    })
1743                    .collect::<Vec<_>>()
1744            })?;
1745            let to_unstage = to_delete
1746                .into_iter()
1747                .filter(|entry| !entry.status.staging().is_fully_unstaged())
1748                .collect();
1749            this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
1750            for task in tasks {
1751                task.await?;
1752            }
1753            Ok(())
1754        })
1755        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1756            Some(format!("{e}"))
1757        });
1758    }
1759
1760    fn change_all_files_stage(&mut self, stage: bool, cx: &mut Context<Self>) {
1761        let Some(active_repository) = self.active_repository.clone() else {
1762            return;
1763        };
1764        cx.spawn({
1765            async move |this, cx| {
1766                let result = this
1767                    .update(cx, |this, cx| {
1768                        let task = active_repository.update(cx, |repo, cx| {
1769                            if stage {
1770                                repo.stage_all(cx)
1771                            } else {
1772                                repo.unstage_all(cx)
1773                            }
1774                        });
1775                        this.update_counts(active_repository.read(cx));
1776                        cx.notify();
1777                        task
1778                    })?
1779                    .await;
1780
1781                this.update(cx, |this, cx| {
1782                    if let Err(err) = result {
1783                        this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
1784                    }
1785                    cx.notify()
1786                })
1787            }
1788        })
1789        .detach();
1790    }
1791
1792    fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus {
1793        // Checking for current staged/unstaged file status is a chained operation:
1794        // 1. first, we check for any pending operation recorded in repository
1795        // 2. if there are no pending ops either running or finished, we then ask the repository
1796        //    for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry`
1797        //    is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on
1798        //    the checkbox's state (or flickering) which is undesirable.
1799        // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded
1800        //    in `entry` arg.
1801        repo.pending_ops_for_path(&entry.repo_path)
1802            .map(|ops| {
1803                if ops.staging() || ops.staged() {
1804                    StageStatus::Staged
1805                } else {
1806                    StageStatus::Unstaged
1807                }
1808            })
1809            .or_else(|| {
1810                repo.status_for_path(&entry.repo_path)
1811                    .map(|status| status.status.staging())
1812            })
1813            .unwrap_or(entry.staging)
1814    }
1815
1816    fn stage_status_for_directory(
1817        &self,
1818        entry: &GitTreeDirEntry,
1819        repo: &Repository,
1820    ) -> StageStatus {
1821        let GitPanelViewMode::Tree(tree_state) = &self.view_mode else {
1822            util::debug_panic!("We should never render a directory entry while in flat view mode");
1823            return StageStatus::Unstaged;
1824        };
1825
1826        let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else {
1827            return StageStatus::Unstaged;
1828        };
1829
1830        let show_placeholders = self.show_placeholders && !self.has_staged_changes();
1831        let mut fully_staged_count = 0usize;
1832        let mut any_staged_or_partially_staged = false;
1833
1834        for descendant in descendants {
1835            if show_placeholders && !descendant.status.is_created() {
1836                fully_staged_count += 1;
1837                any_staged_or_partially_staged = true;
1838            } else {
1839                match GitPanel::stage_status_for_entry(descendant, repo) {
1840                    StageStatus::Staged => {
1841                        fully_staged_count += 1;
1842                        any_staged_or_partially_staged = true;
1843                    }
1844                    StageStatus::PartiallyStaged => {
1845                        any_staged_or_partially_staged = true;
1846                    }
1847                    StageStatus::Unstaged => {}
1848                }
1849            }
1850        }
1851
1852        if descendants.is_empty() {
1853            StageStatus::Unstaged
1854        } else if fully_staged_count == descendants.len() {
1855            StageStatus::Staged
1856        } else if any_staged_or_partially_staged {
1857            StageStatus::PartiallyStaged
1858        } else {
1859            StageStatus::Unstaged
1860        }
1861    }
1862
1863    pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1864        self.change_all_files_stage(true, cx);
1865    }
1866
1867    pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1868        self.change_all_files_stage(false, cx);
1869    }
1870
1871    fn toggle_staged_for_entry(
1872        &mut self,
1873        entry: &GitListEntry,
1874        _window: &mut Window,
1875        cx: &mut Context<Self>,
1876    ) {
1877        let Some(active_repository) = self.active_repository.clone() else {
1878            return;
1879        };
1880        let mut set_anchor: Option<RepoPath> = None;
1881        let mut clear_anchor = None;
1882
1883        let (stage, repo_paths) = {
1884            let repo = active_repository.read(cx);
1885            match entry {
1886                GitListEntry::Status(status_entry) => {
1887                    let repo_paths = vec![status_entry.clone()];
1888                    let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) {
1889                        StageStatus::Staged => {
1890                            if let Some(op) = self.bulk_staging.clone()
1891                                && op.anchor == status_entry.repo_path
1892                            {
1893                                clear_anchor = Some(op.anchor);
1894                            }
1895                            false
1896                        }
1897                        StageStatus::Unstaged | StageStatus::PartiallyStaged => {
1898                            set_anchor = Some(status_entry.repo_path.clone());
1899                            true
1900                        }
1901                    };
1902                    (stage, repo_paths)
1903                }
1904                GitListEntry::TreeStatus(status_entry) => {
1905                    let repo_paths = vec![status_entry.entry.clone()];
1906                    let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) {
1907                        StageStatus::Staged => {
1908                            if let Some(op) = self.bulk_staging.clone()
1909                                && op.anchor == status_entry.entry.repo_path
1910                            {
1911                                clear_anchor = Some(op.anchor);
1912                            }
1913                            false
1914                        }
1915                        StageStatus::Unstaged | StageStatus::PartiallyStaged => {
1916                            set_anchor = Some(status_entry.entry.repo_path.clone());
1917                            true
1918                        }
1919                    };
1920                    (stage, repo_paths)
1921                }
1922                GitListEntry::Header(section) => {
1923                    let goal_staged_state = !self.header_state(section.header).selected();
1924                    let entries = self
1925                        .entries
1926                        .iter()
1927                        .filter_map(|entry| entry.status_entry())
1928                        .filter(|status_entry| {
1929                            section.contains(status_entry, &repo)
1930                                && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool()
1931                                    != Some(goal_staged_state)
1932                        })
1933                        .cloned()
1934                        .collect::<Vec<_>>();
1935
1936                    (goal_staged_state, entries)
1937                }
1938                GitListEntry::Directory(entry) => {
1939                    let goal_staged_state = match self.stage_status_for_directory(entry, repo) {
1940                        StageStatus::Staged => StageStatus::Unstaged,
1941                        StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged,
1942                    };
1943                    let goal_stage = goal_staged_state == StageStatus::Staged;
1944
1945                    let entries = self
1946                        .view_mode
1947                        .tree_state()
1948                        .and_then(|state| state.directory_descendants.get(&entry.key))
1949                        .cloned()
1950                        .unwrap_or_default()
1951                        .into_iter()
1952                        .filter(|status_entry| {
1953                            GitPanel::stage_status_for_entry(status_entry, &repo)
1954                                != goal_staged_state
1955                        })
1956                        .collect::<Vec<_>>();
1957                    (goal_stage, entries)
1958                }
1959            }
1960        };
1961        if let Some(anchor) = clear_anchor {
1962            if let Some(op) = self.bulk_staging.clone()
1963                && op.anchor == anchor
1964            {
1965                self.bulk_staging = None;
1966            }
1967        }
1968        if let Some(anchor) = set_anchor {
1969            self.set_bulk_staging_anchor(anchor, cx);
1970        }
1971
1972        self.change_file_stage(stage, repo_paths, cx);
1973    }
1974
1975    fn change_file_stage(
1976        &mut self,
1977        stage: bool,
1978        entries: Vec<GitStatusEntry>,
1979        cx: &mut Context<Self>,
1980    ) {
1981        let Some(active_repository) = self.active_repository.clone() else {
1982            return;
1983        };
1984        cx.spawn({
1985            async move |this, cx| {
1986                let result = this
1987                    .update(cx, |this, cx| {
1988                        let task = active_repository.update(cx, |repo, cx| {
1989                            let repo_paths = entries
1990                                .iter()
1991                                .map(|entry| entry.repo_path.clone())
1992                                .collect();
1993                            if stage {
1994                                repo.stage_entries(repo_paths, cx)
1995                            } else {
1996                                repo.unstage_entries(repo_paths, cx)
1997                            }
1998                        });
1999                        this.update_counts(active_repository.read(cx));
2000                        cx.notify();
2001                        task
2002                    })?
2003                    .await;
2004
2005                this.update(cx, |this, cx| {
2006                    if let Err(err) = result {
2007                        this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
2008                    }
2009                    cx.notify();
2010                })
2011            }
2012        })
2013        .detach();
2014    }
2015
2016    pub fn total_staged_count(&self) -> usize {
2017        self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
2018    }
2019
2020    pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
2021        let Some(active_repository) = self.active_repository.clone() else {
2022            return;
2023        };
2024
2025        cx.spawn({
2026            async move |this, cx| {
2027                let stash_task = active_repository
2028                    .update(cx, |repo, cx| repo.stash_pop(None, cx))
2029                    .await;
2030                this.update(cx, |this, cx| {
2031                    stash_task
2032                        .map_err(|e| {
2033                            this.show_error_toast("stash pop", e, cx);
2034                        })
2035                        .ok();
2036                    cx.notify();
2037                })
2038            }
2039        })
2040        .detach();
2041    }
2042
2043    pub fn stash_apply(&mut self, _: &StashApply, _window: &mut Window, cx: &mut Context<Self>) {
2044        let Some(active_repository) = self.active_repository.clone() else {
2045            return;
2046        };
2047
2048        cx.spawn({
2049            async move |this, cx| {
2050                let stash_task = active_repository
2051                    .update(cx, |repo, cx| repo.stash_apply(None, cx))
2052                    .await;
2053                this.update(cx, |this, cx| {
2054                    stash_task
2055                        .map_err(|e| {
2056                            this.show_error_toast("stash apply", e, cx);
2057                        })
2058                        .ok();
2059                    cx.notify();
2060                })
2061            }
2062        })
2063        .detach();
2064    }
2065
2066    pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
2067        let Some(active_repository) = self.active_repository.clone() else {
2068            return;
2069        };
2070
2071        cx.spawn({
2072            async move |this, cx| {
2073                let stash_task = active_repository
2074                    .update(cx, |repo, cx| repo.stash_all(cx))
2075                    .await;
2076                this.update(cx, |this, cx| {
2077                    stash_task
2078                        .map_err(|e| {
2079                            this.show_error_toast("stash", e, cx);
2080                        })
2081                        .ok();
2082                    cx.notify();
2083                })
2084            }
2085        })
2086        .detach();
2087    }
2088
2089    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
2090        self.commit_editor
2091            .read(cx)
2092            .buffer()
2093            .read(cx)
2094            .as_singleton()
2095            .unwrap()
2096    }
2097
2098    fn toggle_staged_for_selected(
2099        &mut self,
2100        _: &git::ToggleStaged,
2101        window: &mut Window,
2102        cx: &mut Context<Self>,
2103    ) {
2104        if let Some(selected_entry) = self.get_selected_entry().cloned() {
2105            self.toggle_staged_for_entry(&selected_entry, window, cx);
2106        }
2107    }
2108
2109    fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
2110        let Some(index) = self.selected_entry else {
2111            return;
2112        };
2113        self.stage_bulk(index, cx);
2114    }
2115
2116    fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
2117        let Some(selected_entry) = self.get_selected_entry() else {
2118            return;
2119        };
2120        let Some(status_entry) = selected_entry.status_entry() else {
2121            return;
2122        };
2123        if status_entry.staging != StageStatus::Staged {
2124            self.change_file_stage(true, vec![status_entry.clone()], cx);
2125        }
2126    }
2127
2128    fn unstage_selected(
2129        &mut self,
2130        _: &git::UnstageFile,
2131        _window: &mut Window,
2132        cx: &mut Context<Self>,
2133    ) {
2134        let Some(selected_entry) = self.get_selected_entry() else {
2135            return;
2136        };
2137        let Some(status_entry) = selected_entry.status_entry() else {
2138            return;
2139        };
2140        if status_entry.staging != StageStatus::Unstaged {
2141            self.change_file_stage(false, vec![status_entry.clone()], cx);
2142        }
2143    }
2144
2145    fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
2146        if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
2147            telemetry::event!("Git Committed", source = "Git Panel");
2148        }
2149    }
2150
2151    /// Commits staged changes with the current commit message.
2152    ///
2153    /// Returns `true` if the commit was executed, `false` otherwise.
2154    pub(crate) fn commit(
2155        &mut self,
2156        commit_editor_focus_handle: &FocusHandle,
2157        window: &mut Window,
2158        cx: &mut Context<Self>,
2159    ) -> bool {
2160        if self.amend_pending {
2161            return false;
2162        }
2163
2164        if commit_editor_focus_handle.contains_focused(window, cx) {
2165            self.commit_changes(
2166                CommitOptions {
2167                    amend: false,
2168                    signoff: self.signoff_enabled,
2169                },
2170                window,
2171                cx,
2172            );
2173            true
2174        } else {
2175            cx.propagate();
2176            false
2177        }
2178    }
2179
2180    fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
2181        if self.amend(&self.commit_editor.focus_handle(cx), window, cx) {
2182            telemetry::event!("Git Amended", source = "Git Panel");
2183        }
2184    }
2185
2186    /// Amends the most recent commit with staged changes and/or an updated commit message.
2187    ///
2188    /// Uses a two-stage workflow where the first invocation loads the commit
2189    /// message for editing, second invocation performs the amend. Returns
2190    /// `true` if the amend was executed, `false` otherwise.
2191    pub(crate) fn amend(
2192        &mut self,
2193        commit_editor_focus_handle: &FocusHandle,
2194        window: &mut Window,
2195        cx: &mut Context<Self>,
2196    ) -> bool {
2197        if commit_editor_focus_handle.contains_focused(window, cx) {
2198            if self.head_commit(cx).is_some() {
2199                if !self.amend_pending {
2200                    self.set_amend_pending(true, cx);
2201                    self.load_last_commit_message(cx);
2202
2203                    return false;
2204                } else {
2205                    self.commit_changes(
2206                        CommitOptions {
2207                            amend: true,
2208                            signoff: self.signoff_enabled,
2209                        },
2210                        window,
2211                        cx,
2212                    );
2213
2214                    return true;
2215                }
2216            }
2217            return false;
2218        } else {
2219            cx.propagate();
2220            return false;
2221        }
2222    }
2223    pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
2224        self.active_repository
2225            .as_ref()
2226            .and_then(|repo| repo.read(cx).head_commit.as_ref())
2227            .cloned()
2228    }
2229
2230    pub fn load_last_commit_message(&mut self, cx: &mut Context<Self>) {
2231        let Some(head_commit) = self.head_commit(cx) else {
2232            return;
2233        };
2234
2235        let recent_sha = head_commit.sha.to_string();
2236        let detail_task = self.load_commit_details(recent_sha, cx);
2237        cx.spawn(async move |this, cx| {
2238            if let Ok(message) = detail_task.await.map(|detail| detail.message) {
2239                this.update(cx, |this, cx| {
2240                    this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2241                        let start = buffer.anchor_before(0);
2242                        let end = buffer.anchor_after(buffer.len());
2243                        buffer.edit([(start..end, message)], None, cx);
2244                    });
2245                })
2246                .log_err();
2247            }
2248        })
2249        .detach();
2250    }
2251
2252    fn custom_or_suggested_commit_message(
2253        &self,
2254        window: &mut Window,
2255        cx: &mut Context<Self>,
2256    ) -> Option<String> {
2257        let git_commit_language = self
2258            .commit_editor
2259            .read(cx)
2260            .language_at(MultiBufferOffset(0), cx);
2261        let message = self.commit_editor.read(cx).text(cx);
2262        if message.is_empty() {
2263            return self
2264                .suggest_commit_message(cx)
2265                .filter(|message| !message.trim().is_empty());
2266        } else if message.trim().is_empty() {
2267            return None;
2268        }
2269        let buffer = cx.new(|cx| {
2270            let mut buffer = Buffer::local(message, cx);
2271            buffer.set_language(git_commit_language, cx);
2272            buffer
2273        });
2274        let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
2275        let wrapped_message = editor.update(cx, |editor, cx| {
2276            editor.select_all(&Default::default(), window, cx);
2277            editor.rewrap_impl(
2278                RewrapOptions {
2279                    override_language_settings: false,
2280                    preserve_existing_whitespace: true,
2281                },
2282                cx,
2283            );
2284            editor.text(cx)
2285        });
2286        if wrapped_message.trim().is_empty() {
2287            return None;
2288        }
2289        Some(wrapped_message)
2290    }
2291
2292    fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
2293        let text = self.commit_editor.read(cx).text(cx);
2294        if !text.trim().is_empty() {
2295            true
2296        } else if text.is_empty() {
2297            self.suggest_commit_message(cx)
2298                .is_some_and(|text| !text.trim().is_empty())
2299        } else {
2300            false
2301        }
2302    }
2303
2304    pub(crate) fn commit_changes(
2305        &mut self,
2306        options: CommitOptions,
2307        window: &mut Window,
2308        cx: &mut Context<Self>,
2309    ) {
2310        let Some(active_repository) = self.active_repository.clone() else {
2311            return;
2312        };
2313        let error_spawn = |message, window: &mut Window, cx: &mut App| {
2314            let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
2315            cx.spawn(async move |_| {
2316                prompt.await.ok();
2317            })
2318            .detach();
2319        };
2320
2321        if self.has_unstaged_conflicts() {
2322            error_spawn(
2323                "There are still conflicts. You must stage these before committing",
2324                window,
2325                cx,
2326            );
2327            return;
2328        }
2329
2330        let askpass = self.askpass_delegate("git commit", window, cx);
2331        let commit_message = self.custom_or_suggested_commit_message(window, cx);
2332
2333        let Some(mut message) = commit_message else {
2334            self.commit_editor
2335                .read(cx)
2336                .focus_handle(cx)
2337                .focus(window, cx);
2338            return;
2339        };
2340
2341        if self.add_coauthors {
2342            self.fill_co_authors(&mut message, cx);
2343        }
2344
2345        let task = if self.has_staged_changes() {
2346            // Repository serializes all git operations, so we can just send a commit immediately
2347            let commit_task = active_repository.update(cx, |repo, cx| {
2348                repo.commit(message.into(), None, options, askpass, cx)
2349            });
2350            cx.background_spawn(async move { commit_task.await? })
2351        } else {
2352            let changed_files = self
2353                .entries
2354                .iter()
2355                .filter_map(|entry| entry.status_entry())
2356                .filter(|status_entry| !status_entry.status.is_created())
2357                .map(|status_entry| status_entry.repo_path.clone())
2358                .collect::<Vec<_>>();
2359
2360            if changed_files.is_empty() && !options.amend {
2361                error_spawn("No changes to commit", window, cx);
2362                return;
2363            }
2364
2365            let stage_task =
2366                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
2367            cx.spawn(async move |_, cx| {
2368                stage_task.await?;
2369                let commit_task = active_repository.update(cx, |repo, cx| {
2370                    repo.commit(message.into(), None, options, askpass, cx)
2371                });
2372                commit_task.await?
2373            })
2374        };
2375        let task = cx.spawn_in(window, async move |this, cx| {
2376            let result = task.await;
2377            this.update_in(cx, |this, window, cx| {
2378                this.pending_commit.take();
2379
2380                match result {
2381                    Ok(()) => {
2382                        if options.amend {
2383                            this.set_amend_pending(false, cx);
2384                        } else {
2385                            this.commit_editor
2386                                .update(cx, |editor, cx| editor.clear(window, cx));
2387                            this.original_commit_message = None;
2388                        }
2389                    }
2390                    Err(e) => this.show_error_toast("commit", e, cx),
2391                }
2392            })
2393            .ok();
2394        });
2395
2396        self.pending_commit = Some(task);
2397    }
2398
2399    pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2400        let Some(repo) = self.active_repository.clone() else {
2401            return;
2402        };
2403        telemetry::event!("Git Uncommitted");
2404
2405        let confirmation = self.check_for_pushed_commits(window, cx);
2406        let prior_head = self.load_commit_details("HEAD".to_string(), cx);
2407
2408        let task = cx.spawn_in(window, async move |this, cx| {
2409            let result = maybe!(async {
2410                if let Ok(true) = confirmation.await {
2411                    let prior_head = prior_head.await?;
2412
2413                    repo.update(cx, |repo, cx| {
2414                        repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
2415                    })
2416                    .await??;
2417
2418                    Ok(Some(prior_head))
2419                } else {
2420                    Ok(None)
2421                }
2422            })
2423            .await;
2424
2425            this.update_in(cx, |this, window, cx| {
2426                this.pending_commit.take();
2427                match result {
2428                    Ok(None) => {}
2429                    Ok(Some(prior_commit)) => {
2430                        this.commit_editor.update(cx, |editor, cx| {
2431                            editor.set_text(prior_commit.message, window, cx)
2432                        });
2433                    }
2434                    Err(e) => this.show_error_toast("reset", e, cx),
2435                }
2436            })
2437            .ok();
2438        });
2439
2440        self.pending_commit = Some(task);
2441    }
2442
2443    fn check_for_pushed_commits(
2444        &mut self,
2445        window: &mut Window,
2446        cx: &mut Context<Self>,
2447    ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
2448        let repo = self.active_repository.clone();
2449        let mut cx = window.to_async(cx);
2450
2451        async move {
2452            let repo = repo.context("No active repository")?;
2453
2454            let pushed_to: Vec<SharedString> = repo
2455                .update(&mut cx, |repo, _| repo.check_for_pushed_commits())
2456                .await??;
2457
2458            if pushed_to.is_empty() {
2459                Ok(true)
2460            } else {
2461                #[derive(strum::EnumIter, strum::VariantNames)]
2462                #[strum(serialize_all = "title_case")]
2463                enum CancelUncommit {
2464                    Uncommit,
2465                    Cancel,
2466                }
2467                let detail = format!(
2468                    "This commit was already pushed to {}.",
2469                    pushed_to.into_iter().join(", ")
2470                );
2471                let result = cx
2472                    .update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
2473                    .await?;
2474
2475                match result {
2476                    CancelUncommit::Cancel => Ok(false),
2477                    CancelUncommit::Uncommit => Ok(true),
2478                }
2479            }
2480        }
2481    }
2482
2483    /// Suggests a commit message based on the changed files and their statuses
2484    pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
2485        if let Some(merge_message) = self
2486            .active_repository
2487            .as_ref()
2488            .and_then(|repo| repo.read(cx).merge.message.as_ref())
2489        {
2490            return Some(merge_message.to_string());
2491        }
2492
2493        let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
2494            Some(staged_entry)
2495        } else if self.total_staged_count() == 0
2496            && let Some(single_tracked_entry) = &self.single_tracked_entry
2497        {
2498            Some(single_tracked_entry)
2499        } else {
2500            None
2501        }?;
2502
2503        let action_text = if git_status_entry.status.is_deleted() {
2504            Some("Delete")
2505        } else if git_status_entry.status.is_created() {
2506            Some("Create")
2507        } else if git_status_entry.status.is_modified() {
2508            Some("Update")
2509        } else {
2510            None
2511        }?;
2512
2513        let file_name = git_status_entry
2514            .repo_path
2515            .file_name()
2516            .unwrap_or_default()
2517            .to_string();
2518
2519        Some(format!("{} {}", action_text, file_name))
2520    }
2521
2522    fn generate_commit_message_action(
2523        &mut self,
2524        _: &git::GenerateCommitMessage,
2525        _window: &mut Window,
2526        cx: &mut Context<Self>,
2527    ) {
2528        self.generate_commit_message(cx);
2529    }
2530
2531    fn split_patch(patch: &str) -> Vec<String> {
2532        let mut result = Vec::new();
2533        let mut current_patch = String::new();
2534
2535        for line in patch.lines() {
2536            if line.starts_with("---") && !current_patch.is_empty() {
2537                result.push(current_patch.trim_end_matches('\n').into());
2538                current_patch = String::new();
2539            }
2540            current_patch.push_str(line);
2541            current_patch.push('\n');
2542        }
2543
2544        if !current_patch.is_empty() {
2545            result.push(current_patch.trim_end_matches('\n').into());
2546        }
2547
2548        result
2549    }
2550    fn truncate_iteratively(patch: &str, max_bytes: usize) -> String {
2551        let mut current_size = patch.len();
2552        if current_size <= max_bytes {
2553            return patch.to_string();
2554        }
2555        let file_patches = Self::split_patch(patch);
2556        let mut file_infos: Vec<TruncatedPatch> = file_patches
2557            .iter()
2558            .filter_map(|patch| TruncatedPatch::from_unified_diff(patch))
2559            .collect();
2560
2561        if file_infos.is_empty() {
2562            return patch.to_string();
2563        }
2564
2565        current_size = file_infos.iter().map(|f| f.calculate_size()).sum::<usize>();
2566        while current_size > max_bytes {
2567            let file_idx = file_infos
2568                .iter()
2569                .enumerate()
2570                .filter(|(_, f)| f.hunks_to_keep > 1)
2571                .max_by_key(|(_, f)| f.hunks_to_keep)
2572                .map(|(idx, _)| idx);
2573            match file_idx {
2574                Some(idx) => {
2575                    let file = &mut file_infos[idx];
2576                    let size_before = file.calculate_size();
2577                    file.hunks_to_keep -= 1;
2578                    let size_after = file.calculate_size();
2579                    let saved = size_before.saturating_sub(size_after);
2580                    current_size = current_size.saturating_sub(saved);
2581                }
2582                None => {
2583                    break;
2584                }
2585            }
2586        }
2587
2588        file_infos
2589            .iter()
2590            .map(|info| info.to_string())
2591            .collect::<Vec<_>>()
2592            .join("\n")
2593    }
2594
2595    pub fn compress_commit_diff(diff_text: &str, max_bytes: usize) -> String {
2596        if diff_text.len() <= max_bytes {
2597            return diff_text.to_string();
2598        }
2599
2600        let mut compressed = diff_text
2601            .lines()
2602            .map(|line| {
2603                if line.len() > 256 {
2604                    format!("{}...[truncated]\n", &line[..line.floor_char_boundary(256)])
2605                } else {
2606                    format!("{}\n", line)
2607                }
2608            })
2609            .collect::<Vec<_>>()
2610            .join("");
2611
2612        if compressed.len() <= max_bytes {
2613            return compressed;
2614        }
2615
2616        compressed = Self::truncate_iteratively(&compressed, max_bytes);
2617
2618        compressed
2619    }
2620
2621    async fn load_project_rules(
2622        project: &Entity<Project>,
2623        repo_work_dir: &Arc<Path>,
2624        cx: &mut AsyncApp,
2625    ) -> Option<String> {
2626        let rules_path = cx.update(|cx| {
2627            for worktree in project.read(cx).worktrees(cx) {
2628                let worktree_abs_path = worktree.read(cx).abs_path();
2629                if !worktree_abs_path.starts_with(&repo_work_dir) {
2630                    continue;
2631                }
2632
2633                let worktree_snapshot = worktree.read(cx).snapshot();
2634                for rules_name in RULES_FILE_NAMES {
2635                    if let Ok(rel_path) = RelPath::unix(rules_name) {
2636                        if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) {
2637                            if entry.is_file() {
2638                                return Some(ProjectPath {
2639                                    worktree_id: worktree.read(cx).id(),
2640                                    path: entry.path.clone(),
2641                                });
2642                            }
2643                        }
2644                    }
2645                }
2646            }
2647            None
2648        })?;
2649
2650        let buffer = project
2651            .update(cx, |project, cx| project.open_buffer(rules_path, cx))
2652            .await
2653            .ok()?;
2654
2655        let content = buffer
2656            .read_with(cx, |buffer, _| buffer.text())
2657            .trim()
2658            .to_string();
2659
2660        if content.is_empty() {
2661            None
2662        } else {
2663            Some(content)
2664        }
2665    }
2666
2667    async fn load_commit_message_prompt(cx: &mut AsyncApp) -> String {
2668        let load = async {
2669            let store = cx.update(|cx| PromptStore::global(cx)).await.ok()?;
2670            store
2671                .update(cx, |s, cx| {
2672                    s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
2673                })
2674                .await
2675                .ok()
2676        };
2677        load.await
2678            .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
2679    }
2680
2681    /// Generates a commit message using an LLM.
2682    pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
2683        if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
2684            return;
2685        }
2686
2687        let Some(ConfiguredModel { provider, model }) =
2688            LanguageModelRegistry::read_global(cx).commit_message_model()
2689        else {
2690            return;
2691        };
2692
2693        let Some(repo) = self.active_repository.as_ref() else {
2694            return;
2695        };
2696
2697        telemetry::event!("Git Commit Message Generated");
2698
2699        let diff = repo.update(cx, |repo, cx| {
2700            if self.has_staged_changes() {
2701                repo.diff(DiffType::HeadToIndex, cx)
2702            } else {
2703                repo.diff(DiffType::HeadToWorktree, cx)
2704            }
2705        });
2706
2707        let temperature = AgentSettings::temperature_for_model(&model, cx);
2708        let project = self.project.clone();
2709        let repo_work_dir = repo.read(cx).work_directory_abs_path.clone();
2710
2711        self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| {
2712             async move {
2713                let _defer = cx.on_drop(&this, |this, _cx| {
2714                    this.generate_commit_message_task.take();
2715                });
2716
2717                if let Some(task) = cx.update(|cx| {
2718                    if !provider.is_authenticated(cx) {
2719                        Some(provider.authenticate(cx))
2720                    } else {
2721                        None
2722                    }
2723                }) {
2724                    task.await.log_err();
2725                }
2726
2727                let mut diff_text = match diff.await {
2728                    Ok(result) => match result {
2729                        Ok(text) => text,
2730                        Err(e) => {
2731                            Self::show_commit_message_error(&this, &e, cx);
2732                            return anyhow::Ok(());
2733                        }
2734                    },
2735                    Err(e) => {
2736                        Self::show_commit_message_error(&this, &e, cx);
2737                        return anyhow::Ok(());
2738                    }
2739                };
2740
2741                const MAX_DIFF_BYTES: usize = 20_000;
2742                diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES);
2743
2744                let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await;
2745
2746                let prompt = Self::load_commit_message_prompt(&mut cx).await;
2747
2748                let subject = this.update(cx, |this, cx| {
2749                    this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
2750                })?;
2751
2752                let text_empty = subject.trim().is_empty();
2753
2754                let rules_section = match &rules_content {
2755                    Some(rules) => format!(
2756                        "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\
2757                        <project_rules>\n{rules}\n</project_rules>\n"
2758                    ),
2759                    None => String::new(),
2760                };
2761
2762                let subject_section = if text_empty {
2763                    String::new()
2764                } else {
2765                    format!("\nHere is the user's subject line:\n{subject}")
2766                };
2767
2768                let content = format!(
2769                    "{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
2770                );
2771
2772                let request = LanguageModelRequest {
2773                    thread_id: None,
2774                    prompt_id: None,
2775                    intent: Some(CompletionIntent::GenerateGitCommitMessage),
2776                    messages: vec![LanguageModelRequestMessage {
2777                        role: Role::User,
2778                        content: vec![content.into()],
2779                        cache: false,
2780                        reasoning_details: None,
2781                    }],
2782                    tools: Vec::new(),
2783                    tool_choice: None,
2784                    stop: Vec::new(),
2785                    temperature,
2786                    thinking_allowed: false,
2787                    thinking_effort: None,
2788                    speed: None,
2789                };
2790
2791                let stream = model.stream_completion_text(request, cx);
2792                match stream.await {
2793                    Ok(mut messages) => {
2794                        if !text_empty {
2795                            this.update(cx, |this, cx| {
2796                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2797                                    let insert_position = buffer.anchor_before(buffer.len());
2798                                    buffer.edit([(insert_position..insert_position, "\n")], None, cx)
2799                                });
2800                            })?;
2801                        }
2802
2803                        while let Some(message) = messages.stream.next().await {
2804                            match message {
2805                                Ok(text) => {
2806                                    this.update(cx, |this, cx| {
2807                                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2808                                            let insert_position = buffer.anchor_before(buffer.len());
2809                                            buffer.edit([(insert_position..insert_position, text)], None, cx);
2810                                        });
2811                                    })?;
2812                                }
2813                                Err(e) => {
2814                                    Self::show_commit_message_error(&this, &e, cx);
2815                                    break;
2816                                }
2817                            }
2818                        }
2819                    }
2820                    Err(e) => {
2821                        Self::show_commit_message_error(&this, &e, cx);
2822                    }
2823                }
2824
2825                anyhow::Ok(())
2826            }
2827            .log_err().await
2828        }));
2829    }
2830
2831    fn get_fetch_options(
2832        &self,
2833        window: &mut Window,
2834        cx: &mut Context<Self>,
2835    ) -> Task<Option<FetchOptions>> {
2836        let repo = self.active_repository.clone();
2837        let workspace = self.workspace.clone();
2838
2839        cx.spawn_in(window, async move |_, cx| {
2840            let repo = repo?;
2841            let remotes = repo
2842                .update(cx, |repo, _| repo.get_remotes(None, false))
2843                .await
2844                .ok()?
2845                .log_err()?;
2846
2847            let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
2848            if remotes.len() > 1 {
2849                remotes.push(FetchOptions::All);
2850            }
2851            let selection = cx
2852                .update(|window, cx| {
2853                    picker_prompt::prompt(
2854                        "Pick which remote to fetch",
2855                        remotes.iter().map(|r| r.name()).collect(),
2856                        workspace,
2857                        window,
2858                        cx,
2859                    )
2860                })
2861                .ok()?
2862                .await?;
2863            remotes.get(selection).cloned()
2864        })
2865    }
2866
2867    pub(crate) fn fetch(
2868        &mut self,
2869        is_fetch_all: bool,
2870        window: &mut Window,
2871        cx: &mut Context<Self>,
2872    ) {
2873        if !self.can_push_and_pull(cx) {
2874            return;
2875        }
2876
2877        let Some(repo) = self.active_repository.clone() else {
2878            return;
2879        };
2880        telemetry::event!("Git Fetched");
2881        let askpass = self.askpass_delegate("git fetch", window, cx);
2882        let this = cx.weak_entity();
2883
2884        let fetch_options = if is_fetch_all {
2885            Task::ready(Some(FetchOptions::All))
2886        } else {
2887            self.get_fetch_options(window, cx)
2888        };
2889
2890        window
2891            .spawn(cx, async move |cx| {
2892                let Some(fetch_options) = fetch_options.await else {
2893                    return Ok(());
2894                };
2895                let fetch = repo.update(cx, |repo, cx| {
2896                    repo.fetch(fetch_options.clone(), askpass, cx)
2897                });
2898
2899                let remote_message = fetch.await?;
2900                this.update(cx, |this, cx| {
2901                    let action = match fetch_options {
2902                        FetchOptions::All => RemoteAction::Fetch(None),
2903                        FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
2904                    };
2905                    match remote_message {
2906                        Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2907                        Err(e) => {
2908                            log::error!("Error while fetching {:?}", e);
2909                            this.show_error_toast(action.name(), e, cx)
2910                        }
2911                    }
2912
2913                    anyhow::Ok(())
2914                })
2915                .ok();
2916                anyhow::Ok(())
2917            })
2918            .detach_and_log_err(cx);
2919    }
2920
2921    pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
2922        let workspace = self.workspace.clone();
2923
2924        crate::clone::clone_and_open(
2925            repo.into(),
2926            workspace,
2927            window,
2928            cx,
2929            Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
2930        );
2931    }
2932
2933    pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2934        let worktrees = self
2935            .project
2936            .read(cx)
2937            .visible_worktrees(cx)
2938            .collect::<Vec<_>>();
2939
2940        let worktree = if worktrees.len() == 1 {
2941            Task::ready(Some(worktrees.first().unwrap().clone()))
2942        } else if worktrees.is_empty() {
2943            let result = window.prompt(
2944                PromptLevel::Warning,
2945                "Unable to initialize a git repository",
2946                Some("Open a directory first"),
2947                &["Ok"],
2948                cx,
2949            );
2950            cx.background_executor()
2951                .spawn(async move {
2952                    result.await.ok();
2953                })
2954                .detach();
2955            return;
2956        } else {
2957            let worktree_directories = worktrees
2958                .iter()
2959                .map(|worktree| worktree.read(cx).abs_path())
2960                .map(|worktree_abs_path| {
2961                    if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2962                        Path::new("~")
2963                            .join(path)
2964                            .to_string_lossy()
2965                            .to_string()
2966                            .into()
2967                    } else {
2968                        worktree_abs_path.to_string_lossy().into_owned().into()
2969                    }
2970                })
2971                .collect_vec();
2972            let prompt = picker_prompt::prompt(
2973                "Where would you like to initialize this git repository?",
2974                worktree_directories,
2975                self.workspace.clone(),
2976                window,
2977                cx,
2978            );
2979
2980            cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2981        };
2982
2983        cx.spawn_in(window, async move |this, cx| {
2984            let worktree = match worktree.await {
2985                Some(worktree) => worktree,
2986                None => {
2987                    return;
2988                }
2989            };
2990
2991            let Ok(result) = this.update(cx, |this, cx| {
2992                let fallback_branch_name = GitPanelSettings::get_global(cx)
2993                    .fallback_branch_name
2994                    .clone();
2995                this.project.read(cx).git_init(
2996                    worktree.read(cx).abs_path(),
2997                    fallback_branch_name,
2998                    cx,
2999                )
3000            }) else {
3001                return;
3002            };
3003
3004            let result = result.await;
3005
3006            this.update_in(cx, |this, _, cx| match result {
3007                Ok(()) => {}
3008                Err(e) => this.show_error_toast("init", e, cx),
3009            })
3010            .ok();
3011        })
3012        .detach();
3013    }
3014
3015    pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context<Self>) {
3016        if !self.can_push_and_pull(cx) {
3017            return;
3018        }
3019        let Some(repo) = self.active_repository.clone() else {
3020            return;
3021        };
3022        let Some(branch) = repo.read(cx).branch.as_ref() else {
3023            return;
3024        };
3025        telemetry::event!("Git Pulled");
3026        let branch = branch.clone();
3027        let remote = self.get_remote(false, false, window, cx);
3028        cx.spawn_in(window, async move |this, cx| {
3029            let remote = match remote.await {
3030                Ok(Some(remote)) => remote,
3031                Ok(None) => {
3032                    return Ok(());
3033                }
3034                Err(e) => {
3035                    log::error!("Failed to get current remote: {}", e);
3036                    this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
3037                        .ok();
3038                    return Ok(());
3039                }
3040            };
3041
3042            let askpass = this.update_in(cx, |this, window, cx| {
3043                this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
3044            })?;
3045
3046            let branch_name = branch
3047                .upstream
3048                .is_none()
3049                .then(|| branch.name().to_owned().into());
3050
3051            let pull = repo.update(cx, |repo, cx| {
3052                repo.pull(branch_name, remote.name.clone(), rebase, askpass, cx)
3053            });
3054
3055            let remote_message = pull.await?;
3056
3057            let action = RemoteAction::Pull(remote);
3058            this.update(cx, |this, cx| match remote_message {
3059                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3060                Err(e) => {
3061                    log::error!("Error while pulling {:?}", e);
3062                    this.show_error_toast(action.name(), e, cx)
3063                }
3064            })
3065            .ok();
3066
3067            anyhow::Ok(())
3068        })
3069        .detach_and_log_err(cx);
3070    }
3071
3072    pub(crate) fn push(
3073        &mut self,
3074        force_push: bool,
3075        select_remote: bool,
3076        window: &mut Window,
3077        cx: &mut Context<Self>,
3078    ) {
3079        if !self.can_push_and_pull(cx) {
3080            return;
3081        }
3082        let Some(repo) = self.active_repository.clone() else {
3083            return;
3084        };
3085        let Some(branch) = repo.read(cx).branch.as_ref() else {
3086            return;
3087        };
3088        telemetry::event!("Git Pushed");
3089        let branch = branch.clone();
3090
3091        let options = if force_push {
3092            Some(PushOptions::Force)
3093        } else {
3094            match branch.upstream {
3095                Some(Upstream {
3096                    tracking: UpstreamTracking::Gone,
3097                    ..
3098                })
3099                | None => Some(PushOptions::SetUpstream),
3100                _ => None,
3101            }
3102        };
3103        let remote = self.get_remote(select_remote, true, window, cx);
3104
3105        cx.spawn_in(window, async move |this, cx| {
3106            let remote = match remote.await {
3107                Ok(Some(remote)) => remote,
3108                Ok(None) => {
3109                    return Ok(());
3110                }
3111                Err(e) => {
3112                    log::error!("Failed to get current remote: {}", e);
3113                    this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
3114                        .ok();
3115                    return Ok(());
3116                }
3117            };
3118
3119            let askpass_delegate = this.update_in(cx, |this, window, cx| {
3120                this.askpass_delegate(format!("git push {}", remote.name), window, cx)
3121            })?;
3122
3123            let push = repo.update(cx, |repo, cx| {
3124                repo.push(
3125                    branch.name().to_owned().into(),
3126                    branch
3127                        .upstream
3128                        .as_ref()
3129                        .filter(|u| matches!(u.tracking, UpstreamTracking::Tracked(_)))
3130                        .and_then(|u| u.branch_name())
3131                        .unwrap_or_else(|| branch.name())
3132                        .to_owned()
3133                        .into(),
3134                    remote.name.clone(),
3135                    options,
3136                    askpass_delegate,
3137                    cx,
3138                )
3139            });
3140
3141            let remote_output = push.await?;
3142
3143            let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
3144            this.update(cx, |this, cx| match remote_output {
3145                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3146                Err(e) => {
3147                    log::error!("Error while pushing {:?}", e);
3148                    this.show_error_toast(action.name(), e, cx)
3149                }
3150            })?;
3151
3152            anyhow::Ok(())
3153        })
3154        .detach_and_log_err(cx);
3155    }
3156
3157    pub fn create_pull_request(&self, window: &mut Window, cx: &mut Context<Self>) {
3158        let result = (|| -> anyhow::Result<()> {
3159            let repo = self
3160                .active_repository
3161                .clone()
3162                .ok_or_else(|| anyhow::anyhow!("No active repository"))?;
3163
3164            let (branch, remote_origin, remote_upstream) = {
3165                let repository = repo.read(cx);
3166                (
3167                    repository.branch.clone(),
3168                    repository.remote_origin_url.clone(),
3169                    repository.remote_upstream_url.clone(),
3170                )
3171            };
3172
3173            let branch = branch.ok_or_else(|| anyhow::anyhow!("No active branch"))?;
3174            let source_branch = branch
3175                .upstream
3176                .as_ref()
3177                .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_)))
3178                .and_then(|upstream| upstream.branch_name())
3179                .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3180            let source_branch = source_branch.to_string();
3181
3182            let remote_url = branch
3183                .upstream
3184                .as_ref()
3185                .and_then(|upstream| match upstream.remote_name() {
3186                    Some("upstream") => remote_upstream.as_deref(),
3187                    Some(_) => remote_origin.as_deref(),
3188                    None => None,
3189                })
3190                .or(remote_origin.as_deref())
3191                .or(remote_upstream.as_deref())
3192                .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3193            let remote_url = remote_url.to_string();
3194
3195            let provider_registry = GitHostingProviderRegistry::global(cx);
3196            let Some((provider, parsed_remote)) =
3197                git::parse_git_remote_url(provider_registry, &remote_url)
3198            else {
3199                return Err(anyhow::anyhow!("Unsupported remote URL: {}", remote_url));
3200            };
3201
3202            let Some(url) = provider.build_create_pull_request_url(&parsed_remote, &source_branch)
3203            else {
3204                return Err(anyhow::anyhow!("Unable to construct pull request URL"));
3205            };
3206
3207            cx.open_url(url.as_str());
3208            Ok(())
3209        })();
3210
3211        if let Err(err) = result {
3212            log::error!("Error while creating pull request {:?}", err);
3213            cx.defer_in(window, |panel, _window, cx| {
3214                panel.show_error_toast("create pull request", err, cx);
3215            });
3216        }
3217    }
3218
3219    fn askpass_delegate(
3220        &self,
3221        operation: impl Into<SharedString>,
3222        window: &mut Window,
3223        cx: &mut Context<Self>,
3224    ) -> AskPassDelegate {
3225        let workspace = self.workspace.clone();
3226        let operation = operation.into();
3227        let window = window.window_handle();
3228        AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
3229            window
3230                .update(cx, |_, window, cx| {
3231                    workspace.update(cx, |workspace, cx| {
3232                        workspace.toggle_modal(window, cx, |window, cx| {
3233                            AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
3234                        });
3235                    })
3236                })
3237                .ok();
3238        })
3239    }
3240
3241    fn can_push_and_pull(&self, cx: &App) -> bool {
3242        !self.project.read(cx).is_via_collab()
3243    }
3244
3245    fn get_remote(
3246        &mut self,
3247        always_select: bool,
3248        is_push: bool,
3249        window: &mut Window,
3250        cx: &mut Context<Self>,
3251    ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
3252        let repo = self.active_repository.clone();
3253        let workspace = self.workspace.clone();
3254        let mut cx = window.to_async(cx);
3255
3256        async move {
3257            let repo = repo.context("No active repository")?;
3258            let current_remotes: Vec<Remote> = repo
3259                .update(&mut cx, |repo, _| {
3260                    let current_branch = if always_select {
3261                        None
3262                    } else {
3263                        let current_branch = repo.branch.as_ref().context("No active branch")?;
3264                        Some(current_branch.name().to_string())
3265                    };
3266                    anyhow::Ok(repo.get_remotes(current_branch, is_push))
3267                })?
3268                .await??;
3269
3270            let current_remotes: Vec<_> = current_remotes
3271                .into_iter()
3272                .map(|remotes| remotes.name)
3273                .collect();
3274            let selection = cx
3275                .update(|window, cx| {
3276                    picker_prompt::prompt(
3277                        "Pick which remote to push to",
3278                        current_remotes.clone(),
3279                        workspace,
3280                        window,
3281                        cx,
3282                    )
3283                })?
3284                .await;
3285
3286            Ok(selection.map(|selection| Remote {
3287                name: current_remotes[selection].clone(),
3288            }))
3289        }
3290    }
3291
3292    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
3293        if self.local_committer_task.is_none() {
3294            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
3295                let committer = get_git_committer(cx).await;
3296                this.update(cx, |this, cx| {
3297                    this.local_committer = Some(committer);
3298                    cx.notify()
3299                })
3300                .ok();
3301            }));
3302        }
3303    }
3304
3305    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
3306        let mut new_co_authors = Vec::new();
3307        let project = self.project.read(cx);
3308
3309        let Some(room) =
3310            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
3311        else {
3312            return Vec::default();
3313        };
3314
3315        let room = room.read(cx);
3316
3317        for (peer_id, collaborator) in project.collaborators() {
3318            if collaborator.is_host {
3319                continue;
3320            }
3321
3322            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
3323                continue;
3324            };
3325            if !participant.can_write() {
3326                continue;
3327            }
3328            if let Some(email) = &collaborator.committer_email {
3329                let name = collaborator
3330                    .committer_name
3331                    .clone()
3332                    .or_else(|| participant.user.name.clone())
3333                    .unwrap_or_else(|| participant.user.github_login.clone().to_string());
3334                new_co_authors.push((name.clone(), email.clone()))
3335            }
3336        }
3337        if !project.is_local()
3338            && !project.is_read_only(cx)
3339            && let Some(local_committer) = self.local_committer(room, cx)
3340        {
3341            new_co_authors.push(local_committer);
3342        }
3343        new_co_authors
3344    }
3345
3346    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
3347        let user = room.local_participant_user(cx)?;
3348        let committer = self.local_committer.as_ref()?;
3349        let email = committer.email.clone()?;
3350        let name = committer
3351            .name
3352            .clone()
3353            .or_else(|| user.name.clone())
3354            .unwrap_or_else(|| user.github_login.clone().to_string());
3355        Some((name, email))
3356    }
3357
3358    fn toggle_fill_co_authors(
3359        &mut self,
3360        _: &ToggleFillCoAuthors,
3361        _: &mut Window,
3362        cx: &mut Context<Self>,
3363    ) {
3364        self.add_coauthors = !self.add_coauthors;
3365        cx.notify();
3366    }
3367
3368    fn toggle_sort_by_path(
3369        &mut self,
3370        _: &ToggleSortByPath,
3371        _: &mut Window,
3372        cx: &mut Context<Self>,
3373    ) {
3374        let current_setting = GitPanelSettings::get_global(cx).sort_by_path;
3375        if let Some(workspace) = self.workspace.upgrade() {
3376            let workspace = workspace.read(cx);
3377            let fs = workspace.app_state().fs.clone();
3378            cx.update_global::<SettingsStore, _>(|store, _cx| {
3379                store.update_settings_file(fs, move |settings, _cx| {
3380                    settings.git_panel.get_or_insert_default().sort_by_path =
3381                        Some(!current_setting);
3382                });
3383            });
3384        }
3385    }
3386
3387    fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context<Self>) {
3388        let current_setting = GitPanelSettings::get_global(cx).tree_view;
3389        if let Some(workspace) = self.workspace.upgrade() {
3390            let workspace = workspace.read(cx);
3391            let fs = workspace.app_state().fs.clone();
3392            cx.update_global::<SettingsStore, _>(|store, _cx| {
3393                store.update_settings_file(fs, move |settings, _cx| {
3394                    settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting);
3395                });
3396            })
3397        }
3398    }
3399
3400    fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context<Self>) {
3401        if let Some(state) = self.view_mode.tree_state_mut() {
3402            let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
3403            *expanded = !*expanded;
3404            self.update_visible_entries(window, cx);
3405        } else {
3406            util::debug_panic!("Attempted to toggle directory in flat Git Panel state");
3407        }
3408    }
3409
3410    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
3411        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
3412
3413        let existing_text = message.to_ascii_lowercase();
3414        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
3415        let mut ends_with_co_authors = false;
3416        let existing_co_authors = existing_text
3417            .lines()
3418            .filter_map(|line| {
3419                let line = line.trim();
3420                if line.starts_with(&lowercase_co_author_prefix) {
3421                    ends_with_co_authors = true;
3422                    Some(line)
3423                } else {
3424                    ends_with_co_authors = false;
3425                    None
3426                }
3427            })
3428            .collect::<HashSet<_>>();
3429
3430        let new_co_authors = self
3431            .potential_co_authors(cx)
3432            .into_iter()
3433            .filter(|(_, email)| {
3434                !existing_co_authors
3435                    .iter()
3436                    .any(|existing| existing.contains(email.as_str()))
3437            })
3438            .collect::<Vec<_>>();
3439
3440        if new_co_authors.is_empty() {
3441            return;
3442        }
3443
3444        if !ends_with_co_authors {
3445            message.push('\n');
3446        }
3447        for (name, email) in new_co_authors {
3448            message.push('\n');
3449            message.push_str(CO_AUTHOR_PREFIX);
3450            message.push_str(&name);
3451            message.push_str(" <");
3452            message.push_str(&email);
3453            message.push('>');
3454        }
3455        message.push('\n');
3456    }
3457
3458    fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3459        let handle = cx.entity().downgrade();
3460        self.reopen_commit_buffer(window, cx);
3461        self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
3462            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
3463            if let Some(git_panel) = handle.upgrade() {
3464                git_panel
3465                    .update_in(cx, |git_panel, window, cx| {
3466                        git_panel.update_visible_entries(window, cx);
3467                    })
3468                    .ok();
3469            }
3470        });
3471    }
3472
3473    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3474        let Some(active_repo) = self.active_repository.as_ref() else {
3475            return;
3476        };
3477        let load_buffer = active_repo.update(cx, |active_repo, cx| {
3478            let project = self.project.read(cx);
3479            active_repo.open_commit_buffer(
3480                Some(project.languages().clone()),
3481                project.buffer_store().clone(),
3482                cx,
3483            )
3484        });
3485
3486        cx.spawn_in(window, async move |git_panel, cx| {
3487            let buffer = load_buffer.await?;
3488            git_panel.update_in(cx, |git_panel, window, cx| {
3489                if git_panel
3490                    .commit_editor
3491                    .read(cx)
3492                    .buffer()
3493                    .read(cx)
3494                    .as_singleton()
3495                    .as_ref()
3496                    != Some(&buffer)
3497                {
3498                    git_panel.commit_editor = cx.new(|cx| {
3499                        commit_message_editor(
3500                            buffer,
3501                            git_panel.suggest_commit_message(cx).map(SharedString::from),
3502                            git_panel.project.clone(),
3503                            true,
3504                            window,
3505                            cx,
3506                        )
3507                    });
3508                }
3509            })
3510        })
3511        .detach_and_log_err(cx);
3512    }
3513
3514    fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3515        let path_style = self.project.read(cx).path_style(cx);
3516        let bulk_staging = self.bulk_staging.take();
3517        let last_staged_path_prev_index = bulk_staging
3518            .as_ref()
3519            .and_then(|op| self.entry_by_path(&op.anchor));
3520
3521        self.active_repository = self.project.read(cx).active_repository(cx);
3522        self.entries.clear();
3523        self.entries_indices.clear();
3524        self.single_staged_entry.take();
3525        self.single_tracked_entry.take();
3526        self.conflicted_count = 0;
3527        self.conflicted_staged_count = 0;
3528        self.changes_count = 0;
3529        self.new_count = 0;
3530        self.tracked_count = 0;
3531        self.new_staged_count = 0;
3532        self.tracked_staged_count = 0;
3533        self.entry_count = 0;
3534        self.max_width_item_index = None;
3535
3536        let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
3537        let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
3538        let group_by_status = is_tree_view || !sort_by_path;
3539
3540        let mut changed_entries = Vec::new();
3541        let mut new_entries = Vec::new();
3542        let mut conflict_entries = Vec::new();
3543        let mut single_staged_entry = None;
3544        let mut staged_count = 0;
3545        let mut seen_directories = HashSet::default();
3546        let mut max_width_estimate = 0usize;
3547        let mut max_width_item_index = None;
3548
3549        let Some(repo) = self.active_repository.as_ref() else {
3550            // Just clear entries if no repository is active.
3551            cx.notify();
3552            return;
3553        };
3554
3555        let repo = repo.read(cx);
3556
3557        self.stash_entries = repo.cached_stash();
3558
3559        for entry in repo.cached_status() {
3560            self.changes_count += 1;
3561            let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
3562            let is_new = entry.status.is_created();
3563            let staging = entry.status.staging();
3564
3565            if let Some(pending) = repo.pending_ops_for_path(&entry.repo_path)
3566                && pending
3567                    .ops
3568                    .iter()
3569                    .any(|op| op.git_status == pending_op::GitStatus::Reverted && op.finished())
3570            {
3571                continue;
3572            }
3573
3574            let entry = GitStatusEntry {
3575                repo_path: entry.repo_path.clone(),
3576                status: entry.status,
3577                staging,
3578            };
3579
3580            if staging.has_staged() {
3581                staged_count += 1;
3582                single_staged_entry = Some(entry.clone());
3583            }
3584
3585            if group_by_status && is_conflict {
3586                conflict_entries.push(entry);
3587            } else if group_by_status && is_new {
3588                new_entries.push(entry);
3589            } else {
3590                changed_entries.push(entry);
3591            }
3592        }
3593
3594        if conflict_entries.is_empty() {
3595            if staged_count == 1
3596                && let Some(entry) = single_staged_entry.as_ref()
3597            {
3598                if let Some(ops) = repo.pending_ops_for_path(&entry.repo_path) {
3599                    if ops.staged() {
3600                        self.single_staged_entry = single_staged_entry;
3601                    }
3602                } else {
3603                    self.single_staged_entry = single_staged_entry;
3604                }
3605            } else if repo.pending_ops_summary().item_summary.staging_count == 1
3606                && let Some(ops) = repo.pending_ops().find(|ops| ops.staging())
3607            {
3608                self.single_staged_entry =
3609                    repo.status_for_path(&ops.repo_path)
3610                        .map(|status| GitStatusEntry {
3611                            repo_path: ops.repo_path.clone(),
3612                            status: status.status,
3613                            staging: StageStatus::Staged,
3614                        });
3615            }
3616        }
3617
3618        if conflict_entries.is_empty() && changed_entries.len() == 1 {
3619            self.single_tracked_entry = changed_entries.first().cloned();
3620        }
3621
3622        let mut push_entry =
3623            |this: &mut Self,
3624             entry: GitListEntry,
3625             is_visible: bool,
3626             logical_indices: Option<&mut Vec<usize>>| {
3627                if let Some(estimate) =
3628                    this.width_estimate_for_list_entry(is_tree_view, &entry, path_style)
3629                {
3630                    if estimate > max_width_estimate {
3631                        max_width_estimate = estimate;
3632                        max_width_item_index = Some(this.entries.len());
3633                    }
3634                }
3635
3636                if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
3637                {
3638                    this.entries_indices.insert(repo_path, this.entries.len());
3639                }
3640
3641                if let (Some(indices), true) = (logical_indices, is_visible) {
3642                    indices.push(this.entries.len());
3643                }
3644
3645                this.entries.push(entry);
3646            };
3647
3648        macro_rules! take_section_entries {
3649            () => {
3650                [
3651                    (Section::Conflict, std::mem::take(&mut conflict_entries)),
3652                    (Section::Tracked, std::mem::take(&mut changed_entries)),
3653                    (Section::New, std::mem::take(&mut new_entries)),
3654                ]
3655            };
3656        }
3657
3658        match &mut self.view_mode {
3659            GitPanelViewMode::Tree(tree_state) => {
3660                tree_state.logical_indices.clear();
3661                tree_state.directory_descendants.clear();
3662
3663                // This is just to get around the borrow checker
3664                // because push_entry mutably borrows self
3665                let mut tree_state = std::mem::take(tree_state);
3666
3667                for (section, entries) in take_section_entries!() {
3668                    if entries.is_empty() {
3669                        continue;
3670                    }
3671
3672                    push_entry(
3673                        self,
3674                        GitListEntry::Header(GitHeaderEntry { header: section }),
3675                        true,
3676                        Some(&mut tree_state.logical_indices),
3677                    );
3678
3679                    for (entry, is_visible) in
3680                        tree_state.build_tree_entries(section, entries, &mut seen_directories)
3681                    {
3682                        push_entry(
3683                            self,
3684                            entry,
3685                            is_visible,
3686                            Some(&mut tree_state.logical_indices),
3687                        );
3688                    }
3689                }
3690
3691                tree_state
3692                    .expanded_dirs
3693                    .retain(|key, _| seen_directories.contains(key));
3694                self.view_mode = GitPanelViewMode::Tree(tree_state);
3695            }
3696            GitPanelViewMode::Flat => {
3697                for (section, entries) in take_section_entries!() {
3698                    if entries.is_empty() {
3699                        continue;
3700                    }
3701
3702                    if section != Section::Tracked || !sort_by_path {
3703                        push_entry(
3704                            self,
3705                            GitListEntry::Header(GitHeaderEntry { header: section }),
3706                            true,
3707                            None,
3708                        );
3709                    }
3710
3711                    for entry in entries {
3712                        push_entry(self, GitListEntry::Status(entry), true, None);
3713                    }
3714                }
3715            }
3716        }
3717
3718        self.max_width_item_index = max_width_item_index;
3719
3720        self.update_counts(repo);
3721
3722        let bulk_staging_anchor_new_index = bulk_staging
3723            .as_ref()
3724            .filter(|op| op.repo_id == repo.id)
3725            .and_then(|op| self.entry_by_path(&op.anchor));
3726        if bulk_staging_anchor_new_index == last_staged_path_prev_index
3727            && let Some(index) = bulk_staging_anchor_new_index
3728            && let Some(entry) = self.entries.get(index)
3729            && let Some(entry) = entry.status_entry()
3730            && GitPanel::stage_status_for_entry(entry, &repo)
3731                .as_bool()
3732                .unwrap_or(false)
3733        {
3734            self.bulk_staging = bulk_staging;
3735        }
3736
3737        self.select_first_entry_if_none(window, cx);
3738
3739        let suggested_commit_message = self.suggest_commit_message(cx);
3740        let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
3741
3742        self.commit_editor.update(cx, |editor, cx| {
3743            editor.set_placeholder_text(&placeholder_text, window, cx)
3744        });
3745
3746        if GitPanelSettings::get_global(cx).diff_stats {
3747            self.fetch_diff_stats(cx);
3748        }
3749
3750        cx.notify();
3751    }
3752
3753    fn fetch_diff_stats(&mut self, cx: &mut Context<Self>) {
3754        let Some(repo) = self.active_repository.clone() else {
3755            self.diff_stats.clear();
3756            return;
3757        };
3758
3759        let unstaged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx));
3760        let staged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx));
3761
3762        self.diff_stats_task = cx.spawn(async move |this, cx| {
3763            let (unstaged_result, staged_result) =
3764                futures::future::join(unstaged_rx, staged_rx).await;
3765
3766            let mut combined = match unstaged_result {
3767                Ok(Ok(stats)) => stats,
3768                Ok(Err(err)) => {
3769                    log::warn!("Failed to fetch unstaged diff stats: {err:?}");
3770                    HashMap::default()
3771                }
3772                Err(_) => HashMap::default(),
3773            };
3774
3775            let staged = match staged_result {
3776                Ok(Ok(stats)) => Some(stats),
3777                Ok(Err(err)) => {
3778                    log::warn!("Failed to fetch staged diff stats: {err:?}");
3779                    None
3780                }
3781                Err(_) => None,
3782            };
3783
3784            if let Some(staged) = staged {
3785                for (path, stat) in staged {
3786                    let entry = combined.entry(path).or_default();
3787                    entry.added += stat.added;
3788                    entry.deleted += stat.deleted;
3789                }
3790            }
3791
3792            this.update(cx, |this, cx| {
3793                this.diff_stats = combined;
3794                cx.notify();
3795            })
3796            .ok();
3797        });
3798    }
3799
3800    fn header_state(&self, header_type: Section) -> ToggleState {
3801        let (staged_count, count) = match header_type {
3802            Section::New => (self.new_staged_count, self.new_count),
3803            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
3804            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
3805        };
3806        if staged_count == 0 {
3807            ToggleState::Unselected
3808        } else if count == staged_count {
3809            ToggleState::Selected
3810        } else {
3811            ToggleState::Indeterminate
3812        }
3813    }
3814
3815    fn update_counts(&mut self, repo: &Repository) {
3816        self.show_placeholders = false;
3817        self.conflicted_count = 0;
3818        self.conflicted_staged_count = 0;
3819        self.new_count = 0;
3820        self.tracked_count = 0;
3821        self.new_staged_count = 0;
3822        self.tracked_staged_count = 0;
3823        self.entry_count = 0;
3824
3825        for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) {
3826            self.entry_count += 1;
3827            let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo)
3828                .as_bool()
3829                .unwrap_or(true);
3830
3831            if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
3832                self.conflicted_count += 1;
3833                if is_staging_or_staged {
3834                    self.conflicted_staged_count += 1;
3835                }
3836            } else if status_entry.status.is_created() {
3837                self.new_count += 1;
3838                if is_staging_or_staged {
3839                    self.new_staged_count += 1;
3840                }
3841            } else {
3842                self.tracked_count += 1;
3843                if is_staging_or_staged {
3844                    self.tracked_staged_count += 1;
3845                }
3846            }
3847        }
3848    }
3849
3850    pub(crate) fn has_staged_changes(&self) -> bool {
3851        self.tracked_staged_count > 0
3852            || self.new_staged_count > 0
3853            || self.conflicted_staged_count > 0
3854    }
3855
3856    pub(crate) fn has_unstaged_changes(&self) -> bool {
3857        self.tracked_count > self.tracked_staged_count
3858            || self.new_count > self.new_staged_count
3859            || self.conflicted_count > self.conflicted_staged_count
3860    }
3861
3862    fn has_tracked_changes(&self) -> bool {
3863        self.tracked_count > 0
3864    }
3865
3866    pub fn has_unstaged_conflicts(&self) -> bool {
3867        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
3868    }
3869
3870    fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
3871        let Some(workspace) = self.workspace.upgrade() else {
3872            return;
3873        };
3874        show_error_toast(workspace, action, e, cx)
3875    }
3876
3877    fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
3878    where
3879        E: std::fmt::Debug + std::fmt::Display,
3880    {
3881        if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
3882            let _ = workspace.update(cx, |workspace, cx| {
3883                struct CommitMessageError;
3884                let notification_id = NotificationId::unique::<CommitMessageError>();
3885                workspace.show_notification(notification_id, cx, |cx| {
3886                    cx.new(|cx| {
3887                        ErrorMessagePrompt::new(
3888                            format!("Failed to generate commit message: {err}"),
3889                            cx,
3890                        )
3891                    })
3892                });
3893            });
3894        }
3895    }
3896
3897    fn show_remote_output(
3898        &mut self,
3899        action: RemoteAction,
3900        info: RemoteCommandOutput,
3901        cx: &mut Context<Self>,
3902    ) {
3903        let Some(workspace) = self.workspace.upgrade() else {
3904            return;
3905        };
3906
3907        workspace.update(cx, |workspace, cx| {
3908            let SuccessMessage { message, style } = remote_output::format_output(&action, info);
3909            let workspace_weak = cx.weak_entity();
3910            let operation = action.name();
3911
3912            let status_toast = StatusToast::new(message, cx, move |this, _cx| {
3913                use remote_output::SuccessStyle::*;
3914                match style {
3915                    Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
3916                    ToastWithLog { output } => this
3917                        .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3918                        .action("View Log", move |window, cx| {
3919                            let output = output.clone();
3920                            let output =
3921                                format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
3922                            workspace_weak
3923                                .update(cx, move |workspace, cx| {
3924                                    open_output(operation, workspace, &output, window, cx)
3925                                })
3926                                .ok();
3927                        }),
3928                    PushPrLink { text, link } => this
3929                        .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3930                        .action(text, move |_, cx| cx.open_url(&link)),
3931                }
3932                .dismiss_button(true)
3933            });
3934            workspace.toggle_status_toast(status_toast, cx)
3935        });
3936    }
3937
3938    pub fn can_commit(&self) -> bool {
3939        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
3940    }
3941
3942    pub fn can_stage_all(&self) -> bool {
3943        self.has_unstaged_changes()
3944    }
3945
3946    pub fn can_unstage_all(&self) -> bool {
3947        self.has_staged_changes()
3948    }
3949
3950    /// Computes tree indentation depths for visible entries in the given range.
3951    /// Used by indent guides to render vertical connector lines in tree view.
3952    fn compute_visible_depths(&self, range: Range<usize>) -> SmallVec<[usize; 64]> {
3953        let GitPanelViewMode::Tree(state) = &self.view_mode else {
3954            return SmallVec::new();
3955        };
3956
3957        range
3958            .map(|ix| {
3959                state
3960                    .logical_indices
3961                    .get(ix)
3962                    .and_then(|&entry_ix| self.entries.get(entry_ix))
3963                    .map_or(0, |entry| entry.depth())
3964            })
3965            .collect()
3966    }
3967
3968    fn status_width_estimate(
3969        tree_view: bool,
3970        entry: &GitStatusEntry,
3971        path_style: PathStyle,
3972        depth: usize,
3973    ) -> usize {
3974        if tree_view {
3975            Self::item_width_estimate(0, entry.display_name(path_style).len(), depth)
3976        } else {
3977            Self::item_width_estimate(
3978                entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
3979                entry.display_name(path_style).len(),
3980                0,
3981            )
3982        }
3983    }
3984
3985    fn width_estimate_for_list_entry(
3986        &self,
3987        tree_view: bool,
3988        entry: &GitListEntry,
3989        path_style: PathStyle,
3990    ) -> Option<usize> {
3991        match entry {
3992            GitListEntry::Status(status) => Some(Self::status_width_estimate(
3993                tree_view, status, path_style, 0,
3994            )),
3995            GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate(
3996                tree_view,
3997                &status.entry,
3998                path_style,
3999                status.depth,
4000            )),
4001            GitListEntry::Directory(dir) => {
4002                Some(Self::item_width_estimate(0, dir.name.len(), dir.depth))
4003            }
4004            GitListEntry::Header(_) => None,
4005        }
4006    }
4007
4008    fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize {
4009        path + file_name + depth * 2
4010    }
4011
4012    fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
4013        let focus_handle = self.focus_handle.clone();
4014        let has_tracked_changes = self.has_tracked_changes();
4015        let has_staged_changes = self.has_staged_changes();
4016        let has_unstaged_changes = self.has_unstaged_changes();
4017        let has_new_changes = self.new_count > 0;
4018        let has_stash_items = self.stash_entries.entries.len() > 0;
4019
4020        PopoverMenu::new(id.into())
4021            .trigger(
4022                IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
4023                    .icon_size(IconSize::Small)
4024                    .icon_color(Color::Muted),
4025            )
4026            .menu(move |window, cx| {
4027                Some(git_panel_context_menu(
4028                    focus_handle.clone(),
4029                    GitMenuState {
4030                        has_tracked_changes,
4031                        has_staged_changes,
4032                        has_unstaged_changes,
4033                        has_new_changes,
4034                        sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
4035                        has_stash_items,
4036                        tree_view: GitPanelSettings::get_global(cx).tree_view,
4037                    },
4038                    window,
4039                    cx,
4040                ))
4041            })
4042            .anchor(Corner::TopRight)
4043    }
4044
4045    pub(crate) fn render_generate_commit_message_button(
4046        &self,
4047        cx: &Context<Self>,
4048    ) -> Option<AnyElement> {
4049        if !agent_settings::AgentSettings::get_global(cx).enabled(cx) {
4050            return None;
4051        }
4052
4053        if self.generate_commit_message_task.is_some() {
4054            return Some(
4055                h_flex()
4056                    .gap_1()
4057                    .child(
4058                        Icon::new(IconName::ArrowCircle)
4059                            .size(IconSize::XSmall)
4060                            .color(Color::Info)
4061                            .with_rotate_animation(2),
4062                    )
4063                    .child(
4064                        Label::new("Generating Commit…")
4065                            .size(LabelSize::Small)
4066                            .color(Color::Muted),
4067                    )
4068                    .into_any_element(),
4069            );
4070        }
4071
4072        let model_registry = LanguageModelRegistry::read_global(cx);
4073        let has_commit_model_configuration_error = model_registry
4074            .configuration_error(model_registry.commit_message_model(), cx)
4075            .is_some();
4076        let can_commit = self.can_commit();
4077
4078        let editor_focus_handle = self.commit_editor.focus_handle(cx);
4079
4080        Some(
4081            IconButton::new("generate-commit-message", IconName::AiEdit)
4082                .shape(ui::IconButtonShape::Square)
4083                .icon_color(if has_commit_model_configuration_error {
4084                    Color::Disabled
4085                } else {
4086                    Color::Muted
4087                })
4088                .tooltip(move |_window, cx| {
4089                    if !can_commit {
4090                        Tooltip::simple("No Changes to Commit", cx)
4091                    } else if has_commit_model_configuration_error {
4092                        Tooltip::simple("Configure an LLM provider to generate commit messages", cx)
4093                    } else {
4094                        Tooltip::for_action_in(
4095                            "Generate Commit Message",
4096                            &git::GenerateCommitMessage,
4097                            &editor_focus_handle,
4098                            cx,
4099                        )
4100                    }
4101                })
4102                .disabled(!can_commit || has_commit_model_configuration_error)
4103                .on_click(cx.listener(move |this, _event, _window, cx| {
4104                    this.generate_commit_message(cx);
4105                }))
4106                .into_any_element(),
4107        )
4108    }
4109
4110    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
4111        let potential_co_authors = self.potential_co_authors(cx);
4112
4113        let (tooltip_label, icon) = if self.add_coauthors {
4114            ("Remove co-authored-by", IconName::Person)
4115        } else {
4116            ("Add co-authored-by", IconName::UserCheck)
4117        };
4118
4119        if potential_co_authors.is_empty() {
4120            None
4121        } else {
4122            Some(
4123                IconButton::new("co-authors", icon)
4124                    .shape(ui::IconButtonShape::Square)
4125                    .icon_color(Color::Disabled)
4126                    .selected_icon_color(Color::Selected)
4127                    .toggle_state(self.add_coauthors)
4128                    .tooltip(move |_, cx| {
4129                        let title = format!(
4130                            "{}:{}{}",
4131                            tooltip_label,
4132                            if potential_co_authors.len() == 1 {
4133                                ""
4134                            } else {
4135                                "\n"
4136                            },
4137                            potential_co_authors
4138                                .iter()
4139                                .map(|(name, email)| format!(" {} <{}>", name, email))
4140                                .join("\n")
4141                        );
4142                        Tooltip::simple(title, cx)
4143                    })
4144                    .on_click(cx.listener(|this, _, _, cx| {
4145                        this.add_coauthors = !this.add_coauthors;
4146                        cx.notify();
4147                    }))
4148                    .into_any_element(),
4149            )
4150        }
4151    }
4152
4153    fn render_git_commit_menu(
4154        &self,
4155        id: impl Into<ElementId>,
4156        keybinding_target: Option<FocusHandle>,
4157        cx: &mut Context<Self>,
4158    ) -> impl IntoElement {
4159        PopoverMenu::new(id.into())
4160            .trigger(
4161                ui::ButtonLike::new_rounded_right("commit-split-button-right")
4162                    .layer(ui::ElevationIndex::ModalSurface)
4163                    .size(ButtonSize::None)
4164                    .child(
4165                        h_flex()
4166                            .px_1()
4167                            .h_full()
4168                            .justify_center()
4169                            .border_l_1()
4170                            .border_color(cx.theme().colors().border)
4171                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
4172                    ),
4173            )
4174            .menu({
4175                let git_panel = cx.entity();
4176                let has_previous_commit = self.head_commit(cx).is_some();
4177                let amend = self.amend_pending();
4178                let signoff = self.signoff_enabled;
4179
4180                move |window, cx| {
4181                    Some(ContextMenu::build(window, cx, |context_menu, _, _| {
4182                        context_menu
4183                            .when_some(keybinding_target.clone(), |el, keybinding_target| {
4184                                el.context(keybinding_target)
4185                            })
4186                            .when(has_previous_commit, |this| {
4187                                this.toggleable_entry(
4188                                    "Amend",
4189                                    amend,
4190                                    IconPosition::Start,
4191                                    Some(Box::new(Amend)),
4192                                    {
4193                                        let git_panel = git_panel.downgrade();
4194                                        move |_, cx| {
4195                                            git_panel
4196                                                .update(cx, |git_panel, cx| {
4197                                                    git_panel.toggle_amend_pending(cx);
4198                                                })
4199                                                .ok();
4200                                        }
4201                                    },
4202                                )
4203                            })
4204                            .toggleable_entry(
4205                                "Signoff",
4206                                signoff,
4207                                IconPosition::Start,
4208                                Some(Box::new(Signoff)),
4209                                move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
4210                            )
4211                    }))
4212                }
4213            })
4214            .anchor(Corner::TopRight)
4215    }
4216
4217    pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
4218        if self.has_unstaged_conflicts() {
4219            (false, "You must resolve conflicts before committing")
4220        } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending {
4221            (false, "No changes to commit")
4222        } else if self.pending_commit.is_some() {
4223            (false, "Commit in progress")
4224        } else if !self.has_commit_message(cx) {
4225            (false, "No commit message")
4226        } else if !self.has_write_access(cx) {
4227            (false, "You do not have write access to this project")
4228        } else {
4229            (true, self.commit_button_title())
4230        }
4231    }
4232
4233    pub fn commit_button_title(&self) -> &'static str {
4234        if self.amend_pending {
4235            if self.has_staged_changes() {
4236                "Amend"
4237            } else if self.has_tracked_changes() {
4238                "Amend Tracked"
4239            } else {
4240                "Amend"
4241            }
4242        } else if self.has_staged_changes() {
4243            "Commit"
4244        } else {
4245            "Commit Tracked"
4246        }
4247    }
4248
4249    fn expand_commit_editor(
4250        &mut self,
4251        _: &git::ExpandCommitEditor,
4252        window: &mut Window,
4253        cx: &mut Context<Self>,
4254    ) {
4255        let workspace = self.workspace.clone();
4256        window.defer(cx, move |window, cx| {
4257            workspace
4258                .update(cx, |workspace, cx| {
4259                    CommitModal::toggle(workspace, None, window, cx)
4260                })
4261                .ok();
4262        })
4263    }
4264
4265    fn render_panel_header(
4266        &self,
4267        window: &mut Window,
4268        cx: &mut Context<Self>,
4269    ) -> Option<impl IntoElement> {
4270        self.active_repository.as_ref()?;
4271
4272        let (text, action, stage, tooltip) =
4273            if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
4274                ("Unstage All", UnstageAll.boxed_clone(), false, "git reset")
4275            } else {
4276                ("Stage All", StageAll.boxed_clone(), true, "git add --all")
4277            };
4278
4279        let change_string = match self.changes_count {
4280            0 => "No Changes".to_string(),
4281            1 => "1 Change".to_string(),
4282            count => format!("{} Changes", count),
4283        };
4284
4285        Some(
4286            self.panel_header_container(window, cx)
4287                .px_2()
4288                .justify_between()
4289                .child(
4290                    panel_button(change_string)
4291                        .color(Color::Muted)
4292                        .tooltip(Tooltip::for_action_title_in(
4293                            "Open Diff",
4294                            &Diff,
4295                            &self.focus_handle,
4296                        ))
4297                        .on_click(|_, _, cx| {
4298                            cx.defer(|cx| {
4299                                cx.dispatch_action(&Diff);
4300                            })
4301                        }),
4302                )
4303                .child(
4304                    h_flex()
4305                        .gap_1()
4306                        .child(self.render_overflow_menu("overflow_menu"))
4307                        .child(
4308                            panel_filled_button(text)
4309                                .tooltip(Tooltip::for_action_title_in(
4310                                    tooltip,
4311                                    action.as_ref(),
4312                                    &self.focus_handle,
4313                                ))
4314                                .disabled(self.entry_count == 0)
4315                                .on_click({
4316                                    let git_panel = cx.weak_entity();
4317                                    move |_, _, cx| {
4318                                        git_panel
4319                                            .update(cx, |git_panel, cx| {
4320                                                git_panel.change_all_files_stage(stage, cx);
4321                                            })
4322                                            .ok();
4323                                    }
4324                                }),
4325                        ),
4326                ),
4327        )
4328    }
4329
4330    pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4331        let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
4332        if !self.can_push_and_pull(cx) {
4333            return None;
4334        }
4335        Some(
4336            h_flex()
4337                .gap_1()
4338                .flex_shrink_0()
4339                .when_some(branch, |this, branch| {
4340                    let focus_handle = Some(self.focus_handle(cx));
4341
4342                    this.children(render_remote_button(
4343                        "remote-button",
4344                        &branch,
4345                        focus_handle,
4346                        true,
4347                    ))
4348                })
4349                .into_any_element(),
4350        )
4351    }
4352
4353    pub fn render_footer(
4354        &self,
4355        window: &mut Window,
4356        cx: &mut Context<Self>,
4357    ) -> Option<impl IntoElement> {
4358        let active_repository = self.active_repository.clone()?;
4359        let panel_editor_style = panel_editor_style(true, window, cx);
4360        let enable_coauthors = self.render_co_authors(cx);
4361
4362        let editor_focus_handle = self.commit_editor.focus_handle(cx);
4363        let expand_tooltip_focus_handle = editor_focus_handle;
4364
4365        let branch = active_repository.read(cx).branch.clone();
4366        let head_commit = active_repository.read(cx).head_commit.clone();
4367
4368        let footer_size = px(32.);
4369        let gap = px(9.0);
4370        let max_height = panel_editor_style
4371            .text
4372            .line_height_in_pixels(window.rem_size())
4373            * MAX_PANEL_EDITOR_LINES
4374            + gap;
4375
4376        let git_panel = cx.entity();
4377        let display_name = SharedString::from(Arc::from(
4378            active_repository
4379                .read(cx)
4380                .display_name()
4381                .trim_end_matches("/"),
4382        ));
4383        let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
4384            editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
4385        });
4386
4387        let footer = v_flex()
4388            .child(PanelRepoFooter::new(
4389                display_name,
4390                branch,
4391                head_commit,
4392                Some(git_panel),
4393            ))
4394            .child(
4395                panel_editor_container(window, cx)
4396                    .id("commit-editor-container")
4397                    .relative()
4398                    .w_full()
4399                    .h(max_height + footer_size)
4400                    .border_t_1()
4401                    .border_color(cx.theme().colors().border)
4402                    .cursor_text()
4403                    .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
4404                        window.focus(&this.commit_editor.focus_handle(cx), cx);
4405                    }))
4406                    .child(
4407                        h_flex()
4408                            .id("commit-footer")
4409                            .border_t_1()
4410                            .when(editor_is_long, |el| {
4411                                el.border_color(cx.theme().colors().border_variant)
4412                            })
4413                            .absolute()
4414                            .bottom_0()
4415                            .left_0()
4416                            .w_full()
4417                            .px_2()
4418                            .h(footer_size)
4419                            .flex_none()
4420                            .justify_between()
4421                            .child(
4422                                self.render_generate_commit_message_button(cx)
4423                                    .unwrap_or_else(|| div().into_any_element()),
4424                            )
4425                            .child(
4426                                h_flex()
4427                                    .gap_0p5()
4428                                    .children(enable_coauthors)
4429                                    .child(self.render_commit_button(cx)),
4430                            ),
4431                    )
4432                    .child(
4433                        div()
4434                            .pr_2p5()
4435                            .on_action(|&zed_actions::editor::MoveUp, _, cx| {
4436                                cx.stop_propagation();
4437                            })
4438                            .on_action(|&zed_actions::editor::MoveDown, _, cx| {
4439                                cx.stop_propagation();
4440                            })
4441                            .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
4442                    )
4443                    .child(
4444                        h_flex()
4445                            .absolute()
4446                            .top_2()
4447                            .right_2()
4448                            .opacity(0.5)
4449                            .hover(|this| this.opacity(1.0))
4450                            .child(
4451                                panel_icon_button("expand-commit-editor", IconName::Maximize)
4452                                    .icon_size(IconSize::Small)
4453                                    .size(ui::ButtonSize::Default)
4454                                    .tooltip(move |_window, cx| {
4455                                        Tooltip::for_action_in(
4456                                            "Open Commit Modal",
4457                                            &git::ExpandCommitEditor,
4458                                            &expand_tooltip_focus_handle,
4459                                            cx,
4460                                        )
4461                                    })
4462                                    .on_click(cx.listener({
4463                                        move |_, _, window, cx| {
4464                                            window.dispatch_action(
4465                                                git::ExpandCommitEditor.boxed_clone(),
4466                                                cx,
4467                                            )
4468                                        }
4469                                    })),
4470                            ),
4471                    ),
4472            );
4473
4474        Some(footer)
4475    }
4476
4477    fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4478        let (can_commit, tooltip) = self.configure_commit_button(cx);
4479        let title = self.commit_button_title();
4480        let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
4481        let amend = self.amend_pending();
4482        let signoff = self.signoff_enabled;
4483
4484        let label_color = if self.pending_commit.is_some() {
4485            Color::Disabled
4486        } else {
4487            Color::Default
4488        };
4489
4490        div()
4491            .id("commit-wrapper")
4492            .on_hover(cx.listener(move |this, hovered, _, cx| {
4493                this.show_placeholders =
4494                    *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
4495                cx.notify()
4496            }))
4497            .child(SplitButton::new(
4498                ButtonLike::new_rounded_left(ElementId::Name(
4499                    format!("split-button-left-{}", title).into(),
4500                ))
4501                .layer(ElevationIndex::ModalSurface)
4502                .size(ButtonSize::Compact)
4503                .child(
4504                    Label::new(title)
4505                        .size(LabelSize::Small)
4506                        .color(label_color)
4507                        .mr_0p5(),
4508                )
4509                .on_click({
4510                    let git_panel = cx.weak_entity();
4511                    move |_, window, cx| {
4512                        telemetry::event!("Git Committed", source = "Git Panel");
4513                        git_panel
4514                            .update(cx, |git_panel, cx| {
4515                                git_panel.commit_changes(
4516                                    CommitOptions { amend, signoff },
4517                                    window,
4518                                    cx,
4519                                );
4520                            })
4521                            .ok();
4522                    }
4523                })
4524                .disabled(!can_commit || self.modal_open)
4525                .tooltip({
4526                    let handle = commit_tooltip_focus_handle.clone();
4527                    move |_window, cx| {
4528                        if can_commit {
4529                            Tooltip::with_meta_in(
4530                                tooltip,
4531                                Some(if amend { &git::Amend } else { &git::Commit }),
4532                                format!(
4533                                    "git commit{}{}",
4534                                    if amend { " --amend" } else { "" },
4535                                    if signoff { " --signoff" } else { "" }
4536                                ),
4537                                &handle.clone(),
4538                                cx,
4539                            )
4540                        } else {
4541                            Tooltip::simple(tooltip, cx)
4542                        }
4543                    }
4544                }),
4545                self.render_git_commit_menu(
4546                    ElementId::Name(format!("split-button-right-{}", title).into()),
4547                    Some(commit_tooltip_focus_handle),
4548                    cx,
4549                )
4550                .into_any_element(),
4551            ))
4552    }
4553
4554    fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
4555        h_flex()
4556            .py_1p5()
4557            .px_2()
4558            .gap_1p5()
4559            .justify_between()
4560            .border_t_1()
4561            .border_color(cx.theme().colors().border.opacity(0.8))
4562            .child(
4563                div()
4564                    .flex_grow()
4565                    .overflow_hidden()
4566                    .max_w(relative(0.85))
4567                    .child(
4568                        Label::new("This will update your most recent commit.")
4569                            .size(LabelSize::Small)
4570                            .truncate(),
4571                    ),
4572            )
4573            .child(
4574                panel_button("Cancel")
4575                    .size(ButtonSize::Default)
4576                    .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
4577            )
4578    }
4579
4580    fn render_previous_commit(
4581        &self,
4582        window: &mut Window,
4583        cx: &mut Context<Self>,
4584    ) -> Option<impl IntoElement> {
4585        let active_repository = self.active_repository.as_ref()?;
4586        let branch = active_repository.read(cx).branch.as_ref()?;
4587        let commit = branch.most_recent_commit.as_ref()?.clone();
4588        let workspace = self.workspace.clone();
4589        let this = cx.entity();
4590
4591        Some(
4592            h_flex()
4593                .p_1p5()
4594                .gap_1p5()
4595                .justify_between()
4596                .border_t_1()
4597                .border_color(cx.theme().colors().border.opacity(0.8))
4598                .child(
4599                    div()
4600                        .id("commit-msg-hover")
4601                        .cursor_pointer()
4602                        .px_1()
4603                        .rounded_sm()
4604                        .line_clamp(1)
4605                        .hover(|s| s.bg(cx.theme().colors().element_hover))
4606                        .child(
4607                            Label::new(commit.subject.clone())
4608                                .size(LabelSize::Small)
4609                                .truncate(),
4610                        )
4611                        .on_click({
4612                            let commit = commit.clone();
4613                            let repo = active_repository.downgrade();
4614                            move |_, window, cx| {
4615                                CommitView::open(
4616                                    commit.sha.to_string(),
4617                                    repo.clone(),
4618                                    workspace.clone(),
4619                                    None,
4620                                    None,
4621                                    window,
4622                                    cx,
4623                                );
4624                            }
4625                        })
4626                        .hoverable_tooltip({
4627                            let repo = active_repository.clone();
4628                            move |window, cx| {
4629                                GitPanelMessageTooltip::new(
4630                                    this.clone(),
4631                                    commit.sha.clone(),
4632                                    repo.clone(),
4633                                    window,
4634                                    cx,
4635                                )
4636                                .into()
4637                            }
4638                        }),
4639                )
4640                .child(
4641                    h_flex()
4642                        .gap_0p5()
4643                        .when(commit.has_parent, |this| {
4644                            let has_unstaged = self.has_unstaged_changes();
4645                            this.child(
4646                                panel_icon_button("undo", IconName::Undo)
4647                                    .icon_size(IconSize::Small)
4648                                    .tooltip(move |_window, cx| {
4649                                        Tooltip::with_meta(
4650                                            "Uncommit",
4651                                            Some(&git::Uncommit),
4652                                            if has_unstaged {
4653                                                "git reset HEAD^ --soft"
4654                                            } else {
4655                                                "git reset HEAD^"
4656                                            },
4657                                            cx,
4658                                        )
4659                                    })
4660                                    .on_click(
4661                                        cx.listener(|this, _, window, cx| {
4662                                            this.uncommit(window, cx)
4663                                        }),
4664                                    ),
4665                            )
4666                        })
4667                        .when(window.is_action_available(&Open, cx), |this| {
4668                            this.child(
4669                                panel_icon_button("git-graph-button", IconName::GitGraph)
4670                                    .icon_size(IconSize::Small)
4671                                    .tooltip(|_window, cx| {
4672                                        Tooltip::for_action("Open Git Graph", &Open, cx)
4673                                    })
4674                                    .on_click(|_, window, cx| {
4675                                        window.dispatch_action(Open.boxed_clone(), cx)
4676                                    }),
4677                            )
4678                        }),
4679                ),
4680        )
4681    }
4682
4683    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4684        let has_repo = self.active_repository.is_some();
4685        let has_no_repo = self.active_repository.is_none();
4686        let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
4687
4688        let should_show_branch_diff =
4689            has_repo && self.changes_count == 0 && !self.is_on_main_branch(cx);
4690
4691        let label = if has_repo {
4692            "No changes to commit"
4693        } else {
4694            "No Git repositories"
4695        };
4696
4697        v_flex()
4698            .gap_1p5()
4699            .flex_1()
4700            .items_center()
4701            .justify_center()
4702            .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
4703            .when(has_no_repo && worktree_count > 0, |this| {
4704                this.child(
4705                    panel_filled_button("Initialize Repository")
4706                        .tooltip(Tooltip::for_action_title_in(
4707                            "git init",
4708                            &git::Init,
4709                            &self.focus_handle,
4710                        ))
4711                        .on_click(move |_, _, cx| {
4712                            cx.defer(move |cx| {
4713                                cx.dispatch_action(&git::Init);
4714                            })
4715                        }),
4716                )
4717            })
4718            .when(should_show_branch_diff, |this| {
4719                this.child(
4720                    panel_filled_button("View Branch Diff")
4721                        .tooltip(move |_, cx| {
4722                            Tooltip::with_meta(
4723                                "Branch Diff",
4724                                Some(&BranchDiff),
4725                                "Show diff between working directory and default branch",
4726                                cx,
4727                            )
4728                        })
4729                        .on_click(move |_, _, cx| {
4730                            cx.defer(move |cx| {
4731                                cx.dispatch_action(&BranchDiff);
4732                            })
4733                        }),
4734                )
4735            })
4736    }
4737
4738    fn is_on_main_branch(&self, cx: &Context<Self>) -> bool {
4739        let Some(repo) = self.active_repository.as_ref() else {
4740            return false;
4741        };
4742
4743        let Some(branch) = repo.read(cx).branch.as_ref() else {
4744            return false;
4745        };
4746
4747        let branch_name = branch.name();
4748        matches!(branch_name, "main" | "master")
4749    }
4750
4751    fn render_buffer_header_controls(
4752        &self,
4753        entity: &Entity<Self>,
4754        file: &Arc<dyn File>,
4755        _: &Window,
4756        cx: &App,
4757    ) -> Option<AnyElement> {
4758        let repo = self.active_repository.as_ref()?.read(cx);
4759        let project_path = (file.worktree_id(cx), file.path().clone()).into();
4760        let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
4761        let ix = self.entry_by_path(&repo_path)?;
4762        let entry = self.entries.get(ix)?;
4763
4764        let is_staging_or_staged = repo
4765            .pending_ops_for_path(&repo_path)
4766            .map(|ops| ops.staging() || ops.staged())
4767            .or_else(|| {
4768                repo.status_for_path(&repo_path)
4769                    .and_then(|status| status.status.staging().as_bool())
4770            })
4771            .or_else(|| {
4772                entry
4773                    .status_entry()
4774                    .and_then(|entry| entry.staging.as_bool())
4775            });
4776
4777        let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into())
4778            .disabled(!self.has_write_access(cx))
4779            .fill()
4780            .elevation(ElevationIndex::Surface)
4781            .on_click({
4782                let entry = entry.clone();
4783                let git_panel = entity.downgrade();
4784                move |_, window, cx| {
4785                    git_panel
4786                        .update(cx, |this, cx| {
4787                            this.toggle_staged_for_entry(&entry, window, cx);
4788                            cx.stop_propagation();
4789                        })
4790                        .ok();
4791                }
4792            });
4793        Some(
4794            h_flex()
4795                .id("start-slot")
4796                .text_lg()
4797                .child(checkbox)
4798                .on_mouse_down(MouseButton::Left, |_, _, cx| {
4799                    // prevent the list item active state triggering when toggling checkbox
4800                    cx.stop_propagation();
4801                })
4802                .into_any_element(),
4803        )
4804    }
4805
4806    fn render_entries(
4807        &self,
4808        has_write_access: bool,
4809        repo: Entity<Repository>,
4810        window: &mut Window,
4811        cx: &mut Context<Self>,
4812    ) -> impl IntoElement {
4813        let (is_tree_view, entry_count) = match &self.view_mode {
4814            GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()),
4815            GitPanelViewMode::Flat => (false, self.entries.len()),
4816        };
4817        let repo = repo.downgrade();
4818
4819        v_flex()
4820            .flex_1()
4821            .size_full()
4822            .overflow_hidden()
4823            .relative()
4824            .child(
4825                h_flex()
4826                    .flex_1()
4827                    .size_full()
4828                    .relative()
4829                    .overflow_hidden()
4830                    .child(
4831                        uniform_list(
4832                            "entries",
4833                            entry_count,
4834                            cx.processor(move |this, range: Range<usize>, window, cx| {
4835                                let Some(repo) = repo.upgrade() else {
4836                                    return Vec::new();
4837                                };
4838                                let repo = repo.read(cx);
4839
4840                                let mut items = Vec::with_capacity(range.end - range.start);
4841
4842                                for ix in range.into_iter().map(|ix| match &this.view_mode {
4843                                    GitPanelViewMode::Tree(state) => state.logical_indices[ix],
4844                                    GitPanelViewMode::Flat => ix,
4845                                }) {
4846                                    match &this.entries.get(ix) {
4847                                        Some(GitListEntry::Status(entry)) => {
4848                                            items.push(this.render_status_entry(
4849                                                ix,
4850                                                entry,
4851                                                0,
4852                                                has_write_access,
4853                                                repo,
4854                                                window,
4855                                                cx,
4856                                            ));
4857                                        }
4858                                        Some(GitListEntry::TreeStatus(entry)) => {
4859                                            items.push(this.render_status_entry(
4860                                                ix,
4861                                                &entry.entry,
4862                                                entry.depth,
4863                                                has_write_access,
4864                                                repo,
4865                                                window,
4866                                                cx,
4867                                            ));
4868                                        }
4869                                        Some(GitListEntry::Directory(entry)) => {
4870                                            items.push(this.render_directory_entry(
4871                                                ix,
4872                                                entry,
4873                                                has_write_access,
4874                                                window,
4875                                                cx,
4876                                            ));
4877                                        }
4878                                        Some(GitListEntry::Header(header)) => {
4879                                            items.push(this.render_list_header(
4880                                                ix,
4881                                                header,
4882                                                has_write_access,
4883                                                window,
4884                                                cx,
4885                                            ));
4886                                        }
4887                                        None => {}
4888                                    }
4889                                }
4890
4891                                items
4892                            }),
4893                        )
4894                        .when(is_tree_view, |list| {
4895                            let indent_size = px(TREE_INDENT);
4896                            list.with_decoration(
4897                                ui::indent_guides(indent_size, IndentGuideColors::panel(cx))
4898                                    .with_compute_indents_fn(
4899                                        cx.entity(),
4900                                        |this, range, _window, _cx| {
4901                                            this.compute_visible_depths(range)
4902                                        },
4903                                    )
4904                                    .with_render_fn(cx.entity(), |_, params, _, _| {
4905                                        // Magic number to align the tree item is 3 here
4906                                        // because we're using 12px as the left-side padding
4907                                        // and 3 makes the alignment work with the bounding box of the icon
4908                                        let left_offset = px(TREE_INDENT + 3_f32);
4909                                        let indent_size = params.indent_size;
4910                                        let item_height = params.item_height;
4911
4912                                        params
4913                                            .indent_guides
4914                                            .into_iter()
4915                                            .map(|layout| {
4916                                                let bounds = Bounds::new(
4917                                                    point(
4918                                                        layout.offset.x * indent_size + left_offset,
4919                                                        layout.offset.y * item_height,
4920                                                    ),
4921                                                    size(px(1.), layout.length * item_height),
4922                                                );
4923                                                RenderedIndentGuide {
4924                                                    bounds,
4925                                                    layout,
4926                                                    is_active: false,
4927                                                    hitbox: None,
4928                                                }
4929                                            })
4930                                            .collect()
4931                                    }),
4932                            )
4933                        })
4934                        .size_full()
4935                        .flex_grow()
4936                        .with_width_from_item(self.max_width_item_index)
4937                        .track_scroll(&self.scroll_handle),
4938                    )
4939                    .on_mouse_down(
4940                        MouseButton::Right,
4941                        cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4942                            this.deploy_panel_context_menu(event.position, window, cx)
4943                        }),
4944                    )
4945                    .custom_scrollbars(
4946                        Scrollbars::for_settings::<GitPanelSettings>()
4947                            .tracked_scroll_handle(&self.scroll_handle)
4948                            .with_track_along(
4949                                ScrollAxes::Horizontal,
4950                                cx.theme().colors().panel_background,
4951                            ),
4952                        window,
4953                        cx,
4954                    ),
4955            )
4956    }
4957
4958    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
4959        Label::new(label.into()).color(color)
4960    }
4961
4962    fn list_item_height(&self) -> Rems {
4963        rems(1.75)
4964    }
4965
4966    fn render_list_header(
4967        &self,
4968        ix: usize,
4969        header: &GitHeaderEntry,
4970        _: bool,
4971        _: &Window,
4972        _: &Context<Self>,
4973    ) -> AnyElement {
4974        let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
4975
4976        h_flex()
4977            .id(id)
4978            .h(self.list_item_height())
4979            .w_full()
4980            .items_end()
4981            .px_3()
4982            .pb_1()
4983            .child(
4984                Label::new(header.title())
4985                    .color(Color::Muted)
4986                    .size(LabelSize::Small)
4987                    .line_height_style(LineHeightStyle::UiLabel)
4988                    .single_line(),
4989            )
4990            .into_any_element()
4991    }
4992
4993    pub fn load_commit_details(
4994        &self,
4995        sha: String,
4996        cx: &mut Context<Self>,
4997    ) -> Task<anyhow::Result<CommitDetails>> {
4998        let Some(repo) = self.active_repository.clone() else {
4999            return Task::ready(Err(anyhow::anyhow!("no active repo")));
5000        };
5001        repo.update(cx, |repo, cx| {
5002            let show = repo.show(sha);
5003            cx.spawn(async move |_, _| show.await?)
5004        })
5005    }
5006
5007    fn deploy_entry_context_menu(
5008        &mut self,
5009        position: Point<Pixels>,
5010        ix: usize,
5011        window: &mut Window,
5012        cx: &mut Context<Self>,
5013    ) {
5014        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
5015            return;
5016        };
5017        let stage_title = if entry.status.staging().is_fully_staged() {
5018            "Unstage File"
5019        } else {
5020            "Stage File"
5021        };
5022        let restore_title = if entry.status.is_created() {
5023            "Trash File"
5024        } else {
5025            "Discard Changes"
5026        };
5027        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
5028            let is_created = entry.status.is_created();
5029            context_menu
5030                .context(self.focus_handle.clone())
5031                .action(stage_title, ToggleStaged.boxed_clone())
5032                .action(restore_title, git::RestoreFile::default().boxed_clone())
5033                .action_disabled_when(
5034                    !is_created,
5035                    "Add to .gitignore",
5036                    git::AddToGitignore.boxed_clone(),
5037                )
5038                .separator()
5039                .action("Open Diff", menu::Confirm.boxed_clone())
5040                .action("Open File", menu::SecondaryConfirm.boxed_clone())
5041                .separator()
5042                .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
5043        });
5044        self.selected_entry = Some(ix);
5045        self.set_context_menu(context_menu, position, window, cx);
5046    }
5047
5048    fn deploy_panel_context_menu(
5049        &mut self,
5050        position: Point<Pixels>,
5051        window: &mut Window,
5052        cx: &mut Context<Self>,
5053    ) {
5054        let context_menu = git_panel_context_menu(
5055            self.focus_handle.clone(),
5056            GitMenuState {
5057                has_tracked_changes: self.has_tracked_changes(),
5058                has_staged_changes: self.has_staged_changes(),
5059                has_unstaged_changes: self.has_unstaged_changes(),
5060                has_new_changes: self.new_count > 0,
5061                sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
5062                has_stash_items: self.stash_entries.entries.len() > 0,
5063                tree_view: GitPanelSettings::get_global(cx).tree_view,
5064            },
5065            window,
5066            cx,
5067        );
5068        self.set_context_menu(context_menu, position, window, cx);
5069    }
5070
5071    fn set_context_menu(
5072        &mut self,
5073        context_menu: Entity<ContextMenu>,
5074        position: Point<Pixels>,
5075        window: &Window,
5076        cx: &mut Context<Self>,
5077    ) {
5078        let subscription = cx.subscribe_in(
5079            &context_menu,
5080            window,
5081            |this, _, _: &DismissEvent, window, cx| {
5082                if this.context_menu.as_ref().is_some_and(|context_menu| {
5083                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
5084                }) {
5085                    cx.focus_self(window);
5086                }
5087                this.context_menu.take();
5088                cx.notify();
5089            },
5090        );
5091        self.context_menu = Some((context_menu, position, subscription));
5092        cx.notify();
5093    }
5094
5095    fn render_status_entry(
5096        &self,
5097        ix: usize,
5098        entry: &GitStatusEntry,
5099        depth: usize,
5100        has_write_access: bool,
5101        repo: &Repository,
5102        window: &Window,
5103        cx: &Context<Self>,
5104    ) -> AnyElement {
5105        let tree_view = GitPanelSettings::get_global(cx).tree_view;
5106        let path_style = self.project.read(cx).path_style(cx);
5107        let git_path_style = ProjectSettings::get_global(cx).git.path_style;
5108        let display_name = entry.display_name(path_style);
5109
5110        let selected = self.selected_entry == Some(ix);
5111        let marked = self.marked_entries.contains(&ix);
5112        let status_style = GitPanelSettings::get_global(cx).status_style;
5113        let status = entry.status;
5114
5115        let has_conflict = status.is_conflicted();
5116        let is_modified = status.is_modified();
5117        let is_deleted = status.is_deleted();
5118        let is_created = status.is_created();
5119
5120        let label_color = if status_style == StatusStyle::LabelColor {
5121            if has_conflict {
5122                Color::VersionControlConflict
5123            } else if is_created {
5124                Color::VersionControlAdded
5125            } else if is_modified {
5126                Color::VersionControlModified
5127            } else if is_deleted {
5128                // We don't want a bunch of red labels in the list
5129                Color::Disabled
5130            } else {
5131                Color::VersionControlAdded
5132            }
5133        } else {
5134            Color::Default
5135        };
5136
5137        let path_color = if status.is_deleted() {
5138            Color::Disabled
5139        } else {
5140            Color::Muted
5141        };
5142
5143        let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
5144        let checkbox_wrapper_id: ElementId =
5145            ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
5146        let checkbox_id: ElementId =
5147            ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
5148
5149        let stage_status = GitPanel::stage_status_for_entry(entry, &repo);
5150        let mut is_staged: ToggleState = match stage_status {
5151            StageStatus::Staged => ToggleState::Selected,
5152            StageStatus::Unstaged => ToggleState::Unselected,
5153            StageStatus::PartiallyStaged => ToggleState::Indeterminate,
5154        };
5155        if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
5156            is_staged = ToggleState::Selected;
5157        }
5158
5159        let handle = cx.weak_entity();
5160
5161        let selected_bg_alpha = 0.08;
5162        let marked_bg_alpha = 0.12;
5163        let state_opacity_step = 0.04;
5164
5165        let info_color = cx.theme().status().info;
5166
5167        let base_bg = match (selected, marked) {
5168            (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
5169            (true, false) => info_color.alpha(selected_bg_alpha),
5170            (false, true) => info_color.alpha(marked_bg_alpha),
5171            _ => cx.theme().colors().ghost_element_background,
5172        };
5173
5174        let (hover_bg, active_bg) = if selected {
5175            (
5176                info_color.alpha(selected_bg_alpha + state_opacity_step),
5177                info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
5178            )
5179        } else {
5180            (
5181                cx.theme().colors().ghost_element_hover,
5182                cx.theme().colors().ghost_element_active,
5183            )
5184        };
5185
5186        let name_row = h_flex()
5187            .min_w_0()
5188            .flex_1()
5189            .gap_1()
5190            .child(git_status_icon(status))
5191            .map(|this| {
5192                if tree_view {
5193                    this.pl(px(depth as f32 * TREE_INDENT)).child(
5194                        self.entry_label(display_name, label_color)
5195                            .when(status.is_deleted(), Label::strikethrough)
5196                            .truncate(),
5197                    )
5198                } else {
5199                    this.child(self.path_formatted(
5200                        entry.parent_dir(path_style),
5201                        path_color,
5202                        display_name,
5203                        label_color,
5204                        path_style,
5205                        git_path_style,
5206                        status.is_deleted(),
5207                    ))
5208                }
5209            });
5210
5211        let id_for_diff_stat = id.clone();
5212
5213        h_flex()
5214            .id(id)
5215            .h(self.list_item_height())
5216            .w_full()
5217            .pl_3()
5218            .pr_1()
5219            .gap_1p5()
5220            .border_1()
5221            .border_r_2()
5222            .when(selected && self.focus_handle.is_focused(window), |el| {
5223                el.border_color(cx.theme().colors().panel_focused_border)
5224            })
5225            .bg(base_bg)
5226            .hover(|s| s.bg(hover_bg))
5227            .active(|s| s.bg(active_bg))
5228            .child(name_row)
5229            .when(GitPanelSettings::get_global(cx).diff_stats, |el| {
5230                el.when_some(
5231                    self.diff_stats.get(&entry.repo_path).copied(),
5232                    move |this, stat| {
5233                        let id = format!("diff-stat-{}", id_for_diff_stat);
5234                        this.child(ui::DiffStat::new(
5235                            id,
5236                            stat.added as usize,
5237                            stat.deleted as usize,
5238                        ))
5239                    },
5240                )
5241            })
5242            .child(
5243                div()
5244                    .id(checkbox_wrapper_id)
5245                    .flex_none()
5246                    .occlude()
5247                    .cursor_pointer()
5248                    .child(
5249                        Checkbox::new(checkbox_id, is_staged)
5250                            .disabled(!has_write_access)
5251                            .fill()
5252                            .elevation(ElevationIndex::Surface)
5253                            .on_click_ext({
5254                                let entry = entry.clone();
5255                                let this = cx.weak_entity();
5256                                move |_, click, window, cx| {
5257                                    this.update(cx, |this, cx| {
5258                                        if !has_write_access {
5259                                            return;
5260                                        }
5261                                        if click.modifiers().shift {
5262                                            this.stage_bulk(ix, cx);
5263                                        } else {
5264                                            let list_entry =
5265                                                if GitPanelSettings::get_global(cx).tree_view {
5266                                                    GitListEntry::TreeStatus(GitTreeStatusEntry {
5267                                                        entry: entry.clone(),
5268                                                        depth,
5269                                                    })
5270                                                } else {
5271                                                    GitListEntry::Status(entry.clone())
5272                                                };
5273                                            this.toggle_staged_for_entry(&list_entry, window, cx);
5274                                        }
5275                                        cx.stop_propagation();
5276                                    })
5277                                    .ok();
5278                                }
5279                            })
5280                            .tooltip(move |_window, cx| {
5281                                let action = match stage_status {
5282                                    StageStatus::Staged => "Unstage",
5283                                    StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5284                                };
5285                                let tooltip_name = action.to_string();
5286
5287                                Tooltip::for_action(tooltip_name, &ToggleStaged, cx)
5288                            }),
5289                    ),
5290            )
5291            .on_click({
5292                cx.listener(move |this, event: &ClickEvent, window, cx| {
5293                    this.selected_entry = Some(ix);
5294                    cx.notify();
5295                    if event.click_count() > 1 || event.modifiers().secondary() {
5296                        this.open_file(&Default::default(), window, cx)
5297                    } else {
5298                        this.open_diff(&Default::default(), window, cx);
5299                        this.focus_handle.focus(window, cx);
5300                    }
5301                })
5302            })
5303            .on_mouse_down(
5304                MouseButton::Right,
5305                move |event: &MouseDownEvent, window, cx| {
5306                    // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
5307                    if event.button != MouseButton::Right {
5308                        return;
5309                    }
5310
5311                    let Some(this) = handle.upgrade() else {
5312                        return;
5313                    };
5314                    this.update(cx, |this, cx| {
5315                        this.deploy_entry_context_menu(event.position, ix, window, cx);
5316                    });
5317                    cx.stop_propagation();
5318                },
5319            )
5320            .into_any_element()
5321    }
5322
5323    fn render_directory_entry(
5324        &self,
5325        ix: usize,
5326        entry: &GitTreeDirEntry,
5327        has_write_access: bool,
5328        window: &Window,
5329        cx: &Context<Self>,
5330    ) -> AnyElement {
5331        // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that
5332        let selected = self.selected_entry == Some(ix);
5333        let label_color = Color::Muted;
5334
5335        let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into());
5336        let checkbox_id: ElementId =
5337            ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into());
5338        let checkbox_wrapper_id: ElementId =
5339            ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into());
5340
5341        let selected_bg_alpha = 0.08;
5342        let state_opacity_step = 0.04;
5343
5344        let info_color = cx.theme().status().info;
5345        let colors = cx.theme().colors();
5346
5347        let (base_bg, hover_bg, active_bg) = if selected {
5348            (
5349                info_color.alpha(selected_bg_alpha),
5350                info_color.alpha(selected_bg_alpha + state_opacity_step),
5351                info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
5352            )
5353        } else {
5354            (
5355                colors.ghost_element_background,
5356                colors.ghost_element_hover,
5357                colors.ghost_element_active,
5358            )
5359        };
5360
5361        let folder_icon = if entry.expanded {
5362            IconName::FolderOpen
5363        } else {
5364            IconName::Folder
5365        };
5366
5367        let stage_status = if let Some(repo) = &self.active_repository {
5368            self.stage_status_for_directory(entry, repo.read(cx))
5369        } else {
5370            util::debug_panic!(
5371                "Won't have entries to render without an active repository in Git Panel"
5372            );
5373            StageStatus::PartiallyStaged
5374        };
5375
5376        let toggle_state: ToggleState = match stage_status {
5377            StageStatus::Staged => ToggleState::Selected,
5378            StageStatus::Unstaged => ToggleState::Unselected,
5379            StageStatus::PartiallyStaged => ToggleState::Indeterminate,
5380        };
5381
5382        let name_row = h_flex()
5383            .min_w_0()
5384            .gap_1()
5385            .pl(px(entry.depth as f32 * TREE_INDENT))
5386            .child(
5387                Icon::new(folder_icon)
5388                    .size(IconSize::Small)
5389                    .color(Color::Muted),
5390            )
5391            .child(self.entry_label(entry.name.clone(), label_color).truncate());
5392
5393        h_flex()
5394            .id(id)
5395            .h(self.list_item_height())
5396            .min_w_0()
5397            .w_full()
5398            .pl_3()
5399            .pr_1()
5400            .gap_1p5()
5401            .justify_between()
5402            .border_1()
5403            .border_r_2()
5404            .when(selected && self.focus_handle.is_focused(window), |el| {
5405                el.border_color(cx.theme().colors().panel_focused_border)
5406            })
5407            .bg(base_bg)
5408            .hover(|s| s.bg(hover_bg))
5409            .active(|s| s.bg(active_bg))
5410            .child(name_row)
5411            .child(
5412                div()
5413                    .id(checkbox_wrapper_id)
5414                    .flex_none()
5415                    .occlude()
5416                    .cursor_pointer()
5417                    .child(
5418                        Checkbox::new(checkbox_id, toggle_state)
5419                            .disabled(!has_write_access)
5420                            .fill()
5421                            .elevation(ElevationIndex::Surface)
5422                            .on_click({
5423                                let entry = entry.clone();
5424                                let this = cx.weak_entity();
5425                                move |_, window, cx| {
5426                                    this.update(cx, |this, cx| {
5427                                        if !has_write_access {
5428                                            return;
5429                                        }
5430                                        this.toggle_staged_for_entry(
5431                                            &GitListEntry::Directory(entry.clone()),
5432                                            window,
5433                                            cx,
5434                                        );
5435                                        cx.stop_propagation();
5436                                    })
5437                                    .ok();
5438                                }
5439                            })
5440                            .tooltip(move |_window, cx| {
5441                                let action = match stage_status {
5442                                    StageStatus::Staged => "Unstage",
5443                                    StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5444                                };
5445                                Tooltip::simple(format!("{action} folder"), cx)
5446                            }),
5447                    ),
5448            )
5449            .on_click({
5450                let key = entry.key.clone();
5451                cx.listener(move |this, _event: &ClickEvent, window, cx| {
5452                    this.selected_entry = Some(ix);
5453                    this.toggle_directory(&key, window, cx);
5454                })
5455            })
5456            .into_any_element()
5457    }
5458
5459    fn path_formatted(
5460        &self,
5461        directory: Option<String>,
5462        path_color: Color,
5463        file_name: String,
5464        label_color: Color,
5465        path_style: PathStyle,
5466        git_path_style: GitPathStyle,
5467        strikethrough: bool,
5468    ) -> Div {
5469        let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
5470        let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
5471
5472        let file_name = format!("{} ", file_name);
5473
5474        h_flex()
5475            .min_w_0()
5476            .overflow_hidden()
5477            .when(file_path_first, |this| this.flex_row_reverse())
5478            .child(
5479                div().flex_none().child(
5480                    self.entry_label(file_name, label_color)
5481                        .when(strikethrough, Label::strikethrough),
5482                ),
5483            )
5484            .when_some(directory, |this, dir| {
5485                let path_name = if file_name_first {
5486                    dir
5487                } else {
5488                    format!("{dir}{}", path_style.primary_separator())
5489                };
5490
5491                this.child(
5492                    self.entry_label(path_name, path_color)
5493                        .truncate_start()
5494                        .when(strikethrough, Label::strikethrough),
5495                )
5496            })
5497    }
5498
5499    fn has_write_access(&self, cx: &App) -> bool {
5500        !self.project.read(cx).is_read_only(cx)
5501    }
5502
5503    pub fn amend_pending(&self) -> bool {
5504        self.amend_pending
5505    }
5506
5507    /// Sets the pending amend state, ensuring that the original commit message
5508    /// is either saved, when `value` is `true` and there's no pending amend, or
5509    /// restored, when `value` is `false` and there's a pending amend.
5510    pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
5511        if value && !self.amend_pending {
5512            let current_message = self.commit_message_buffer(cx).read(cx).text();
5513            self.original_commit_message = if current_message.trim().is_empty() {
5514                None
5515            } else {
5516                Some(current_message)
5517            };
5518        } else if !value && self.amend_pending {
5519            let message = self.original_commit_message.take().unwrap_or_default();
5520            self.commit_message_buffer(cx).update(cx, |buffer, cx| {
5521                let start = buffer.anchor_before(0);
5522                let end = buffer.anchor_after(buffer.len());
5523                buffer.edit([(start..end, message)], None, cx);
5524            });
5525        }
5526
5527        self.amend_pending = value;
5528        self.serialize(cx);
5529        cx.notify();
5530    }
5531
5532    pub fn signoff_enabled(&self) -> bool {
5533        self.signoff_enabled
5534    }
5535
5536    pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
5537        self.signoff_enabled = value;
5538        self.serialize(cx);
5539        cx.notify();
5540    }
5541
5542    pub fn toggle_signoff_enabled(
5543        &mut self,
5544        _: &Signoff,
5545        _window: &mut Window,
5546        cx: &mut Context<Self>,
5547    ) {
5548        self.set_signoff_enabled(!self.signoff_enabled, cx);
5549    }
5550
5551    pub async fn load(
5552        workspace: WeakEntity<Workspace>,
5553        mut cx: AsyncWindowContext,
5554    ) -> anyhow::Result<Entity<Self>> {
5555        let serialized_panel = match workspace
5556            .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
5557            .ok()
5558            .flatten()
5559        {
5560            Some(serialization_key) => cx
5561                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
5562                .await
5563                .context("loading git panel")
5564                .log_err()
5565                .flatten()
5566                .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
5567                .transpose()
5568                .log_err()
5569                .flatten(),
5570            None => None,
5571        };
5572
5573        workspace.update_in(&mut cx, |workspace, window, cx| {
5574            let panel = GitPanel::new(workspace, window, cx);
5575
5576            if let Some(serialized_panel) = serialized_panel {
5577                panel.update(cx, |panel, cx| {
5578                    panel.width = serialized_panel.width;
5579                    panel.amend_pending = serialized_panel.amend_pending;
5580                    panel.signoff_enabled = serialized_panel.signoff_enabled;
5581                    cx.notify();
5582                })
5583            }
5584
5585            panel
5586        })
5587    }
5588
5589    fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
5590        let Some(op) = self.bulk_staging.as_ref() else {
5591            return;
5592        };
5593        let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else {
5594            return;
5595        };
5596        if let Some(entry) = self.entries.get(index)
5597            && let Some(entry) = entry.status_entry()
5598        {
5599            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
5600        }
5601        if index < anchor_index {
5602            std::mem::swap(&mut index, &mut anchor_index);
5603        }
5604        let entries = self
5605            .entries
5606            .get(anchor_index..=index)
5607            .unwrap_or_default()
5608            .iter()
5609            .filter_map(|entry| entry.status_entry().cloned())
5610            .collect::<Vec<_>>();
5611        self.change_file_stage(true, entries, cx);
5612    }
5613
5614    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
5615        let Some(repo) = self.active_repository.as_ref() else {
5616            return;
5617        };
5618        self.bulk_staging = Some(BulkStaging {
5619            repo_id: repo.read(cx).id,
5620            anchor: path,
5621        });
5622    }
5623
5624    pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
5625        self.set_amend_pending(!self.amend_pending, cx);
5626        if self.amend_pending {
5627            self.load_last_commit_message(cx);
5628        }
5629    }
5630}
5631
5632impl Render for GitPanel {
5633    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5634        let project = self.project.read(cx);
5635        let has_entries = !self.entries.is_empty();
5636        let room = self.workspace.upgrade().and_then(|_workspace| {
5637            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
5638        });
5639
5640        let has_write_access = self.has_write_access(cx);
5641
5642        let has_co_authors = room.is_some_and(|room| {
5643            self.load_local_committer(cx);
5644            let room = room.read(cx);
5645            room.remote_participants()
5646                .values()
5647                .any(|remote_participant| remote_participant.can_write())
5648        });
5649
5650        v_flex()
5651            .id("git_panel")
5652            .key_context(self.dispatch_context(window, cx))
5653            .track_focus(&self.focus_handle)
5654            .when(has_write_access && !project.is_read_only(cx), |this| {
5655                this.on_action(cx.listener(Self::toggle_staged_for_selected))
5656                    .on_action(cx.listener(Self::stage_range))
5657                    .on_action(cx.listener(GitPanel::on_commit))
5658                    .on_action(cx.listener(GitPanel::on_amend))
5659                    .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
5660                    .on_action(cx.listener(Self::stage_all))
5661                    .on_action(cx.listener(Self::unstage_all))
5662                    .on_action(cx.listener(Self::stage_selected))
5663                    .on_action(cx.listener(Self::unstage_selected))
5664                    .on_action(cx.listener(Self::restore_tracked_files))
5665                    .on_action(cx.listener(Self::revert_selected))
5666                    .on_action(cx.listener(Self::add_to_gitignore))
5667                    .on_action(cx.listener(Self::clean_all))
5668                    .on_action(cx.listener(Self::generate_commit_message_action))
5669                    .on_action(cx.listener(Self::stash_all))
5670                    .on_action(cx.listener(Self::stash_pop))
5671            })
5672            .on_action(cx.listener(Self::collapse_selected_entry))
5673            .on_action(cx.listener(Self::expand_selected_entry))
5674            .on_action(cx.listener(Self::select_first))
5675            .on_action(cx.listener(Self::select_next))
5676            .on_action(cx.listener(Self::select_previous))
5677            .on_action(cx.listener(Self::select_last))
5678            .on_action(cx.listener(Self::first_entry))
5679            .on_action(cx.listener(Self::next_entry))
5680            .on_action(cx.listener(Self::previous_entry))
5681            .on_action(cx.listener(Self::last_entry))
5682            .on_action(cx.listener(Self::close_panel))
5683            .on_action(cx.listener(Self::open_diff))
5684            .on_action(cx.listener(Self::open_file))
5685            .on_action(cx.listener(Self::file_history))
5686            .on_action(cx.listener(Self::focus_changes_list))
5687            .on_action(cx.listener(Self::focus_editor))
5688            .on_action(cx.listener(Self::expand_commit_editor))
5689            .when(has_write_access && has_co_authors, |git_panel| {
5690                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
5691            })
5692            .on_action(cx.listener(Self::toggle_sort_by_path))
5693            .on_action(cx.listener(Self::toggle_tree_view))
5694            .size_full()
5695            .overflow_hidden()
5696            .bg(cx.theme().colors().panel_background)
5697            .child(
5698                v_flex()
5699                    .size_full()
5700                    .children(self.render_panel_header(window, cx))
5701                    .map(|this| {
5702                        if let Some(repo) = self.active_repository.clone()
5703                            && has_entries
5704                        {
5705                            this.child(self.render_entries(has_write_access, repo, window, cx))
5706                        } else {
5707                            this.child(self.render_empty_state(cx).into_any_element())
5708                        }
5709                    })
5710                    .children(self.render_footer(window, cx))
5711                    .when(self.amend_pending, |this| {
5712                        this.child(self.render_pending_amend(cx))
5713                    })
5714                    .when(!self.amend_pending, |this| {
5715                        this.children(self.render_previous_commit(window, cx))
5716                    })
5717                    .into_any_element(),
5718            )
5719            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
5720                deferred(
5721                    anchored()
5722                        .position(*position)
5723                        .anchor(Corner::TopLeft)
5724                        .child(menu.clone()),
5725                )
5726                .with_priority(1)
5727            }))
5728    }
5729}
5730
5731impl Focusable for GitPanel {
5732    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
5733        if self.entries.is_empty() {
5734            self.commit_editor.focus_handle(cx)
5735        } else {
5736            self.focus_handle.clone()
5737        }
5738    }
5739}
5740
5741impl EventEmitter<Event> for GitPanel {}
5742
5743impl EventEmitter<PanelEvent> for GitPanel {}
5744
5745pub(crate) struct GitPanelAddon {
5746    pub(crate) workspace: WeakEntity<Workspace>,
5747}
5748
5749impl editor::Addon for GitPanelAddon {
5750    fn to_any(&self) -> &dyn std::any::Any {
5751        self
5752    }
5753
5754    fn render_buffer_header_controls(
5755        &self,
5756        excerpt_info: &ExcerptInfo,
5757        window: &Window,
5758        cx: &App,
5759    ) -> Option<AnyElement> {
5760        let file = excerpt_info.buffer.file()?;
5761        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
5762
5763        git_panel
5764            .read(cx)
5765            .render_buffer_header_controls(&git_panel, file, window, cx)
5766    }
5767}
5768
5769impl Panel for GitPanel {
5770    fn persistent_name() -> &'static str {
5771        "GitPanel"
5772    }
5773
5774    fn panel_key() -> &'static str {
5775        GIT_PANEL_KEY
5776    }
5777
5778    fn position(&self, _: &Window, cx: &App) -> DockPosition {
5779        GitPanelSettings::get_global(cx).dock
5780    }
5781
5782    fn position_is_valid(&self, position: DockPosition) -> bool {
5783        matches!(position, DockPosition::Left | DockPosition::Right)
5784    }
5785
5786    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
5787        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
5788            settings.git_panel.get_or_insert_default().dock = Some(position.into())
5789        });
5790    }
5791
5792    fn size(&self, _: &Window, cx: &App) -> Pixels {
5793        self.width
5794            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
5795    }
5796
5797    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
5798        self.width = size;
5799        self.serialize(cx);
5800        cx.notify();
5801    }
5802
5803    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
5804        Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
5805    }
5806
5807    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
5808        Some("Git Panel")
5809    }
5810
5811    fn toggle_action(&self) -> Box<dyn Action> {
5812        Box::new(ToggleFocus)
5813    }
5814
5815    fn activation_priority(&self) -> u32 {
5816        2
5817    }
5818}
5819
5820impl PanelHeader for GitPanel {}
5821
5822pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
5823    v_flex()
5824        .size_full()
5825        .gap(px(8.))
5826        .p_2()
5827        .bg(cx.theme().colors().editor_background)
5828}
5829
5830pub(crate) fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorStyle {
5831    let settings = ThemeSettings::get_global(cx);
5832
5833    let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
5834
5835    let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
5836        (
5837            settings.buffer_font.family.clone(),
5838            settings.buffer_font.fallbacks.clone(),
5839            settings.buffer_font.features.clone(),
5840            settings.buffer_font.weight,
5841            font_size * settings.buffer_line_height.value(),
5842        )
5843    } else {
5844        (
5845            settings.ui_font.family.clone(),
5846            settings.ui_font.fallbacks.clone(),
5847            settings.ui_font.features.clone(),
5848            settings.ui_font.weight,
5849            window.line_height(),
5850        )
5851    };
5852
5853    EditorStyle {
5854        background: cx.theme().colors().editor_background,
5855        local_player: cx.theme().players().local(),
5856        text: TextStyle {
5857            color: cx.theme().colors().text,
5858            font_family,
5859            font_fallbacks,
5860            font_features,
5861            font_size: TextSize::Small.rems(cx).into(),
5862            font_weight,
5863            line_height: line_height.into(),
5864            ..Default::default()
5865        },
5866        syntax: cx.theme().syntax().clone(),
5867        ..Default::default()
5868    }
5869}
5870
5871struct GitPanelMessageTooltip {
5872    commit_tooltip: Option<Entity<CommitTooltip>>,
5873}
5874
5875impl GitPanelMessageTooltip {
5876    fn new(
5877        git_panel: Entity<GitPanel>,
5878        sha: SharedString,
5879        repository: Entity<Repository>,
5880        window: &mut Window,
5881        cx: &mut App,
5882    ) -> Entity<Self> {
5883        let remote_url = repository.read(cx).default_remote_url();
5884        cx.new(|cx| {
5885            cx.spawn_in(window, async move |this, cx| {
5886                let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
5887                    (
5888                        git_panel.load_commit_details(sha.to_string(), cx),
5889                        git_panel.workspace.clone(),
5890                    )
5891                });
5892                let details = details.await?;
5893                let provider_registry = cx
5894                    .update(|_, app| GitHostingProviderRegistry::default_global(app))
5895                    .ok();
5896
5897                let commit_details = crate::commit_tooltip::CommitDetails {
5898                    sha: details.sha.clone(),
5899                    author_name: details.author_name.clone(),
5900                    author_email: details.author_email.clone(),
5901                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
5902                    message: Some(ParsedCommitMessage::parse(
5903                        details.sha.to_string(),
5904                        details.message.to_string(),
5905                        remote_url.as_deref(),
5906                        provider_registry,
5907                    )),
5908                };
5909
5910                this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
5911                    this.commit_tooltip = Some(cx.new(move |cx| {
5912                        CommitTooltip::new(commit_details, repository, workspace, cx)
5913                    }));
5914                    cx.notify();
5915                })
5916            })
5917            .detach();
5918
5919            Self {
5920                commit_tooltip: None,
5921            }
5922        })
5923    }
5924}
5925
5926impl Render for GitPanelMessageTooltip {
5927    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5928        if let Some(commit_tooltip) = &self.commit_tooltip {
5929            commit_tooltip.clone().into_any_element()
5930        } else {
5931            gpui::Empty.into_any_element()
5932        }
5933    }
5934}
5935
5936#[derive(IntoElement, RegisterComponent)]
5937pub struct PanelRepoFooter {
5938    active_repository: SharedString,
5939    branch: Option<Branch>,
5940    head_commit: Option<CommitDetails>,
5941
5942    // Getting a GitPanel in previews will be difficult.
5943    //
5944    // For now just take an option here, and we won't bind handlers to buttons in previews.
5945    git_panel: Option<Entity<GitPanel>>,
5946}
5947
5948impl PanelRepoFooter {
5949    pub fn new(
5950        active_repository: SharedString,
5951        branch: Option<Branch>,
5952        head_commit: Option<CommitDetails>,
5953        git_panel: Option<Entity<GitPanel>>,
5954    ) -> Self {
5955        Self {
5956            active_repository,
5957            branch,
5958            head_commit,
5959            git_panel,
5960        }
5961    }
5962
5963    pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
5964        Self {
5965            active_repository,
5966            branch,
5967            head_commit: None,
5968            git_panel: None,
5969        }
5970    }
5971}
5972
5973impl RenderOnce for PanelRepoFooter {
5974    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
5975        let project = self
5976            .git_panel
5977            .as_ref()
5978            .map(|panel| panel.read(cx).project.clone());
5979
5980        let (workspace, repo) = self
5981            .git_panel
5982            .as_ref()
5983            .map(|panel| {
5984                let panel = panel.read(cx);
5985                (panel.workspace.clone(), panel.active_repository.clone())
5986            })
5987            .unzip();
5988
5989        let single_repo = project
5990            .as_ref()
5991            .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
5992            .unwrap_or(true);
5993
5994        const MAX_BRANCH_LEN: usize = 16;
5995        const MAX_REPO_LEN: usize = 16;
5996        const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
5997        const MAX_SHORT_SHA_LEN: usize = 8;
5998        let branch_name = self
5999            .branch
6000            .as_ref()
6001            .map(|branch| branch.name().to_owned())
6002            .or_else(|| {
6003                self.head_commit.as_ref().map(|commit| {
6004                    commit
6005                        .sha
6006                        .chars()
6007                        .take(MAX_SHORT_SHA_LEN)
6008                        .collect::<String>()
6009                })
6010            })
6011            .unwrap_or_else(|| " (no branch)".to_owned());
6012        let show_separator = self.branch.is_some() || self.head_commit.is_some();
6013
6014        let active_repo_name = self.active_repository.clone();
6015
6016        let branch_actual_len = branch_name.len();
6017        let repo_actual_len = active_repo_name.len();
6018
6019        // ideally, show the whole branch and repo names but
6020        // when we can't, use a budget to allocate space between the two
6021        let (repo_display_len, branch_display_len) =
6022            if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
6023                (repo_actual_len, branch_actual_len)
6024            } else if branch_actual_len <= MAX_BRANCH_LEN {
6025                let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
6026                (repo_space, branch_actual_len)
6027            } else if repo_actual_len <= MAX_REPO_LEN {
6028                let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
6029                (repo_actual_len, branch_space)
6030            } else {
6031                (MAX_REPO_LEN, MAX_BRANCH_LEN)
6032            };
6033
6034        let truncated_repo_name = if repo_actual_len <= repo_display_len {
6035            active_repo_name.to_string()
6036        } else {
6037            util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
6038        };
6039
6040        let truncated_branch_name = if branch_actual_len <= branch_display_len {
6041            branch_name
6042        } else {
6043            util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
6044        };
6045
6046        let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
6047            .size(ButtonSize::None)
6048            .label_size(LabelSize::Small);
6049
6050        let repo_selector = PopoverMenu::new("repository-switcher")
6051            .menu({
6052                let project = project;
6053                move |window, cx| {
6054                    let project = project.clone()?;
6055                    Some(cx.new(|cx| RepositorySelector::new(project, rems(20.), window, cx)))
6056                }
6057            })
6058            .trigger_with_tooltip(
6059                repo_selector_trigger
6060                    .when(single_repo, |this| this.disabled(true).color(Color::Muted))
6061                    .truncate(true),
6062                move |_, cx| {
6063                    if single_repo {
6064                        cx.new(|_| Empty).into()
6065                    } else {
6066                        Tooltip::simple("Switch Active Repository", cx)
6067                    }
6068                },
6069            )
6070            .anchor(Corner::BottomLeft)
6071            .offset(gpui::Point {
6072                x: px(0.0),
6073                y: px(-2.0),
6074            })
6075            .into_any_element();
6076
6077        let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
6078            .size(ButtonSize::None)
6079            .label_size(LabelSize::Small)
6080            .truncate(true)
6081            .on_click(|_, window, cx| {
6082                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
6083            });
6084
6085        let branch_selector = PopoverMenu::new("popover-button")
6086            .menu(move |window, cx| {
6087                let workspace = workspace.clone()?;
6088                let repo = repo.clone().flatten();
6089                Some(branch_picker::popover(workspace, false, repo, window, cx))
6090            })
6091            .trigger_with_tooltip(
6092                branch_selector_button,
6093                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
6094            )
6095            .anchor(Corner::BottomLeft)
6096            .offset(gpui::Point {
6097                x: px(0.0),
6098                y: px(-2.0),
6099            });
6100
6101        h_flex()
6102            .h(px(36.))
6103            .w_full()
6104            .px_2()
6105            .justify_between()
6106            .gap_1()
6107            .child(
6108                h_flex()
6109                    .flex_1()
6110                    .overflow_hidden()
6111                    .gap_px()
6112                    .child(
6113                        Icon::new(IconName::GitBranchAlt)
6114                            .size(IconSize::Small)
6115                            .color(if single_repo {
6116                                Color::Disabled
6117                            } else {
6118                                Color::Muted
6119                            }),
6120                    )
6121                    .child(repo_selector)
6122                    .when(show_separator, |this| {
6123                        this.child(
6124                            div()
6125                                .text_sm()
6126                                .text_color(cx.theme().colors().icon_muted.opacity(0.5))
6127                                .child("/"),
6128                        )
6129                    })
6130                    .child(branch_selector),
6131            )
6132            .children(if let Some(git_panel) = self.git_panel {
6133                git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
6134            } else {
6135                None
6136            })
6137    }
6138}
6139
6140impl Component for PanelRepoFooter {
6141    fn scope() -> ComponentScope {
6142        ComponentScope::VersionControl
6143    }
6144
6145    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
6146        let unknown_upstream = None;
6147        let no_remote_upstream = Some(UpstreamTracking::Gone);
6148        let ahead_of_upstream = Some(
6149            UpstreamTrackingStatus {
6150                ahead: 2,
6151                behind: 0,
6152            }
6153            .into(),
6154        );
6155        let behind_upstream = Some(
6156            UpstreamTrackingStatus {
6157                ahead: 0,
6158                behind: 2,
6159            }
6160            .into(),
6161        );
6162        let ahead_and_behind_upstream = Some(
6163            UpstreamTrackingStatus {
6164                ahead: 3,
6165                behind: 1,
6166            }
6167            .into(),
6168        );
6169
6170        let not_ahead_or_behind_upstream = Some(
6171            UpstreamTrackingStatus {
6172                ahead: 0,
6173                behind: 0,
6174            }
6175            .into(),
6176        );
6177
6178        fn branch(upstream: Option<UpstreamTracking>) -> Branch {
6179            Branch {
6180                is_head: true,
6181                ref_name: "some-branch".into(),
6182                upstream: upstream.map(|tracking| Upstream {
6183                    ref_name: "origin/some-branch".into(),
6184                    tracking,
6185                }),
6186                most_recent_commit: Some(CommitSummary {
6187                    sha: "abc123".into(),
6188                    subject: "Modify stuff".into(),
6189                    commit_timestamp: 1710932954,
6190                    author_name: "John Doe".into(),
6191                    has_parent: true,
6192                }),
6193            }
6194        }
6195
6196        fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
6197            Branch {
6198                is_head: true,
6199                ref_name: branch_name.to_string().into(),
6200                upstream: upstream.map(|tracking| Upstream {
6201                    ref_name: format!("zed/{}", branch_name).into(),
6202                    tracking,
6203                }),
6204                most_recent_commit: Some(CommitSummary {
6205                    sha: "abc123".into(),
6206                    subject: "Modify stuff".into(),
6207                    commit_timestamp: 1710932954,
6208                    author_name: "John Doe".into(),
6209                    has_parent: true,
6210                }),
6211            }
6212        }
6213
6214        fn active_repository(id: usize) -> SharedString {
6215            format!("repo-{}", id).into()
6216        }
6217
6218        let example_width = px(340.);
6219        Some(
6220            v_flex()
6221                .gap_6()
6222                .w_full()
6223                .flex_none()
6224                .children(vec![
6225                    example_group_with_title(
6226                        "Action Button States",
6227                        vec![
6228                            single_example(
6229                                "No Branch",
6230                                div()
6231                                    .w(example_width)
6232                                    .overflow_hidden()
6233                                    .child(PanelRepoFooter::new_preview(active_repository(1), None))
6234                                    .into_any_element(),
6235                            ),
6236                            single_example(
6237                                "Remote status unknown",
6238                                div()
6239                                    .w(example_width)
6240                                    .overflow_hidden()
6241                                    .child(PanelRepoFooter::new_preview(
6242                                        active_repository(2),
6243                                        Some(branch(unknown_upstream)),
6244                                    ))
6245                                    .into_any_element(),
6246                            ),
6247                            single_example(
6248                                "No Remote Upstream",
6249                                div()
6250                                    .w(example_width)
6251                                    .overflow_hidden()
6252                                    .child(PanelRepoFooter::new_preview(
6253                                        active_repository(3),
6254                                        Some(branch(no_remote_upstream)),
6255                                    ))
6256                                    .into_any_element(),
6257                            ),
6258                            single_example(
6259                                "Not Ahead or Behind",
6260                                div()
6261                                    .w(example_width)
6262                                    .overflow_hidden()
6263                                    .child(PanelRepoFooter::new_preview(
6264                                        active_repository(4),
6265                                        Some(branch(not_ahead_or_behind_upstream)),
6266                                    ))
6267                                    .into_any_element(),
6268                            ),
6269                            single_example(
6270                                "Behind remote",
6271                                div()
6272                                    .w(example_width)
6273                                    .overflow_hidden()
6274                                    .child(PanelRepoFooter::new_preview(
6275                                        active_repository(5),
6276                                        Some(branch(behind_upstream)),
6277                                    ))
6278                                    .into_any_element(),
6279                            ),
6280                            single_example(
6281                                "Ahead of remote",
6282                                div()
6283                                    .w(example_width)
6284                                    .overflow_hidden()
6285                                    .child(PanelRepoFooter::new_preview(
6286                                        active_repository(6),
6287                                        Some(branch(ahead_of_upstream)),
6288                                    ))
6289                                    .into_any_element(),
6290                            ),
6291                            single_example(
6292                                "Ahead and behind remote",
6293                                div()
6294                                    .w(example_width)
6295                                    .overflow_hidden()
6296                                    .child(PanelRepoFooter::new_preview(
6297                                        active_repository(7),
6298                                        Some(branch(ahead_and_behind_upstream)),
6299                                    ))
6300                                    .into_any_element(),
6301                            ),
6302                        ],
6303                    )
6304                    .grow()
6305                    .vertical(),
6306                ])
6307                .children(vec![
6308                    example_group_with_title(
6309                        "Labels",
6310                        vec![
6311                            single_example(
6312                                "Short Branch & Repo",
6313                                div()
6314                                    .w(example_width)
6315                                    .overflow_hidden()
6316                                    .child(PanelRepoFooter::new_preview(
6317                                        SharedString::from("zed"),
6318                                        Some(custom("main", behind_upstream)),
6319                                    ))
6320                                    .into_any_element(),
6321                            ),
6322                            single_example(
6323                                "Long Branch",
6324                                div()
6325                                    .w(example_width)
6326                                    .overflow_hidden()
6327                                    .child(PanelRepoFooter::new_preview(
6328                                        SharedString::from("zed"),
6329                                        Some(custom(
6330                                            "redesign-and-update-git-ui-list-entry-style",
6331                                            behind_upstream,
6332                                        )),
6333                                    ))
6334                                    .into_any_element(),
6335                            ),
6336                            single_example(
6337                                "Long Repo",
6338                                div()
6339                                    .w(example_width)
6340                                    .overflow_hidden()
6341                                    .child(PanelRepoFooter::new_preview(
6342                                        SharedString::from("zed-industries-community-examples"),
6343                                        Some(custom("gpui", ahead_of_upstream)),
6344                                    ))
6345                                    .into_any_element(),
6346                            ),
6347                            single_example(
6348                                "Long Repo & Branch",
6349                                div()
6350                                    .w(example_width)
6351                                    .overflow_hidden()
6352                                    .child(PanelRepoFooter::new_preview(
6353                                        SharedString::from("zed-industries-community-examples"),
6354                                        Some(custom(
6355                                            "redesign-and-update-git-ui-list-entry-style",
6356                                            behind_upstream,
6357                                        )),
6358                                    ))
6359                                    .into_any_element(),
6360                            ),
6361                            single_example(
6362                                "Uppercase Repo",
6363                                div()
6364                                    .w(example_width)
6365                                    .overflow_hidden()
6366                                    .child(PanelRepoFooter::new_preview(
6367                                        SharedString::from("LICENSES"),
6368                                        Some(custom("main", ahead_of_upstream)),
6369                                    ))
6370                                    .into_any_element(),
6371                            ),
6372                            single_example(
6373                                "Uppercase Branch",
6374                                div()
6375                                    .w(example_width)
6376                                    .overflow_hidden()
6377                                    .child(PanelRepoFooter::new_preview(
6378                                        SharedString::from("zed"),
6379                                        Some(custom("update-README", behind_upstream)),
6380                                    ))
6381                                    .into_any_element(),
6382                            ),
6383                        ],
6384                    )
6385                    .grow()
6386                    .vertical(),
6387                ])
6388                .into_any_element(),
6389        )
6390    }
6391}
6392
6393fn open_output(
6394    operation: impl Into<SharedString>,
6395    workspace: &mut Workspace,
6396    output: &str,
6397    window: &mut Window,
6398    cx: &mut Context<Workspace>,
6399) {
6400    let operation = operation.into();
6401    let buffer = cx.new(|cx| Buffer::local(output, cx));
6402    buffer.update(cx, |buffer, cx| {
6403        buffer.set_capability(language::Capability::ReadOnly, cx);
6404    });
6405    let editor = cx.new(|cx| {
6406        let mut editor = Editor::for_buffer(buffer, None, window, cx);
6407        editor.buffer().update(cx, |buffer, cx| {
6408            buffer.set_title(format!("Output from git {operation}"), cx);
6409        });
6410        editor.set_read_only(true);
6411        editor
6412    });
6413
6414    workspace.add_item_to_center(Box::new(editor), window, cx);
6415}
6416
6417pub(crate) fn show_error_toast(
6418    workspace: Entity<Workspace>,
6419    action: impl Into<SharedString>,
6420    e: anyhow::Error,
6421    cx: &mut App,
6422) {
6423    let action = action.into();
6424    let message = format_git_error_toast_message(&e);
6425    if message
6426        .matches(git::repository::REMOTE_CANCELLED_BY_USER)
6427        .next()
6428        .is_some()
6429    { // Hide the cancelled by user message
6430    } else {
6431        workspace.update(cx, |workspace, cx| {
6432            let workspace_weak = cx.weak_entity();
6433            let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
6434                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
6435                    .action("View Log", move |window, cx| {
6436                        let message = message.clone();
6437                        let action = action.clone();
6438                        workspace_weak
6439                            .update(cx, move |workspace, cx| {
6440                                open_output(action, workspace, &message, window, cx)
6441                            })
6442                            .ok();
6443                    })
6444            });
6445            workspace.toggle_status_toast(toast, cx)
6446        });
6447    }
6448}
6449
6450fn rpc_error_raw_message_from_chain(error: &anyhow::Error) -> Option<&str> {
6451    error
6452        .chain()
6453        .find_map(|cause| cause.downcast_ref::<RpcError>().map(RpcError::raw_message))
6454}
6455
6456fn format_git_error_toast_message(error: &anyhow::Error) -> String {
6457    if let Some(message) = rpc_error_raw_message_from_chain(error) {
6458        message.trim().to_string()
6459    } else {
6460        error.to_string().trim().to_string()
6461    }
6462}
6463
6464#[cfg(test)]
6465mod tests {
6466    use git::{
6467        repository::repo_path,
6468        status::{StatusCode, UnmergedStatus, UnmergedStatusCode},
6469    };
6470    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
6471    use indoc::indoc;
6472    use project::FakeFs;
6473    use serde_json::json;
6474    use settings::SettingsStore;
6475    use theme::LoadThemes;
6476    use util::path;
6477    use util::rel_path::rel_path;
6478
6479    use workspace::MultiWorkspace;
6480
6481    use super::*;
6482
6483    fn init_test(cx: &mut gpui::TestAppContext) {
6484        zlog::init_test();
6485
6486        cx.update(|cx| {
6487            let settings_store = SettingsStore::test(cx);
6488            cx.set_global(settings_store);
6489            theme::init(LoadThemes::JustBase, cx);
6490            editor::init(cx);
6491            crate::init(cx);
6492        });
6493    }
6494
6495    #[test]
6496    fn test_format_git_error_toast_message_prefers_raw_rpc_message() {
6497        let rpc_error = RpcError::from_proto(
6498            &proto::Error {
6499                message:
6500                    "Your local changes to the following files would be overwritten by merge\n"
6501                        .to_string(),
6502                code: proto::ErrorCode::Internal as i32,
6503                tags: Default::default(),
6504            },
6505            "Pull",
6506        );
6507
6508        let message = format_git_error_toast_message(&rpc_error);
6509        assert_eq!(
6510            message,
6511            "Your local changes to the following files would be overwritten by merge"
6512        );
6513    }
6514
6515    #[test]
6516    fn test_format_git_error_toast_message_prefers_raw_rpc_message_when_wrapped() {
6517        let rpc_error = RpcError::from_proto(
6518            &proto::Error {
6519                message:
6520                    "Your local changes to the following files would be overwritten by merge\n"
6521                        .to_string(),
6522                code: proto::ErrorCode::Internal as i32,
6523                tags: Default::default(),
6524            },
6525            "Pull",
6526        );
6527        let wrapped = rpc_error.context("sending pull request");
6528
6529        let message = format_git_error_toast_message(&wrapped);
6530        assert_eq!(
6531            message,
6532            "Your local changes to the following files would be overwritten by merge"
6533        );
6534    }
6535
6536    #[gpui::test]
6537    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
6538        init_test(cx);
6539        let fs = FakeFs::new(cx.background_executor.clone());
6540        fs.insert_tree(
6541            "/root",
6542            json!({
6543                "zed": {
6544                    ".git": {},
6545                    "crates": {
6546                        "gpui": {
6547                            "gpui.rs": "fn main() {}"
6548                        },
6549                        "util": {
6550                            "util.rs": "fn do_it() {}"
6551                        }
6552                    }
6553                },
6554            }),
6555        )
6556        .await;
6557
6558        fs.set_status_for_repo(
6559            Path::new(path!("/root/zed/.git")),
6560            &[
6561                ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()),
6562                ("crates/util/util.rs", StatusCode::Modified.worktree()),
6563            ],
6564        );
6565
6566        let project =
6567            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
6568        let window_handle =
6569            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6570        let workspace = window_handle
6571            .read_with(cx, |mw, _| mw.workspace().clone())
6572            .unwrap();
6573        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
6574
6575        cx.read(|cx| {
6576            project
6577                .read(cx)
6578                .worktrees(cx)
6579                .next()
6580                .unwrap()
6581                .read(cx)
6582                .as_local()
6583                .unwrap()
6584                .scan_complete()
6585        })
6586        .await;
6587
6588        cx.executor().run_until_parked();
6589
6590        let panel = workspace.update_in(cx, GitPanel::new);
6591
6592        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6593            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6594        });
6595        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6596        handle.await;
6597
6598        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6599        pretty_assertions::assert_eq!(
6600            entries,
6601            [
6602                GitListEntry::Header(GitHeaderEntry {
6603                    header: Section::Tracked
6604                }),
6605                GitListEntry::Status(GitStatusEntry {
6606                    repo_path: repo_path("crates/gpui/gpui.rs"),
6607                    status: StatusCode::Modified.worktree(),
6608                    staging: StageStatus::Unstaged,
6609                }),
6610                GitListEntry::Status(GitStatusEntry {
6611                    repo_path: repo_path("crates/util/util.rs"),
6612                    status: StatusCode::Modified.worktree(),
6613                    staging: StageStatus::Unstaged,
6614                },),
6615            ],
6616        );
6617
6618        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6619            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6620        });
6621        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6622        handle.await;
6623        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6624        pretty_assertions::assert_eq!(
6625            entries,
6626            [
6627                GitListEntry::Header(GitHeaderEntry {
6628                    header: Section::Tracked
6629                }),
6630                GitListEntry::Status(GitStatusEntry {
6631                    repo_path: repo_path("crates/gpui/gpui.rs"),
6632                    status: StatusCode::Modified.worktree(),
6633                    staging: StageStatus::Unstaged,
6634                }),
6635                GitListEntry::Status(GitStatusEntry {
6636                    repo_path: repo_path("crates/util/util.rs"),
6637                    status: StatusCode::Modified.worktree(),
6638                    staging: StageStatus::Unstaged,
6639                },),
6640            ],
6641        );
6642    }
6643
6644    #[gpui::test]
6645    async fn test_bulk_staging(cx: &mut TestAppContext) {
6646        use GitListEntry::*;
6647
6648        init_test(cx);
6649        let fs = FakeFs::new(cx.background_executor.clone());
6650        fs.insert_tree(
6651            "/root",
6652            json!({
6653                "project": {
6654                    ".git": {},
6655                    "src": {
6656                        "main.rs": "fn main() {}",
6657                        "lib.rs": "pub fn hello() {}",
6658                        "utils.rs": "pub fn util() {}"
6659                    },
6660                    "tests": {
6661                        "test.rs": "fn test() {}"
6662                    },
6663                    "new_file.txt": "new content",
6664                    "another_new.rs": "// new file",
6665                    "conflict.txt": "conflicted content"
6666                }
6667            }),
6668        )
6669        .await;
6670
6671        fs.set_status_for_repo(
6672            Path::new(path!("/root/project/.git")),
6673            &[
6674                ("src/main.rs", StatusCode::Modified.worktree()),
6675                ("src/lib.rs", StatusCode::Modified.worktree()),
6676                ("tests/test.rs", StatusCode::Modified.worktree()),
6677                ("new_file.txt", FileStatus::Untracked),
6678                ("another_new.rs", FileStatus::Untracked),
6679                ("src/utils.rs", FileStatus::Untracked),
6680                (
6681                    "conflict.txt",
6682                    UnmergedStatus {
6683                        first_head: UnmergedStatusCode::Updated,
6684                        second_head: UnmergedStatusCode::Updated,
6685                    }
6686                    .into(),
6687                ),
6688            ],
6689        );
6690
6691        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6692        let window_handle =
6693            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6694        let workspace = window_handle
6695            .read_with(cx, |mw, _| mw.workspace().clone())
6696            .unwrap();
6697        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
6698
6699        cx.read(|cx| {
6700            project
6701                .read(cx)
6702                .worktrees(cx)
6703                .next()
6704                .unwrap()
6705                .read(cx)
6706                .as_local()
6707                .unwrap()
6708                .scan_complete()
6709        })
6710        .await;
6711
6712        cx.executor().run_until_parked();
6713
6714        let panel = workspace.update_in(cx, GitPanel::new);
6715
6716        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6717            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6718        });
6719        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6720        handle.await;
6721
6722        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6723        #[rustfmt::skip]
6724        pretty_assertions::assert_matches!(
6725            entries.as_slice(),
6726            &[
6727                Header(GitHeaderEntry { header: Section::Conflict }),
6728                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6729                Header(GitHeaderEntry { header: Section::Tracked }),
6730                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6731                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6732                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6733                Header(GitHeaderEntry { header: Section::New }),
6734                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6735                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6736                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6737            ],
6738        );
6739
6740        let second_status_entry = entries[3].clone();
6741        panel.update_in(cx, |panel, window, cx| {
6742            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6743        });
6744
6745        panel.update_in(cx, |panel, window, cx| {
6746            panel.selected_entry = Some(7);
6747            panel.stage_range(&git::StageRange, window, cx);
6748        });
6749
6750        cx.read(|cx| {
6751            project
6752                .read(cx)
6753                .worktrees(cx)
6754                .next()
6755                .unwrap()
6756                .read(cx)
6757                .as_local()
6758                .unwrap()
6759                .scan_complete()
6760        })
6761        .await;
6762
6763        cx.executor().run_until_parked();
6764
6765        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6766            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6767        });
6768        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6769        handle.await;
6770
6771        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6772        #[rustfmt::skip]
6773        pretty_assertions::assert_matches!(
6774            entries.as_slice(),
6775            &[
6776                Header(GitHeaderEntry { header: Section::Conflict }),
6777                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6778                Header(GitHeaderEntry { header: Section::Tracked }),
6779                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6780                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6781                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6782                Header(GitHeaderEntry { header: Section::New }),
6783                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6784                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6785                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6786            ],
6787        );
6788
6789        let third_status_entry = entries[4].clone();
6790        panel.update_in(cx, |panel, window, cx| {
6791            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
6792        });
6793
6794        panel.update_in(cx, |panel, window, cx| {
6795            panel.selected_entry = Some(9);
6796            panel.stage_range(&git::StageRange, window, cx);
6797        });
6798
6799        cx.read(|cx| {
6800            project
6801                .read(cx)
6802                .worktrees(cx)
6803                .next()
6804                .unwrap()
6805                .read(cx)
6806                .as_local()
6807                .unwrap()
6808                .scan_complete()
6809        })
6810        .await;
6811
6812        cx.executor().run_until_parked();
6813
6814        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6815            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6816        });
6817        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6818        handle.await;
6819
6820        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6821        #[rustfmt::skip]
6822        pretty_assertions::assert_matches!(
6823            entries.as_slice(),
6824            &[
6825                Header(GitHeaderEntry { header: Section::Conflict }),
6826                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6827                Header(GitHeaderEntry { header: Section::Tracked }),
6828                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6829                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6830                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6831                Header(GitHeaderEntry { header: Section::New }),
6832                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6833                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6834                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6835            ],
6836        );
6837    }
6838
6839    #[gpui::test]
6840    async fn test_bulk_staging_with_sort_by_paths(cx: &mut TestAppContext) {
6841        use GitListEntry::*;
6842
6843        init_test(cx);
6844        let fs = FakeFs::new(cx.background_executor.clone());
6845        fs.insert_tree(
6846            "/root",
6847            json!({
6848                "project": {
6849                    ".git": {},
6850                    "src": {
6851                        "main.rs": "fn main() {}",
6852                        "lib.rs": "pub fn hello() {}",
6853                        "utils.rs": "pub fn util() {}"
6854                    },
6855                    "tests": {
6856                        "test.rs": "fn test() {}"
6857                    },
6858                    "new_file.txt": "new content",
6859                    "another_new.rs": "// new file",
6860                    "conflict.txt": "conflicted content"
6861                }
6862            }),
6863        )
6864        .await;
6865
6866        fs.set_status_for_repo(
6867            Path::new(path!("/root/project/.git")),
6868            &[
6869                ("src/main.rs", StatusCode::Modified.worktree()),
6870                ("src/lib.rs", StatusCode::Modified.worktree()),
6871                ("tests/test.rs", StatusCode::Modified.worktree()),
6872                ("new_file.txt", FileStatus::Untracked),
6873                ("another_new.rs", FileStatus::Untracked),
6874                ("src/utils.rs", FileStatus::Untracked),
6875                (
6876                    "conflict.txt",
6877                    UnmergedStatus {
6878                        first_head: UnmergedStatusCode::Updated,
6879                        second_head: UnmergedStatusCode::Updated,
6880                    }
6881                    .into(),
6882                ),
6883            ],
6884        );
6885
6886        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6887        let window_handle =
6888            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6889        let workspace = window_handle
6890            .read_with(cx, |mw, _| mw.workspace().clone())
6891            .unwrap();
6892        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
6893
6894        cx.read(|cx| {
6895            project
6896                .read(cx)
6897                .worktrees(cx)
6898                .next()
6899                .unwrap()
6900                .read(cx)
6901                .as_local()
6902                .unwrap()
6903                .scan_complete()
6904        })
6905        .await;
6906
6907        cx.executor().run_until_parked();
6908
6909        let panel = workspace.update_in(cx, GitPanel::new);
6910
6911        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6912            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6913        });
6914        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6915        handle.await;
6916
6917        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6918        #[rustfmt::skip]
6919        pretty_assertions::assert_matches!(
6920            entries.as_slice(),
6921            &[
6922                Header(GitHeaderEntry { header: Section::Conflict }),
6923                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6924                Header(GitHeaderEntry { header: Section::Tracked }),
6925                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6926                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6927                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6928                Header(GitHeaderEntry { header: Section::New }),
6929                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6930                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6931                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6932            ],
6933        );
6934
6935        assert_entry_paths(
6936            &entries,
6937            &[
6938                None,
6939                Some("conflict.txt"),
6940                None,
6941                Some("src/lib.rs"),
6942                Some("src/main.rs"),
6943                Some("tests/test.rs"),
6944                None,
6945                Some("another_new.rs"),
6946                Some("new_file.txt"),
6947                Some("src/utils.rs"),
6948            ],
6949        );
6950
6951        let second_status_entry = entries[3].clone();
6952        panel.update_in(cx, |panel, window, cx| {
6953            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6954        });
6955
6956        cx.update(|_window, cx| {
6957            SettingsStore::update_global(cx, |store, cx| {
6958                store.update_user_settings(cx, |settings| {
6959                    settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
6960                })
6961            });
6962        });
6963
6964        panel.update_in(cx, |panel, window, cx| {
6965            panel.selected_entry = Some(7);
6966            panel.stage_range(&git::StageRange, window, cx);
6967        });
6968
6969        cx.read(|cx| {
6970            project
6971                .read(cx)
6972                .worktrees(cx)
6973                .next()
6974                .unwrap()
6975                .read(cx)
6976                .as_local()
6977                .unwrap()
6978                .scan_complete()
6979        })
6980        .await;
6981
6982        cx.executor().run_until_parked();
6983
6984        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6985            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6986        });
6987        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6988        handle.await;
6989
6990        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6991        #[rustfmt::skip]
6992        pretty_assertions::assert_matches!(
6993            entries.as_slice(),
6994            &[
6995                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6996                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
6997                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6998                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
6999                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
7000                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7001                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
7002            ],
7003        );
7004
7005        assert_entry_paths(
7006            &entries,
7007            &[
7008                Some("another_new.rs"),
7009                Some("conflict.txt"),
7010                Some("new_file.txt"),
7011                Some("src/lib.rs"),
7012                Some("src/main.rs"),
7013                Some("src/utils.rs"),
7014                Some("tests/test.rs"),
7015            ],
7016        );
7017
7018        let third_status_entry = entries[4].clone();
7019        panel.update_in(cx, |panel, window, cx| {
7020            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
7021        });
7022
7023        panel.update_in(cx, |panel, window, cx| {
7024            panel.selected_entry = Some(9);
7025            panel.stage_range(&git::StageRange, window, cx);
7026        });
7027
7028        cx.read(|cx| {
7029            project
7030                .read(cx)
7031                .worktrees(cx)
7032                .next()
7033                .unwrap()
7034                .read(cx)
7035                .as_local()
7036                .unwrap()
7037                .scan_complete()
7038        })
7039        .await;
7040
7041        cx.executor().run_until_parked();
7042
7043        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7044            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7045        });
7046        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7047        handle.await;
7048
7049        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
7050        #[rustfmt::skip]
7051        pretty_assertions::assert_matches!(
7052            entries.as_slice(),
7053            &[
7054                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7055                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
7056                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7057                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
7058                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
7059                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7060                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
7061            ],
7062        );
7063
7064        assert_entry_paths(
7065            &entries,
7066            &[
7067                Some("another_new.rs"),
7068                Some("conflict.txt"),
7069                Some("new_file.txt"),
7070                Some("src/lib.rs"),
7071                Some("src/main.rs"),
7072                Some("src/utils.rs"),
7073                Some("tests/test.rs"),
7074            ],
7075        );
7076    }
7077
7078    #[gpui::test]
7079    async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
7080        init_test(cx);
7081        let fs = FakeFs::new(cx.background_executor.clone());
7082        fs.insert_tree(
7083            "/root",
7084            json!({
7085                "project": {
7086                    ".git": {},
7087                    "src": {
7088                        "main.rs": "fn main() {}"
7089                    }
7090                }
7091            }),
7092        )
7093        .await;
7094
7095        fs.set_status_for_repo(
7096            Path::new(path!("/root/project/.git")),
7097            &[("src/main.rs", StatusCode::Modified.worktree())],
7098        );
7099
7100        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
7101        let window_handle =
7102            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7103        let workspace = window_handle
7104            .read_with(cx, |mw, _| mw.workspace().clone())
7105            .unwrap();
7106        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7107
7108        let panel = workspace.update_in(cx, GitPanel::new);
7109
7110        // Test: User has commit message, enables amend (saves message), then disables (restores message)
7111        panel.update(cx, |panel, cx| {
7112            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7113                let start = buffer.anchor_before(0);
7114                let end = buffer.anchor_after(buffer.len());
7115                buffer.edit([(start..end, "Initial commit message")], None, cx);
7116            });
7117
7118            panel.set_amend_pending(true, cx);
7119            assert!(panel.original_commit_message.is_some());
7120
7121            panel.set_amend_pending(false, cx);
7122            let current_message = panel.commit_message_buffer(cx).read(cx).text();
7123            assert_eq!(current_message, "Initial commit message");
7124            assert!(panel.original_commit_message.is_none());
7125        });
7126
7127        // Test: User has empty commit message, enables amend, then disables (clears message)
7128        panel.update(cx, |panel, cx| {
7129            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7130                let start = buffer.anchor_before(0);
7131                let end = buffer.anchor_after(buffer.len());
7132                buffer.edit([(start..end, "")], None, cx);
7133            });
7134
7135            panel.set_amend_pending(true, cx);
7136            assert!(panel.original_commit_message.is_none());
7137
7138            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7139                let start = buffer.anchor_before(0);
7140                let end = buffer.anchor_after(buffer.len());
7141                buffer.edit([(start..end, "Previous commit message")], None, cx);
7142            });
7143
7144            panel.set_amend_pending(false, cx);
7145            let current_message = panel.commit_message_buffer(cx).read(cx).text();
7146            assert_eq!(current_message, "");
7147        });
7148    }
7149
7150    #[gpui::test]
7151    async fn test_amend(cx: &mut TestAppContext) {
7152        init_test(cx);
7153        let fs = FakeFs::new(cx.background_executor.clone());
7154        fs.insert_tree(
7155            "/root",
7156            json!({
7157                "project": {
7158                    ".git": {},
7159                    "src": {
7160                        "main.rs": "fn main() {}"
7161                    }
7162                }
7163            }),
7164        )
7165        .await;
7166
7167        fs.set_status_for_repo(
7168            Path::new(path!("/root/project/.git")),
7169            &[("src/main.rs", StatusCode::Modified.worktree())],
7170        );
7171
7172        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
7173        let window_handle =
7174            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7175        let workspace = window_handle
7176            .read_with(cx, |mw, _| mw.workspace().clone())
7177            .unwrap();
7178        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7179
7180        // Wait for the project scanning to finish so that `head_commit(cx)` is
7181        // actually set, otherwise no head commit would be available from which
7182        // to fetch the latest commit message from.
7183        cx.executor().run_until_parked();
7184
7185        let panel = workspace.update_in(cx, GitPanel::new);
7186        panel.read_with(cx, |panel, cx| {
7187            assert!(panel.active_repository.is_some());
7188            assert!(panel.head_commit(cx).is_some());
7189        });
7190
7191        panel.update_in(cx, |panel, window, cx| {
7192            // Update the commit editor's message to ensure that its contents
7193            // are later restored, after amending is finished.
7194            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7195                buffer.set_text("refactor: update main.rs", cx);
7196            });
7197
7198            // Start amending the previous commit.
7199            panel.focus_editor(&Default::default(), window, cx);
7200            panel.on_amend(&Amend, window, cx);
7201        });
7202
7203        // Since `GitPanel.amend` attempts to fetch the latest commit message in
7204        // a background task, we need to wait for it to complete before being
7205        // able to assert that the commit message editor's state has been
7206        // updated.
7207        cx.run_until_parked();
7208
7209        panel.update_in(cx, |panel, window, cx| {
7210            assert_eq!(
7211                panel.commit_message_buffer(cx).read(cx).text(),
7212                "initial commit"
7213            );
7214            assert_eq!(
7215                panel.original_commit_message,
7216                Some("refactor: update main.rs".to_string())
7217            );
7218
7219            // Finish amending the previous commit.
7220            panel.focus_editor(&Default::default(), window, cx);
7221            panel.on_amend(&Amend, window, cx);
7222        });
7223
7224        // Since the actual commit logic is run in a background task, we need to
7225        // await its completion to actually ensure that the commit message
7226        // editor's contents are set to the original message and haven't been
7227        // cleared.
7228        cx.run_until_parked();
7229
7230        panel.update_in(cx, |panel, _window, cx| {
7231            // After amending, the commit editor's message should be restored to
7232            // the original message.
7233            assert_eq!(
7234                panel.commit_message_buffer(cx).read(cx).text(),
7235                "refactor: update main.rs"
7236            );
7237            assert!(panel.original_commit_message.is_none());
7238        });
7239    }
7240
7241    #[gpui::test]
7242    async fn test_open_diff(cx: &mut TestAppContext) {
7243        init_test(cx);
7244
7245        let fs = FakeFs::new(cx.background_executor.clone());
7246        fs.insert_tree(
7247            path!("/project"),
7248            json!({
7249                ".git": {},
7250                "tracked": "tracked\n",
7251                "untracked": "\n",
7252            }),
7253        )
7254        .await;
7255
7256        fs.set_head_and_index_for_repo(
7257            path!("/project/.git").as_ref(),
7258            &[("tracked", "old tracked\n".into())],
7259        );
7260
7261        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7262        let window_handle =
7263            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7264        let workspace = window_handle
7265            .read_with(cx, |mw, _| mw.workspace().clone())
7266            .unwrap();
7267        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7268        let panel = workspace.update_in(cx, GitPanel::new);
7269
7270        // Enable the `sort_by_path` setting and wait for entries to be updated,
7271        // as there should no longer be separators between Tracked and Untracked
7272        // files.
7273        cx.update(|_window, cx| {
7274            SettingsStore::update_global(cx, |store, cx| {
7275                store.update_user_settings(cx, |settings| {
7276                    settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
7277                })
7278            });
7279        });
7280
7281        cx.update_window_entity(&panel, |panel, _, _| {
7282            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7283        })
7284        .await;
7285
7286        // Confirm that `Open Diff` still works for the untracked file, updating
7287        // the Project Diff's active path.
7288        panel.update_in(cx, |panel, window, cx| {
7289            panel.selected_entry = Some(1);
7290            panel.open_diff(&menu::Confirm, window, cx);
7291        });
7292        cx.run_until_parked();
7293
7294        workspace.update_in(cx, |workspace, _window, cx| {
7295            let active_path = workspace
7296                .item_of_type::<ProjectDiff>(cx)
7297                .expect("ProjectDiff should exist")
7298                .read(cx)
7299                .active_path(cx)
7300                .expect("active_path should exist");
7301
7302            assert_eq!(active_path.path, rel_path("untracked").into_arc());
7303        });
7304    }
7305
7306    #[gpui::test]
7307    async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
7308        cx: &mut TestAppContext,
7309    ) {
7310        init_test(cx);
7311
7312        let fs = FakeFs::new(cx.background_executor.clone());
7313        fs.insert_tree(
7314            path!("/project"),
7315            json!({
7316                ".git": {},
7317                "src": {
7318                    "a": {
7319                        "foo.rs": "fn foo() {}",
7320                    },
7321                    "b": {
7322                        "bar.rs": "fn bar() {}",
7323                    },
7324                },
7325            }),
7326        )
7327        .await;
7328
7329        fs.set_status_for_repo(
7330            path!("/project/.git").as_ref(),
7331            &[
7332                ("src/a/foo.rs", StatusCode::Modified.worktree()),
7333                ("src/b/bar.rs", StatusCode::Modified.worktree()),
7334            ],
7335        );
7336
7337        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7338        let window_handle =
7339            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7340        let workspace = window_handle
7341            .read_with(cx, |mw, _| mw.workspace().clone())
7342            .unwrap();
7343        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7344
7345        cx.read(|cx| {
7346            project
7347                .read(cx)
7348                .worktrees(cx)
7349                .next()
7350                .unwrap()
7351                .read(cx)
7352                .as_local()
7353                .unwrap()
7354                .scan_complete()
7355        })
7356        .await;
7357
7358        cx.executor().run_until_parked();
7359
7360        cx.update(|_window, cx| {
7361            SettingsStore::update_global(cx, |store, cx| {
7362                store.update_user_settings(cx, |settings| {
7363                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
7364                })
7365            });
7366        });
7367
7368        let panel = workspace.update_in(cx, GitPanel::new);
7369
7370        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7371            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7372        });
7373        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7374        handle.await;
7375
7376        let src_key = panel.read_with(cx, |panel, _| {
7377            panel
7378                .entries
7379                .iter()
7380                .find_map(|entry| match entry {
7381                    GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
7382                        Some(dir.key.clone())
7383                    }
7384                    _ => None,
7385                })
7386                .expect("src directory should exist in tree view")
7387        });
7388
7389        panel.update_in(cx, |panel, window, cx| {
7390            panel.toggle_directory(&src_key, window, cx);
7391        });
7392
7393        panel.read_with(cx, |panel, _| {
7394            let state = panel
7395                .view_mode
7396                .tree_state()
7397                .expect("tree view state should exist");
7398            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
7399        });
7400
7401        let worktree_id =
7402            cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7403        let project_path = ProjectPath {
7404            worktree_id,
7405            path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(),
7406        };
7407
7408        panel.update_in(cx, |panel, window, cx| {
7409            panel.select_entry_by_path(project_path, window, cx);
7410        });
7411
7412        panel.read_with(cx, |panel, _| {
7413            let state = panel
7414                .view_mode
7415                .tree_state()
7416                .expect("tree view state should exist");
7417            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
7418
7419            let selected_ix = panel.selected_entry.expect("selection should be set");
7420            assert!(state.logical_indices.contains(&selected_ix));
7421
7422            let selected_entry = panel
7423                .entries
7424                .get(selected_ix)
7425                .and_then(|entry| entry.status_entry())
7426                .expect("selected entry should be a status entry");
7427            assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
7428        });
7429    }
7430
7431    #[gpui::test]
7432    async fn test_tree_view_select_next_at_last_visible_collapsed_directory(
7433        cx: &mut TestAppContext,
7434    ) {
7435        init_test(cx);
7436
7437        let fs = FakeFs::new(cx.background_executor.clone());
7438        fs.insert_tree(
7439            path!("/project"),
7440            json!({
7441                ".git": {},
7442                "bar": {
7443                    "bar1.py": "print('bar1')",
7444                    "bar2.py": "print('bar2')",
7445                },
7446                "foo": {
7447                    "foo1.py": "print('foo1')",
7448                    "foo2.py": "print('foo2')",
7449                },
7450                "foobar.py": "print('foobar')",
7451            }),
7452        )
7453        .await;
7454
7455        fs.set_status_for_repo(
7456            path!("/project/.git").as_ref(),
7457            &[
7458                ("bar/bar1.py", StatusCode::Modified.worktree()),
7459                ("bar/bar2.py", StatusCode::Modified.worktree()),
7460                ("foo/foo1.py", StatusCode::Modified.worktree()),
7461                ("foo/foo2.py", StatusCode::Modified.worktree()),
7462                ("foobar.py", FileStatus::Untracked),
7463            ],
7464        );
7465
7466        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7467        let window_handle =
7468            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7469        let workspace = window_handle
7470            .read_with(cx, |mw, _| mw.workspace().clone())
7471            .unwrap();
7472        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7473
7474        cx.read(|cx| {
7475            project
7476                .read(cx)
7477                .worktrees(cx)
7478                .next()
7479                .unwrap()
7480                .read(cx)
7481                .as_local()
7482                .unwrap()
7483                .scan_complete()
7484        })
7485        .await;
7486
7487        cx.executor().run_until_parked();
7488        cx.update(|_window, cx| {
7489            SettingsStore::update_global(cx, |store, cx| {
7490                store.update_user_settings(cx, |settings| {
7491                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
7492                })
7493            });
7494        });
7495
7496        let panel = workspace.update_in(cx, GitPanel::new);
7497        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7498            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7499        });
7500
7501        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7502        handle.await;
7503
7504        let foo_key = panel.read_with(cx, |panel, _| {
7505            panel
7506                .entries
7507                .iter()
7508                .find_map(|entry| match entry {
7509                    GitListEntry::Directory(dir) if dir.key.path == repo_path("foo") => {
7510                        Some(dir.key.clone())
7511                    }
7512                    _ => None,
7513                })
7514                .expect("foo directory should exist in tree view")
7515        });
7516
7517        panel.update_in(cx, |panel, window, cx| {
7518            panel.toggle_directory(&foo_key, window, cx);
7519        });
7520
7521        let foo_idx = panel.read_with(cx, |panel, _| {
7522            let state = panel
7523                .view_mode
7524                .tree_state()
7525                .expect("tree view state should exist");
7526            assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false));
7527
7528            let foo_idx = panel
7529                .entries
7530                .iter()
7531                .enumerate()
7532                .find_map(|(index, entry)| match entry {
7533                    GitListEntry::Directory(dir) if dir.key.path == repo_path("foo") => Some(index),
7534                    _ => None,
7535                })
7536                .expect("foo directory should exist in tree view");
7537
7538            let foo_logical_idx = state
7539                .logical_indices
7540                .iter()
7541                .position(|&index| index == foo_idx)
7542                .expect("foo directory should be visible");
7543            let next_logical_idx = state.logical_indices[foo_logical_idx + 1];
7544            assert!(matches!(
7545                panel.entries.get(next_logical_idx),
7546                Some(GitListEntry::Header(GitHeaderEntry {
7547                    header: Section::New
7548                }))
7549            ));
7550
7551            foo_idx
7552        });
7553
7554        panel.update_in(cx, |panel, window, cx| {
7555            panel.selected_entry = Some(foo_idx);
7556            panel.select_next(&menu::SelectNext, window, cx);
7557        });
7558
7559        panel.read_with(cx, |panel, _| {
7560            let selected_idx = panel.selected_entry.expect("selection should be set");
7561            let selected_entry = panel
7562                .entries
7563                .get(selected_idx)
7564                .and_then(|entry| entry.status_entry())
7565                .expect("selected entry should be a status entry");
7566            assert_eq!(selected_entry.repo_path, repo_path("foobar.py"));
7567        });
7568    }
7569
7570    fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
7571        assert_eq!(entries.len(), expected_paths.len());
7572        for (entry, expected_path) in entries.iter().zip(expected_paths) {
7573            assert_eq!(
7574                entry.status_entry().map(|status| status
7575                    .repo_path
7576                    .as_ref()
7577                    .as_std_path()
7578                    .to_string_lossy()
7579                    .to_string()),
7580                expected_path.map(|s| s.to_string())
7581            );
7582        }
7583    }
7584
7585    #[test]
7586    fn test_compress_diff_no_truncation() {
7587        let diff = indoc! {"
7588            --- a/file.txt
7589            +++ b/file.txt
7590            @@ -1,2 +1,2 @@
7591            -old
7592            +new
7593        "};
7594        let result = GitPanel::compress_commit_diff(diff, 1000);
7595        assert_eq!(result, diff);
7596    }
7597
7598    #[test]
7599    fn test_compress_diff_truncate_long_lines() {
7600        let long_line = "🦀".repeat(300);
7601        let diff = indoc::formatdoc! {"
7602            --- a/file.txt
7603            +++ b/file.txt
7604            @@ -1,2 +1,3 @@
7605             context
7606            +{}
7607             more context
7608        ", long_line};
7609        let result = GitPanel::compress_commit_diff(&diff, 100);
7610        assert!(result.contains("...[truncated]"));
7611        assert!(result.len() < diff.len());
7612    }
7613
7614    #[test]
7615    fn test_compress_diff_truncate_hunks() {
7616        let diff = indoc! {"
7617            --- a/file.txt
7618            +++ b/file.txt
7619            @@ -1,2 +1,2 @@
7620             context
7621            -old1
7622            +new1
7623            @@ -5,2 +5,2 @@
7624             context 2
7625            -old2
7626            +new2
7627            @@ -10,2 +10,2 @@
7628             context 3
7629            -old3
7630            +new3
7631        "};
7632        let result = GitPanel::compress_commit_diff(diff, 100);
7633        let expected = indoc! {"
7634            --- a/file.txt
7635            +++ b/file.txt
7636            @@ -1,2 +1,2 @@
7637             context
7638            -old1
7639            +new1
7640            [...skipped 2 hunks...]
7641        "};
7642        assert_eq!(result, expected);
7643    }
7644
7645    #[gpui::test]
7646    async fn test_suggest_commit_message(cx: &mut TestAppContext) {
7647        init_test(cx);
7648
7649        let fs = FakeFs::new(cx.background_executor.clone());
7650        fs.insert_tree(
7651            path!("/project"),
7652            json!({
7653                ".git": {},
7654                "tracked": "tracked\n",
7655                "untracked": "\n",
7656            }),
7657        )
7658        .await;
7659
7660        fs.set_head_and_index_for_repo(
7661            path!("/project/.git").as_ref(),
7662            &[("tracked", "old tracked\n".into())],
7663        );
7664
7665        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7666        let window_handle =
7667            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7668        let workspace = window_handle
7669            .read_with(cx, |mw, _| mw.workspace().clone())
7670            .unwrap();
7671        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7672        let panel = workspace.update_in(cx, GitPanel::new);
7673
7674        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7675            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7676        });
7677        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7678        handle.await;
7679
7680        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
7681
7682        // GitPanel
7683        // - Tracked:
7684        // - [] tracked
7685        // - Untracked
7686        // - [] untracked
7687        //
7688        // The commit message should now read:
7689        // "Update tracked"
7690        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7691        assert_eq!(message, Some("Update tracked".to_string()));
7692
7693        let first_status_entry = entries[1].clone();
7694        panel.update_in(cx, |panel, window, cx| {
7695            panel.toggle_staged_for_entry(&first_status_entry, window, cx);
7696        });
7697
7698        cx.read(|cx| {
7699            project
7700                .read(cx)
7701                .worktrees(cx)
7702                .next()
7703                .unwrap()
7704                .read(cx)
7705                .as_local()
7706                .unwrap()
7707                .scan_complete()
7708        })
7709        .await;
7710
7711        cx.executor().run_until_parked();
7712
7713        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7714            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7715        });
7716        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7717        handle.await;
7718
7719        // GitPanel
7720        // - Tracked:
7721        // - [x] tracked
7722        // - Untracked
7723        // - [] untracked
7724        //
7725        // The commit message should still read:
7726        // "Update tracked"
7727        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7728        assert_eq!(message, Some("Update tracked".to_string()));
7729
7730        let second_status_entry = entries[3].clone();
7731        panel.update_in(cx, |panel, window, cx| {
7732            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
7733        });
7734
7735        cx.read(|cx| {
7736            project
7737                .read(cx)
7738                .worktrees(cx)
7739                .next()
7740                .unwrap()
7741                .read(cx)
7742                .as_local()
7743                .unwrap()
7744                .scan_complete()
7745        })
7746        .await;
7747
7748        cx.executor().run_until_parked();
7749
7750        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7751            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7752        });
7753        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7754        handle.await;
7755
7756        // GitPanel
7757        // - Tracked:
7758        // - [x] tracked
7759        // - Untracked
7760        // - [x] untracked
7761        //
7762        // The commit message should now read:
7763        // "Enter commit message"
7764        // (which means we should see None returned).
7765        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7766        assert!(message.is_none());
7767
7768        panel.update_in(cx, |panel, window, cx| {
7769            panel.toggle_staged_for_entry(&first_status_entry, window, cx);
7770        });
7771
7772        cx.read(|cx| {
7773            project
7774                .read(cx)
7775                .worktrees(cx)
7776                .next()
7777                .unwrap()
7778                .read(cx)
7779                .as_local()
7780                .unwrap()
7781                .scan_complete()
7782        })
7783        .await;
7784
7785        cx.executor().run_until_parked();
7786
7787        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7788            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7789        });
7790        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7791        handle.await;
7792
7793        // GitPanel
7794        // - Tracked:
7795        // - [] tracked
7796        // - Untracked
7797        // - [x] untracked
7798        //
7799        // The commit message should now read:
7800        // "Update untracked"
7801        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7802        assert_eq!(message, Some("Create untracked".to_string()));
7803
7804        panel.update_in(cx, |panel, window, cx| {
7805            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
7806        });
7807
7808        cx.read(|cx| {
7809            project
7810                .read(cx)
7811                .worktrees(cx)
7812                .next()
7813                .unwrap()
7814                .read(cx)
7815                .as_local()
7816                .unwrap()
7817                .scan_complete()
7818        })
7819        .await;
7820
7821        cx.executor().run_until_parked();
7822
7823        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7824            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7825        });
7826        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7827        handle.await;
7828
7829        // GitPanel
7830        // - Tracked:
7831        // - [] tracked
7832        // - Untracked
7833        // - [] untracked
7834        //
7835        // The commit message should now read:
7836        // "Update tracked"
7837        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7838        assert_eq!(message, Some("Update tracked".to_string()));
7839    }
7840}