git_panel.rs

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