git_panel.rs

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