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