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                };
2790
2791                let stream = model.stream_completion_text(request, cx);
2792                match stream.await {
2793                    Ok(mut messages) => {
2794                        if !text_empty {
2795                            this.update(cx, |this, cx| {
2796                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2797                                    let insert_position = buffer.anchor_before(buffer.len());
2798                                    buffer.edit([(insert_position..insert_position, "\n")], None, cx)
2799                                });
2800                            })?;
2801                        }
2802
2803                        while let Some(message) = messages.stream.next().await {
2804                            match message {
2805                                Ok(text) => {
2806                                    this.update(cx, |this, cx| {
2807                                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2808                                            let insert_position = buffer.anchor_before(buffer.len());
2809                                            buffer.edit([(insert_position..insert_position, text)], None, cx);
2810                                        });
2811                                    })?;
2812                                }
2813                                Err(e) => {
2814                                    Self::show_commit_message_error(&this, &e, cx);
2815                                    break;
2816                                }
2817                            }
2818                        }
2819                    }
2820                    Err(e) => {
2821                        Self::show_commit_message_error(&this, &e, cx);
2822                    }
2823                }
2824
2825                anyhow::Ok(())
2826            }
2827            .log_err().await
2828        }));
2829    }
2830
2831    fn get_fetch_options(
2832        &self,
2833        window: &mut Window,
2834        cx: &mut Context<Self>,
2835    ) -> Task<Option<FetchOptions>> {
2836        let repo = self.active_repository.clone();
2837        let workspace = self.workspace.clone();
2838
2839        cx.spawn_in(window, async move |_, cx| {
2840            let repo = repo?;
2841            let remotes = repo
2842                .update(cx, |repo, _| repo.get_remotes(None, false))
2843                .await
2844                .ok()?
2845                .log_err()?;
2846
2847            let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
2848            if remotes.len() > 1 {
2849                remotes.push(FetchOptions::All);
2850            }
2851            let selection = cx
2852                .update(|window, cx| {
2853                    picker_prompt::prompt(
2854                        "Pick which remote to fetch",
2855                        remotes.iter().map(|r| r.name()).collect(),
2856                        workspace,
2857                        window,
2858                        cx,
2859                    )
2860                })
2861                .ok()?
2862                .await?;
2863            remotes.get(selection).cloned()
2864        })
2865    }
2866
2867    pub(crate) fn fetch(
2868        &mut self,
2869        is_fetch_all: bool,
2870        window: &mut Window,
2871        cx: &mut Context<Self>,
2872    ) {
2873        if !self.can_push_and_pull(cx) {
2874            return;
2875        }
2876
2877        let Some(repo) = self.active_repository.clone() else {
2878            return;
2879        };
2880        telemetry::event!("Git Fetched");
2881        let askpass = self.askpass_delegate("git fetch", window, cx);
2882        let this = cx.weak_entity();
2883
2884        let fetch_options = if is_fetch_all {
2885            Task::ready(Some(FetchOptions::All))
2886        } else {
2887            self.get_fetch_options(window, cx)
2888        };
2889
2890        window
2891            .spawn(cx, async move |cx| {
2892                let Some(fetch_options) = fetch_options.await else {
2893                    return Ok(());
2894                };
2895                let fetch = repo.update(cx, |repo, cx| {
2896                    repo.fetch(fetch_options.clone(), askpass, cx)
2897                });
2898
2899                let remote_message = fetch.await?;
2900                this.update(cx, |this, cx| {
2901                    let action = match fetch_options {
2902                        FetchOptions::All => RemoteAction::Fetch(None),
2903                        FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
2904                    };
2905                    match remote_message {
2906                        Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2907                        Err(e) => {
2908                            log::error!("Error while fetching {:?}", e);
2909                            this.show_error_toast(action.name(), e, cx)
2910                        }
2911                    }
2912
2913                    anyhow::Ok(())
2914                })
2915                .ok();
2916                anyhow::Ok(())
2917            })
2918            .detach_and_log_err(cx);
2919    }
2920
2921    pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
2922        let workspace = self.workspace.clone();
2923
2924        crate::clone::clone_and_open(
2925            repo.into(),
2926            workspace,
2927            window,
2928            cx,
2929            Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
2930        );
2931    }
2932
2933    pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2934        let worktrees = self
2935            .project
2936            .read(cx)
2937            .visible_worktrees(cx)
2938            .collect::<Vec<_>>();
2939
2940        let worktree = if worktrees.len() == 1 {
2941            Task::ready(Some(worktrees.first().unwrap().clone()))
2942        } else if worktrees.is_empty() {
2943            let result = window.prompt(
2944                PromptLevel::Warning,
2945                "Unable to initialize a git repository",
2946                Some("Open a directory first"),
2947                &["Ok"],
2948                cx,
2949            );
2950            cx.background_executor()
2951                .spawn(async move {
2952                    result.await.ok();
2953                })
2954                .detach();
2955            return;
2956        } else {
2957            let worktree_directories = worktrees
2958                .iter()
2959                .map(|worktree| worktree.read(cx).abs_path())
2960                .map(|worktree_abs_path| {
2961                    if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2962                        Path::new("~")
2963                            .join(path)
2964                            .to_string_lossy()
2965                            .to_string()
2966                            .into()
2967                    } else {
2968                        worktree_abs_path.to_string_lossy().into_owned().into()
2969                    }
2970                })
2971                .collect_vec();
2972            let prompt = picker_prompt::prompt(
2973                "Where would you like to initialize this git repository?",
2974                worktree_directories,
2975                self.workspace.clone(),
2976                window,
2977                cx,
2978            );
2979
2980            cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2981        };
2982
2983        cx.spawn_in(window, async move |this, cx| {
2984            let worktree = match worktree.await {
2985                Some(worktree) => worktree,
2986                None => {
2987                    return;
2988                }
2989            };
2990
2991            let Ok(result) = this.update(cx, |this, cx| {
2992                let fallback_branch_name = GitPanelSettings::get_global(cx)
2993                    .fallback_branch_name
2994                    .clone();
2995                this.project.read(cx).git_init(
2996                    worktree.read(cx).abs_path(),
2997                    fallback_branch_name,
2998                    cx,
2999                )
3000            }) else {
3001                return;
3002            };
3003
3004            let result = result.await;
3005
3006            this.update_in(cx, |this, _, cx| match result {
3007                Ok(()) => {}
3008                Err(e) => this.show_error_toast("init", e, cx),
3009            })
3010            .ok();
3011        })
3012        .detach();
3013    }
3014
3015    pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context<Self>) {
3016        if !self.can_push_and_pull(cx) {
3017            return;
3018        }
3019        let Some(repo) = self.active_repository.clone() else {
3020            return;
3021        };
3022        let Some(branch) = repo.read(cx).branch.as_ref() else {
3023            return;
3024        };
3025        telemetry::event!("Git Pulled");
3026        let branch = branch.clone();
3027        let remote = self.get_remote(false, false, window, cx);
3028        cx.spawn_in(window, async move |this, cx| {
3029            let remote = match remote.await {
3030                Ok(Some(remote)) => remote,
3031                Ok(None) => {
3032                    return Ok(());
3033                }
3034                Err(e) => {
3035                    log::error!("Failed to get current remote: {}", e);
3036                    this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
3037                        .ok();
3038                    return Ok(());
3039                }
3040            };
3041
3042            let askpass = this.update_in(cx, |this, window, cx| {
3043                this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
3044            })?;
3045
3046            let branch_name = branch
3047                .upstream
3048                .is_none()
3049                .then(|| branch.name().to_owned().into());
3050
3051            let pull = repo.update(cx, |repo, cx| {
3052                repo.pull(branch_name, remote.name.clone(), rebase, askpass, cx)
3053            });
3054
3055            let remote_message = pull.await?;
3056
3057            let action = RemoteAction::Pull(remote);
3058            this.update(cx, |this, cx| match remote_message {
3059                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3060                Err(e) => {
3061                    log::error!("Error while pulling {:?}", e);
3062                    this.show_error_toast(action.name(), e, cx)
3063                }
3064            })
3065            .ok();
3066
3067            anyhow::Ok(())
3068        })
3069        .detach_and_log_err(cx);
3070    }
3071
3072    pub(crate) fn push(
3073        &mut self,
3074        force_push: bool,
3075        select_remote: bool,
3076        window: &mut Window,
3077        cx: &mut Context<Self>,
3078    ) {
3079        if !self.can_push_and_pull(cx) {
3080            return;
3081        }
3082        let Some(repo) = self.active_repository.clone() else {
3083            return;
3084        };
3085        let Some(branch) = repo.read(cx).branch.as_ref() else {
3086            return;
3087        };
3088        telemetry::event!("Git Pushed");
3089        let branch = branch.clone();
3090
3091        let options = if force_push {
3092            Some(PushOptions::Force)
3093        } else {
3094            match branch.upstream {
3095                Some(Upstream {
3096                    tracking: UpstreamTracking::Gone,
3097                    ..
3098                })
3099                | None => Some(PushOptions::SetUpstream),
3100                _ => None,
3101            }
3102        };
3103        let remote = self.get_remote(select_remote, true, window, cx);
3104
3105        cx.spawn_in(window, async move |this, cx| {
3106            let remote = match remote.await {
3107                Ok(Some(remote)) => remote,
3108                Ok(None) => {
3109                    return Ok(());
3110                }
3111                Err(e) => {
3112                    log::error!("Failed to get current remote: {}", e);
3113                    this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
3114                        .ok();
3115                    return Ok(());
3116                }
3117            };
3118
3119            let askpass_delegate = this.update_in(cx, |this, window, cx| {
3120                this.askpass_delegate(format!("git push {}", remote.name), window, cx)
3121            })?;
3122
3123            let push = repo.update(cx, |repo, cx| {
3124                repo.push(
3125                    branch.name().to_owned().into(),
3126                    branch
3127                        .upstream
3128                        .as_ref()
3129                        .filter(|u| matches!(u.tracking, UpstreamTracking::Tracked(_)))
3130                        .and_then(|u| u.branch_name())
3131                        .unwrap_or_else(|| branch.name())
3132                        .to_owned()
3133                        .into(),
3134                    remote.name.clone(),
3135                    options,
3136                    askpass_delegate,
3137                    cx,
3138                )
3139            });
3140
3141            let remote_output = push.await?;
3142
3143            let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
3144            this.update(cx, |this, cx| match remote_output {
3145                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3146                Err(e) => {
3147                    log::error!("Error while pushing {:?}", e);
3148                    this.show_error_toast(action.name(), e, cx)
3149                }
3150            })?;
3151
3152            anyhow::Ok(())
3153        })
3154        .detach_and_log_err(cx);
3155    }
3156
3157    pub fn create_pull_request(&self, window: &mut Window, cx: &mut Context<Self>) {
3158        let result = (|| -> anyhow::Result<()> {
3159            let repo = self
3160                .active_repository
3161                .clone()
3162                .ok_or_else(|| anyhow::anyhow!("No active repository"))?;
3163
3164            let (branch, remote_origin, remote_upstream) = {
3165                let repository = repo.read(cx);
3166                (
3167                    repository.branch.clone(),
3168                    repository.remote_origin_url.clone(),
3169                    repository.remote_upstream_url.clone(),
3170                )
3171            };
3172
3173            let branch = branch.ok_or_else(|| anyhow::anyhow!("No active branch"))?;
3174            let source_branch = branch
3175                .upstream
3176                .as_ref()
3177                .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_)))
3178                .and_then(|upstream| upstream.branch_name())
3179                .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3180            let source_branch = source_branch.to_string();
3181
3182            let remote_url = branch
3183                .upstream
3184                .as_ref()
3185                .and_then(|upstream| match upstream.remote_name() {
3186                    Some("upstream") => remote_upstream.as_deref(),
3187                    Some(_) => remote_origin.as_deref(),
3188                    None => None,
3189                })
3190                .or(remote_origin.as_deref())
3191                .or(remote_upstream.as_deref())
3192                .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3193            let remote_url = remote_url.to_string();
3194
3195            let provider_registry = GitHostingProviderRegistry::global(cx);
3196            let Some((provider, parsed_remote)) =
3197                git::parse_git_remote_url(provider_registry, &remote_url)
3198            else {
3199                return Err(anyhow::anyhow!("Unsupported remote URL: {}", remote_url));
3200            };
3201
3202            let Some(url) = provider.build_create_pull_request_url(&parsed_remote, &source_branch)
3203            else {
3204                return Err(anyhow::anyhow!("Unable to construct pull request URL"));
3205            };
3206
3207            cx.open_url(url.as_str());
3208            Ok(())
3209        })();
3210
3211        if let Err(err) = result {
3212            log::error!("Error while creating pull request {:?}", err);
3213            cx.defer_in(window, |panel, _window, cx| {
3214                panel.show_error_toast("create pull request", err, cx);
3215            });
3216        }
3217    }
3218
3219    fn askpass_delegate(
3220        &self,
3221        operation: impl Into<SharedString>,
3222        window: &mut Window,
3223        cx: &mut Context<Self>,
3224    ) -> AskPassDelegate {
3225        let workspace = self.workspace.clone();
3226        let operation = operation.into();
3227        let window = window.window_handle();
3228        AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
3229            window
3230                .update(cx, |_, window, cx| {
3231                    workspace.update(cx, |workspace, cx| {
3232                        workspace.toggle_modal(window, cx, |window, cx| {
3233                            AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
3234                        });
3235                    })
3236                })
3237                .ok();
3238        })
3239    }
3240
3241    fn can_push_and_pull(&self, cx: &App) -> bool {
3242        !self.project.read(cx).is_via_collab()
3243    }
3244
3245    fn get_remote(
3246        &mut self,
3247        always_select: bool,
3248        is_push: bool,
3249        window: &mut Window,
3250        cx: &mut Context<Self>,
3251    ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
3252        let repo = self.active_repository.clone();
3253        let workspace = self.workspace.clone();
3254        let mut cx = window.to_async(cx);
3255
3256        async move {
3257            let repo = repo.context("No active repository")?;
3258            let current_remotes: Vec<Remote> = repo
3259                .update(&mut cx, |repo, _| {
3260                    let current_branch = if always_select {
3261                        None
3262                    } else {
3263                        let current_branch = repo.branch.as_ref().context("No active branch")?;
3264                        Some(current_branch.name().to_string())
3265                    };
3266                    anyhow::Ok(repo.get_remotes(current_branch, is_push))
3267                })?
3268                .await??;
3269
3270            let current_remotes: Vec<_> = current_remotes
3271                .into_iter()
3272                .map(|remotes| remotes.name)
3273                .collect();
3274            let selection = cx
3275                .update(|window, cx| {
3276                    picker_prompt::prompt(
3277                        "Pick which remote to push to",
3278                        current_remotes.clone(),
3279                        workspace,
3280                        window,
3281                        cx,
3282                    )
3283                })?
3284                .await;
3285
3286            Ok(selection.map(|selection| Remote {
3287                name: current_remotes[selection].clone(),
3288            }))
3289        }
3290    }
3291
3292    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
3293        if self.local_committer_task.is_none() {
3294            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
3295                let committer = get_git_committer(cx).await;
3296                this.update(cx, |this, cx| {
3297                    this.local_committer = Some(committer);
3298                    cx.notify()
3299                })
3300                .ok();
3301            }));
3302        }
3303    }
3304
3305    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
3306        let mut new_co_authors = Vec::new();
3307        let project = self.project.read(cx);
3308
3309        let Some(room) =
3310            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
3311        else {
3312            return Vec::default();
3313        };
3314
3315        let room = room.read(cx);
3316
3317        for (peer_id, collaborator) in project.collaborators() {
3318            if collaborator.is_host {
3319                continue;
3320            }
3321
3322            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
3323                continue;
3324            };
3325            if !participant.can_write() {
3326                continue;
3327            }
3328            if let Some(email) = &collaborator.committer_email {
3329                let name = collaborator
3330                    .committer_name
3331                    .clone()
3332                    .or_else(|| participant.user.name.clone())
3333                    .unwrap_or_else(|| participant.user.github_login.clone().to_string());
3334                new_co_authors.push((name.clone(), email.clone()))
3335            }
3336        }
3337        if !project.is_local()
3338            && !project.is_read_only(cx)
3339            && let Some(local_committer) = self.local_committer(room, cx)
3340        {
3341            new_co_authors.push(local_committer);
3342        }
3343        new_co_authors
3344    }
3345
3346    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
3347        let user = room.local_participant_user(cx)?;
3348        let committer = self.local_committer.as_ref()?;
3349        let email = committer.email.clone()?;
3350        let name = committer
3351            .name
3352            .clone()
3353            .or_else(|| user.name.clone())
3354            .unwrap_or_else(|| user.github_login.clone().to_string());
3355        Some((name, email))
3356    }
3357
3358    fn toggle_fill_co_authors(
3359        &mut self,
3360        _: &ToggleFillCoAuthors,
3361        _: &mut Window,
3362        cx: &mut Context<Self>,
3363    ) {
3364        self.add_coauthors = !self.add_coauthors;
3365        cx.notify();
3366    }
3367
3368    fn toggle_sort_by_path(
3369        &mut self,
3370        _: &ToggleSortByPath,
3371        _: &mut Window,
3372        cx: &mut Context<Self>,
3373    ) {
3374        let current_setting = GitPanelSettings::get_global(cx).sort_by_path;
3375        if let Some(workspace) = self.workspace.upgrade() {
3376            let workspace = workspace.read(cx);
3377            let fs = workspace.app_state().fs.clone();
3378            cx.update_global::<SettingsStore, _>(|store, _cx| {
3379                store.update_settings_file(fs, move |settings, _cx| {
3380                    settings.git_panel.get_or_insert_default().sort_by_path =
3381                        Some(!current_setting);
3382                });
3383            });
3384        }
3385    }
3386
3387    fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context<Self>) {
3388        let current_setting = GitPanelSettings::get_global(cx).tree_view;
3389        if let Some(workspace) = self.workspace.upgrade() {
3390            let workspace = workspace.read(cx);
3391            let fs = workspace.app_state().fs.clone();
3392            cx.update_global::<SettingsStore, _>(|store, _cx| {
3393                store.update_settings_file(fs, move |settings, _cx| {
3394                    settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting);
3395                });
3396            })
3397        }
3398    }
3399
3400    fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context<Self>) {
3401        if let Some(state) = self.view_mode.tree_state_mut() {
3402            let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
3403            *expanded = !*expanded;
3404            self.update_visible_entries(window, cx);
3405        } else {
3406            util::debug_panic!("Attempted to toggle directory in flat Git Panel state");
3407        }
3408    }
3409
3410    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
3411        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
3412
3413        let existing_text = message.to_ascii_lowercase();
3414        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
3415        let mut ends_with_co_authors = false;
3416        let existing_co_authors = existing_text
3417            .lines()
3418            .filter_map(|line| {
3419                let line = line.trim();
3420                if line.starts_with(&lowercase_co_author_prefix) {
3421                    ends_with_co_authors = true;
3422                    Some(line)
3423                } else {
3424                    ends_with_co_authors = false;
3425                    None
3426                }
3427            })
3428            .collect::<HashSet<_>>();
3429
3430        let new_co_authors = self
3431            .potential_co_authors(cx)
3432            .into_iter()
3433            .filter(|(_, email)| {
3434                !existing_co_authors
3435                    .iter()
3436                    .any(|existing| existing.contains(email.as_str()))
3437            })
3438            .collect::<Vec<_>>();
3439
3440        if new_co_authors.is_empty() {
3441            return;
3442        }
3443
3444        if !ends_with_co_authors {
3445            message.push('\n');
3446        }
3447        for (name, email) in new_co_authors {
3448            message.push('\n');
3449            message.push_str(CO_AUTHOR_PREFIX);
3450            message.push_str(&name);
3451            message.push_str(" <");
3452            message.push_str(&email);
3453            message.push('>');
3454        }
3455        message.push('\n');
3456    }
3457
3458    fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3459        let handle = cx.entity().downgrade();
3460        self.reopen_commit_buffer(window, cx);
3461        self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
3462            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
3463            if let Some(git_panel) = handle.upgrade() {
3464                git_panel
3465                    .update_in(cx, |git_panel, window, cx| {
3466                        git_panel.update_visible_entries(window, cx);
3467                    })
3468                    .ok();
3469            }
3470        });
3471    }
3472
3473    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3474        let Some(active_repo) = self.active_repository.as_ref() else {
3475            return;
3476        };
3477        let load_buffer = active_repo.update(cx, |active_repo, cx| {
3478            let project = self.project.read(cx);
3479            active_repo.open_commit_buffer(
3480                Some(project.languages().clone()),
3481                project.buffer_store().clone(),
3482                cx,
3483            )
3484        });
3485
3486        cx.spawn_in(window, async move |git_panel, cx| {
3487            let buffer = load_buffer.await?;
3488            git_panel.update_in(cx, |git_panel, window, cx| {
3489                if git_panel
3490                    .commit_editor
3491                    .read(cx)
3492                    .buffer()
3493                    .read(cx)
3494                    .as_singleton()
3495                    .as_ref()
3496                    != Some(&buffer)
3497                {
3498                    git_panel.commit_editor = cx.new(|cx| {
3499                        commit_message_editor(
3500                            buffer,
3501                            git_panel.suggest_commit_message(cx).map(SharedString::from),
3502                            git_panel.project.clone(),
3503                            true,
3504                            window,
3505                            cx,
3506                        )
3507                    });
3508                }
3509            })
3510        })
3511        .detach_and_log_err(cx);
3512    }
3513
3514    fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3515        let path_style = self.project.read(cx).path_style(cx);
3516        let bulk_staging = self.bulk_staging.take();
3517        let last_staged_path_prev_index = bulk_staging
3518            .as_ref()
3519            .and_then(|op| self.entry_by_path(&op.anchor));
3520
3521        self.active_repository = self.project.read(cx).active_repository(cx);
3522        self.entries.clear();
3523        self.entries_indices.clear();
3524        self.single_staged_entry.take();
3525        self.single_tracked_entry.take();
3526        self.conflicted_count = 0;
3527        self.conflicted_staged_count = 0;
3528        self.changes_count = 0;
3529        self.new_count = 0;
3530        self.tracked_count = 0;
3531        self.new_staged_count = 0;
3532        self.tracked_staged_count = 0;
3533        self.entry_count = 0;
3534        self.max_width_item_index = None;
3535
3536        let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
3537        let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
3538        let group_by_status = is_tree_view || !sort_by_path;
3539
3540        let mut changed_entries = Vec::new();
3541        let mut new_entries = Vec::new();
3542        let mut conflict_entries = Vec::new();
3543        let mut single_staged_entry = None;
3544        let mut staged_count = 0;
3545        let mut seen_directories = HashSet::default();
3546        let mut max_width_estimate = 0usize;
3547        let mut max_width_item_index = None;
3548
3549        let Some(repo) = self.active_repository.as_ref() else {
3550            // Just clear entries if no repository is active.
3551            cx.notify();
3552            return;
3553        };
3554
3555        let repo = repo.read(cx);
3556
3557        self.stash_entries = repo.cached_stash();
3558
3559        for entry in repo.cached_status() {
3560            self.changes_count += 1;
3561            let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
3562            let is_new = entry.status.is_created();
3563            let staging = entry.status.staging();
3564
3565            if let Some(pending) = repo.pending_ops_for_path(&entry.repo_path)
3566                && pending
3567                    .ops
3568                    .iter()
3569                    .any(|op| op.git_status == pending_op::GitStatus::Reverted && op.finished())
3570            {
3571                continue;
3572            }
3573
3574            let entry = GitStatusEntry {
3575                repo_path: entry.repo_path.clone(),
3576                status: entry.status,
3577                staging,
3578            };
3579
3580            if staging.has_staged() {
3581                staged_count += 1;
3582                single_staged_entry = Some(entry.clone());
3583            }
3584
3585            if group_by_status && is_conflict {
3586                conflict_entries.push(entry);
3587            } else if group_by_status && is_new {
3588                new_entries.push(entry);
3589            } else {
3590                changed_entries.push(entry);
3591            }
3592        }
3593
3594        if conflict_entries.is_empty() {
3595            if staged_count == 1
3596                && let Some(entry) = single_staged_entry.as_ref()
3597            {
3598                if let Some(ops) = repo.pending_ops_for_path(&entry.repo_path) {
3599                    if ops.staged() {
3600                        self.single_staged_entry = single_staged_entry;
3601                    }
3602                } else {
3603                    self.single_staged_entry = single_staged_entry;
3604                }
3605            } else if repo.pending_ops_summary().item_summary.staging_count == 1
3606                && let Some(ops) = repo.pending_ops().find(|ops| ops.staging())
3607            {
3608                self.single_staged_entry =
3609                    repo.status_for_path(&ops.repo_path)
3610                        .map(|status| GitStatusEntry {
3611                            repo_path: ops.repo_path.clone(),
3612                            status: status.status,
3613                            staging: StageStatus::Staged,
3614                        });
3615            }
3616        }
3617
3618        if conflict_entries.is_empty() && changed_entries.len() == 1 {
3619            self.single_tracked_entry = changed_entries.first().cloned();
3620        }
3621
3622        let mut push_entry =
3623            |this: &mut Self,
3624             entry: GitListEntry,
3625             is_visible: bool,
3626             logical_indices: Option<&mut Vec<usize>>| {
3627                if let Some(estimate) =
3628                    this.width_estimate_for_list_entry(is_tree_view, &entry, path_style)
3629                {
3630                    if estimate > max_width_estimate {
3631                        max_width_estimate = estimate;
3632                        max_width_item_index = Some(this.entries.len());
3633                    }
3634                }
3635
3636                if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
3637                {
3638                    this.entries_indices.insert(repo_path, this.entries.len());
3639                }
3640
3641                if let (Some(indices), true) = (logical_indices, is_visible) {
3642                    indices.push(this.entries.len());
3643                }
3644
3645                this.entries.push(entry);
3646            };
3647
3648        macro_rules! take_section_entries {
3649            () => {
3650                [
3651                    (Section::Conflict, std::mem::take(&mut conflict_entries)),
3652                    (Section::Tracked, std::mem::take(&mut changed_entries)),
3653                    (Section::New, std::mem::take(&mut new_entries)),
3654                ]
3655            };
3656        }
3657
3658        match &mut self.view_mode {
3659            GitPanelViewMode::Tree(tree_state) => {
3660                tree_state.logical_indices.clear();
3661                tree_state.directory_descendants.clear();
3662
3663                // This is just to get around the borrow checker
3664                // because push_entry mutably borrows self
3665                let mut tree_state = std::mem::take(tree_state);
3666
3667                for (section, entries) in take_section_entries!() {
3668                    if entries.is_empty() {
3669                        continue;
3670                    }
3671
3672                    push_entry(
3673                        self,
3674                        GitListEntry::Header(GitHeaderEntry { header: section }),
3675                        true,
3676                        Some(&mut tree_state.logical_indices),
3677                    );
3678
3679                    for (entry, is_visible) in
3680                        tree_state.build_tree_entries(section, entries, &mut seen_directories)
3681                    {
3682                        push_entry(
3683                            self,
3684                            entry,
3685                            is_visible,
3686                            Some(&mut tree_state.logical_indices),
3687                        );
3688                    }
3689                }
3690
3691                tree_state
3692                    .expanded_dirs
3693                    .retain(|key, _| seen_directories.contains(key));
3694                self.view_mode = GitPanelViewMode::Tree(tree_state);
3695            }
3696            GitPanelViewMode::Flat => {
3697                for (section, entries) in take_section_entries!() {
3698                    if entries.is_empty() {
3699                        continue;
3700                    }
3701
3702                    if section != Section::Tracked || !sort_by_path {
3703                        push_entry(
3704                            self,
3705                            GitListEntry::Header(GitHeaderEntry { header: section }),
3706                            true,
3707                            None,
3708                        );
3709                    }
3710
3711                    for entry in entries {
3712                        push_entry(self, GitListEntry::Status(entry), true, None);
3713                    }
3714                }
3715            }
3716        }
3717
3718        self.max_width_item_index = max_width_item_index;
3719
3720        self.update_counts(repo);
3721
3722        let bulk_staging_anchor_new_index = bulk_staging
3723            .as_ref()
3724            .filter(|op| op.repo_id == repo.id)
3725            .and_then(|op| self.entry_by_path(&op.anchor));
3726        if bulk_staging_anchor_new_index == last_staged_path_prev_index
3727            && let Some(index) = bulk_staging_anchor_new_index
3728            && let Some(entry) = self.entries.get(index)
3729            && let Some(entry) = entry.status_entry()
3730            && GitPanel::stage_status_for_entry(entry, &repo)
3731                .as_bool()
3732                .unwrap_or(false)
3733        {
3734            self.bulk_staging = bulk_staging;
3735        }
3736
3737        self.select_first_entry_if_none(window, cx);
3738
3739        let suggested_commit_message = self.suggest_commit_message(cx);
3740        let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
3741
3742        self.commit_editor.update(cx, |editor, cx| {
3743            editor.set_placeholder_text(&placeholder_text, window, cx)
3744        });
3745
3746        if GitPanelSettings::get_global(cx).diff_stats {
3747            self.fetch_diff_stats(cx);
3748        }
3749
3750        cx.notify();
3751    }
3752
3753    fn fetch_diff_stats(&mut self, cx: &mut Context<Self>) {
3754        let Some(repo) = self.active_repository.clone() else {
3755            self.diff_stats.clear();
3756            return;
3757        };
3758
3759        let unstaged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToWorktree, cx));
3760        let staged_rx = repo.update(cx, |repo, cx| repo.diff_stat(DiffType::HeadToIndex, cx));
3761
3762        self.diff_stats_task = cx.spawn(async move |this, cx| {
3763            let (unstaged_result, staged_result) =
3764                futures::future::join(unstaged_rx, staged_rx).await;
3765
3766            let mut combined = match unstaged_result {
3767                Ok(Ok(stats)) => stats,
3768                Ok(Err(err)) => {
3769                    log::warn!("Failed to fetch unstaged diff stats: {err:?}");
3770                    HashMap::default()
3771                }
3772                Err(_) => HashMap::default(),
3773            };
3774
3775            let staged = match staged_result {
3776                Ok(Ok(stats)) => Some(stats),
3777                Ok(Err(err)) => {
3778                    log::warn!("Failed to fetch staged diff stats: {err:?}");
3779                    None
3780                }
3781                Err(_) => None,
3782            };
3783
3784            if let Some(staged) = staged {
3785                for (path, stat) in staged {
3786                    let entry = combined.entry(path).or_default();
3787                    entry.added += stat.added;
3788                    entry.deleted += stat.deleted;
3789                }
3790            }
3791
3792            this.update(cx, |this, cx| {
3793                this.diff_stats = combined;
3794                cx.notify();
3795            })
3796            .ok();
3797        });
3798    }
3799
3800    fn header_state(&self, header_type: Section) -> ToggleState {
3801        let (staged_count, count) = match header_type {
3802            Section::New => (self.new_staged_count, self.new_count),
3803            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
3804            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
3805        };
3806        if staged_count == 0 {
3807            ToggleState::Unselected
3808        } else if count == staged_count {
3809            ToggleState::Selected
3810        } else {
3811            ToggleState::Indeterminate
3812        }
3813    }
3814
3815    fn update_counts(&mut self, repo: &Repository) {
3816        self.show_placeholders = false;
3817        self.conflicted_count = 0;
3818        self.conflicted_staged_count = 0;
3819        self.new_count = 0;
3820        self.tracked_count = 0;
3821        self.new_staged_count = 0;
3822        self.tracked_staged_count = 0;
3823        self.entry_count = 0;
3824
3825        for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) {
3826            self.entry_count += 1;
3827            let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo)
3828                .as_bool()
3829                .unwrap_or(true);
3830
3831            if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
3832                self.conflicted_count += 1;
3833                if is_staging_or_staged {
3834                    self.conflicted_staged_count += 1;
3835                }
3836            } else if status_entry.status.is_created() {
3837                self.new_count += 1;
3838                if is_staging_or_staged {
3839                    self.new_staged_count += 1;
3840                }
3841            } else {
3842                self.tracked_count += 1;
3843                if is_staging_or_staged {
3844                    self.tracked_staged_count += 1;
3845                }
3846            }
3847        }
3848    }
3849
3850    pub(crate) fn has_staged_changes(&self) -> bool {
3851        self.tracked_staged_count > 0
3852            || self.new_staged_count > 0
3853            || self.conflicted_staged_count > 0
3854    }
3855
3856    pub(crate) fn has_unstaged_changes(&self) -> bool {
3857        self.tracked_count > self.tracked_staged_count
3858            || self.new_count > self.new_staged_count
3859            || self.conflicted_count > self.conflicted_staged_count
3860    }
3861
3862    fn has_tracked_changes(&self) -> bool {
3863        self.tracked_count > 0
3864    }
3865
3866    pub fn has_unstaged_conflicts(&self) -> bool {
3867        self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
3868    }
3869
3870    fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
3871        let Some(workspace) = self.workspace.upgrade() else {
3872            return;
3873        };
3874        show_error_toast(workspace, action, e, cx)
3875    }
3876
3877    fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
3878    where
3879        E: std::fmt::Debug + std::fmt::Display,
3880    {
3881        if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
3882            let _ = workspace.update(cx, |workspace, cx| {
3883                struct CommitMessageError;
3884                let notification_id = NotificationId::unique::<CommitMessageError>();
3885                workspace.show_notification(notification_id, cx, |cx| {
3886                    cx.new(|cx| {
3887                        ErrorMessagePrompt::new(
3888                            format!("Failed to generate commit message: {err}"),
3889                            cx,
3890                        )
3891                    })
3892                });
3893            });
3894        }
3895    }
3896
3897    fn show_remote_output(
3898        &mut self,
3899        action: RemoteAction,
3900        info: RemoteCommandOutput,
3901        cx: &mut Context<Self>,
3902    ) {
3903        let Some(workspace) = self.workspace.upgrade() else {
3904            return;
3905        };
3906
3907        workspace.update(cx, |workspace, cx| {
3908            let SuccessMessage { message, style } = remote_output::format_output(&action, info);
3909            let workspace_weak = cx.weak_entity();
3910            let operation = action.name();
3911
3912            let status_toast = StatusToast::new(message, cx, move |this, _cx| {
3913                use remote_output::SuccessStyle::*;
3914                match style {
3915                    Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
3916                    ToastWithLog { output } => this
3917                        .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3918                        .action("View Log", move |window, cx| {
3919                            let output = output.clone();
3920                            let output =
3921                                format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
3922                            workspace_weak
3923                                .update(cx, move |workspace, cx| {
3924                                    open_output(operation, workspace, &output, window, cx)
3925                                })
3926                                .ok();
3927                        }),
3928                    PushPrLink { text, link } => this
3929                        .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3930                        .action(text, move |_, cx| cx.open_url(&link)),
3931                }
3932                .dismiss_button(true)
3933            });
3934            workspace.toggle_status_toast(status_toast, cx)
3935        });
3936    }
3937
3938    pub fn can_commit(&self) -> bool {
3939        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
3940    }
3941
3942    pub fn can_stage_all(&self) -> bool {
3943        self.has_unstaged_changes()
3944    }
3945
3946    pub fn can_unstage_all(&self) -> bool {
3947        self.has_staged_changes()
3948    }
3949
3950    /// Computes tree indentation depths for visible entries in the given range.
3951    /// Used by indent guides to render vertical connector lines in tree view.
3952    fn compute_visible_depths(&self, range: Range<usize>) -> SmallVec<[usize; 64]> {
3953        let GitPanelViewMode::Tree(state) = &self.view_mode else {
3954            return SmallVec::new();
3955        };
3956
3957        range
3958            .map(|ix| {
3959                state
3960                    .logical_indices
3961                    .get(ix)
3962                    .and_then(|&entry_ix| self.entries.get(entry_ix))
3963                    .map_or(0, |entry| entry.depth())
3964            })
3965            .collect()
3966    }
3967
3968    fn status_width_estimate(
3969        tree_view: bool,
3970        entry: &GitStatusEntry,
3971        path_style: PathStyle,
3972        depth: usize,
3973    ) -> usize {
3974        if tree_view {
3975            Self::item_width_estimate(0, entry.display_name(path_style).len(), depth)
3976        } else {
3977            Self::item_width_estimate(
3978                entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
3979                entry.display_name(path_style).len(),
3980                0,
3981            )
3982        }
3983    }
3984
3985    fn width_estimate_for_list_entry(
3986        &self,
3987        tree_view: bool,
3988        entry: &GitListEntry,
3989        path_style: PathStyle,
3990    ) -> Option<usize> {
3991        match entry {
3992            GitListEntry::Status(status) => Some(Self::status_width_estimate(
3993                tree_view, status, path_style, 0,
3994            )),
3995            GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate(
3996                tree_view,
3997                &status.entry,
3998                path_style,
3999                status.depth,
4000            )),
4001            GitListEntry::Directory(dir) => {
4002                Some(Self::item_width_estimate(0, dir.name.len(), dir.depth))
4003            }
4004            GitListEntry::Header(_) => None,
4005        }
4006    }
4007
4008    fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize {
4009        path + file_name + depth * 2
4010    }
4011
4012    fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
4013        let focus_handle = self.focus_handle.clone();
4014        let has_tracked_changes = self.has_tracked_changes();
4015        let has_staged_changes = self.has_staged_changes();
4016        let has_unstaged_changes = self.has_unstaged_changes();
4017        let has_new_changes = self.new_count > 0;
4018        let has_stash_items = self.stash_entries.entries.len() > 0;
4019
4020        PopoverMenu::new(id.into())
4021            .trigger(
4022                IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
4023                    .icon_size(IconSize::Small)
4024                    .icon_color(Color::Muted),
4025            )
4026            .menu(move |window, cx| {
4027                Some(git_panel_context_menu(
4028                    focus_handle.clone(),
4029                    GitMenuState {
4030                        has_tracked_changes,
4031                        has_staged_changes,
4032                        has_unstaged_changes,
4033                        has_new_changes,
4034                        sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
4035                        has_stash_items,
4036                        tree_view: GitPanelSettings::get_global(cx).tree_view,
4037                    },
4038                    window,
4039                    cx,
4040                ))
4041            })
4042            .anchor(Corner::TopRight)
4043    }
4044
4045    pub(crate) fn render_generate_commit_message_button(
4046        &self,
4047        cx: &Context<Self>,
4048    ) -> Option<AnyElement> {
4049        if !agent_settings::AgentSettings::get_global(cx).enabled(cx) {
4050            return None;
4051        }
4052
4053        if self.generate_commit_message_task.is_some() {
4054            return Some(
4055                h_flex()
4056                    .gap_1()
4057                    .child(
4058                        Icon::new(IconName::ArrowCircle)
4059                            .size(IconSize::XSmall)
4060                            .color(Color::Info)
4061                            .with_rotate_animation(2),
4062                    )
4063                    .child(
4064                        Label::new("Generating Commit…")
4065                            .size(LabelSize::Small)
4066                            .color(Color::Muted),
4067                    )
4068                    .into_any_element(),
4069            );
4070        }
4071
4072        let model_registry = LanguageModelRegistry::read_global(cx);
4073        let has_commit_model_configuration_error = model_registry
4074            .configuration_error(model_registry.commit_message_model(), cx)
4075            .is_some();
4076        let can_commit = self.can_commit();
4077
4078        let editor_focus_handle = self.commit_editor.focus_handle(cx);
4079
4080        Some(
4081            IconButton::new("generate-commit-message", IconName::AiEdit)
4082                .shape(ui::IconButtonShape::Square)
4083                .icon_color(if has_commit_model_configuration_error {
4084                    Color::Disabled
4085                } else {
4086                    Color::Muted
4087                })
4088                .tooltip(move |_window, cx| {
4089                    if !can_commit {
4090                        Tooltip::simple("No Changes to Commit", cx)
4091                    } else if has_commit_model_configuration_error {
4092                        Tooltip::simple("Configure an LLM provider to generate commit messages", cx)
4093                    } else {
4094                        Tooltip::for_action_in(
4095                            "Generate Commit Message",
4096                            &git::GenerateCommitMessage,
4097                            &editor_focus_handle,
4098                            cx,
4099                        )
4100                    }
4101                })
4102                .disabled(!can_commit || has_commit_model_configuration_error)
4103                .on_click(cx.listener(move |this, _event, _window, cx| {
4104                    this.generate_commit_message(cx);
4105                }))
4106                .into_any_element(),
4107        )
4108    }
4109
4110    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
4111        let potential_co_authors = self.potential_co_authors(cx);
4112
4113        let (tooltip_label, icon) = if self.add_coauthors {
4114            ("Remove co-authored-by", IconName::Person)
4115        } else {
4116            ("Add co-authored-by", IconName::UserCheck)
4117        };
4118
4119        if potential_co_authors.is_empty() {
4120            None
4121        } else {
4122            Some(
4123                IconButton::new("co-authors", icon)
4124                    .shape(ui::IconButtonShape::Square)
4125                    .icon_color(Color::Disabled)
4126                    .selected_icon_color(Color::Selected)
4127                    .toggle_state(self.add_coauthors)
4128                    .tooltip(move |_, cx| {
4129                        let title = format!(
4130                            "{}:{}{}",
4131                            tooltip_label,
4132                            if potential_co_authors.len() == 1 {
4133                                ""
4134                            } else {
4135                                "\n"
4136                            },
4137                            potential_co_authors
4138                                .iter()
4139                                .map(|(name, email)| format!(" {} <{}>", name, email))
4140                                .join("\n")
4141                        );
4142                        Tooltip::simple(title, cx)
4143                    })
4144                    .on_click(cx.listener(|this, _, _, cx| {
4145                        this.add_coauthors = !this.add_coauthors;
4146                        cx.notify();
4147                    }))
4148                    .into_any_element(),
4149            )
4150        }
4151    }
4152
4153    fn render_git_commit_menu(
4154        &self,
4155        id: impl Into<ElementId>,
4156        keybinding_target: Option<FocusHandle>,
4157        cx: &mut Context<Self>,
4158    ) -> impl IntoElement {
4159        PopoverMenu::new(id.into())
4160            .trigger(
4161                ui::ButtonLike::new_rounded_right("commit-split-button-right")
4162                    .layer(ui::ElevationIndex::ModalSurface)
4163                    .size(ButtonSize::None)
4164                    .child(
4165                        h_flex()
4166                            .px_1()
4167                            .h_full()
4168                            .justify_center()
4169                            .border_l_1()
4170                            .border_color(cx.theme().colors().border)
4171                            .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
4172                    ),
4173            )
4174            .menu({
4175                let git_panel = cx.entity();
4176                let has_previous_commit = self.head_commit(cx).is_some();
4177                let amend = self.amend_pending();
4178                let signoff = self.signoff_enabled;
4179
4180                move |window, cx| {
4181                    Some(ContextMenu::build(window, cx, |context_menu, _, _| {
4182                        context_menu
4183                            .when_some(keybinding_target.clone(), |el, keybinding_target| {
4184                                el.context(keybinding_target)
4185                            })
4186                            .when(has_previous_commit, |this| {
4187                                this.toggleable_entry(
4188                                    "Amend",
4189                                    amend,
4190                                    IconPosition::Start,
4191                                    Some(Box::new(Amend)),
4192                                    {
4193                                        let git_panel = git_panel.downgrade();
4194                                        move |_, cx| {
4195                                            git_panel
4196                                                .update(cx, |git_panel, cx| {
4197                                                    git_panel.toggle_amend_pending(cx);
4198                                                })
4199                                                .ok();
4200                                        }
4201                                    },
4202                                )
4203                            })
4204                            .toggleable_entry(
4205                                "Signoff",
4206                                signoff,
4207                                IconPosition::Start,
4208                                Some(Box::new(Signoff)),
4209                                move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
4210                            )
4211                    }))
4212                }
4213            })
4214            .anchor(Corner::TopRight)
4215    }
4216
4217    pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
4218        if self.has_unstaged_conflicts() {
4219            (false, "You must resolve conflicts before committing")
4220        } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending {
4221            (false, "No changes to commit")
4222        } else if self.pending_commit.is_some() {
4223            (false, "Commit in progress")
4224        } else if !self.has_commit_message(cx) {
4225            (false, "No commit message")
4226        } else if !self.has_write_access(cx) {
4227            (false, "You do not have write access to this project")
4228        } else {
4229            (true, self.commit_button_title())
4230        }
4231    }
4232
4233    pub fn commit_button_title(&self) -> &'static str {
4234        if self.amend_pending {
4235            if self.has_staged_changes() {
4236                "Amend"
4237            } else if self.has_tracked_changes() {
4238                "Amend Tracked"
4239            } else {
4240                "Amend"
4241            }
4242        } else if self.has_staged_changes() {
4243            "Commit"
4244        } else {
4245            "Commit Tracked"
4246        }
4247    }
4248
4249    fn expand_commit_editor(
4250        &mut self,
4251        _: &git::ExpandCommitEditor,
4252        window: &mut Window,
4253        cx: &mut Context<Self>,
4254    ) {
4255        let workspace = self.workspace.clone();
4256        window.defer(cx, move |window, cx| {
4257            workspace
4258                .update(cx, |workspace, cx| {
4259                    CommitModal::toggle(workspace, None, window, cx)
4260                })
4261                .ok();
4262        })
4263    }
4264
4265    fn render_panel_header(
4266        &self,
4267        window: &mut Window,
4268        cx: &mut Context<Self>,
4269    ) -> Option<impl IntoElement> {
4270        self.active_repository.as_ref()?;
4271
4272        let (text, action, stage, tooltip) =
4273            if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
4274                ("Unstage All", UnstageAll.boxed_clone(), false, "git reset")
4275            } else {
4276                ("Stage All", StageAll.boxed_clone(), true, "git add --all")
4277            };
4278
4279        let change_string = match self.changes_count {
4280            0 => "No Changes".to_string(),
4281            1 => "1 Change".to_string(),
4282            count => format!("{} Changes", count),
4283        };
4284
4285        Some(
4286            self.panel_header_container(window, cx)
4287                .px_2()
4288                .justify_between()
4289                .child(
4290                    panel_button(change_string)
4291                        .color(Color::Muted)
4292                        .tooltip(Tooltip::for_action_title_in(
4293                            "Open Diff",
4294                            &Diff,
4295                            &self.focus_handle,
4296                        ))
4297                        .on_click(|_, _, cx| {
4298                            cx.defer(|cx| {
4299                                cx.dispatch_action(&Diff);
4300                            })
4301                        }),
4302                )
4303                .child(
4304                    h_flex()
4305                        .gap_1()
4306                        .child(self.render_overflow_menu("overflow_menu"))
4307                        .child(
4308                            panel_filled_button(text)
4309                                .tooltip(Tooltip::for_action_title_in(
4310                                    tooltip,
4311                                    action.as_ref(),
4312                                    &self.focus_handle,
4313                                ))
4314                                .disabled(self.entry_count == 0)
4315                                .on_click({
4316                                    let git_panel = cx.weak_entity();
4317                                    move |_, _, cx| {
4318                                        git_panel
4319                                            .update(cx, |git_panel, cx| {
4320                                                git_panel.change_all_files_stage(stage, cx);
4321                                            })
4322                                            .ok();
4323                                    }
4324                                }),
4325                        ),
4326                ),
4327        )
4328    }
4329
4330    pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4331        let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
4332        if !self.can_push_and_pull(cx) {
4333            return None;
4334        }
4335        Some(
4336            h_flex()
4337                .gap_1()
4338                .flex_shrink_0()
4339                .when_some(branch, |this, branch| {
4340                    let focus_handle = Some(self.focus_handle(cx));
4341
4342                    this.children(render_remote_button(
4343                        "remote-button",
4344                        &branch,
4345                        focus_handle,
4346                        true,
4347                    ))
4348                })
4349                .into_any_element(),
4350        )
4351    }
4352
4353    pub fn render_footer(
4354        &self,
4355        window: &mut Window,
4356        cx: &mut Context<Self>,
4357    ) -> Option<impl IntoElement> {
4358        let active_repository = self.active_repository.clone()?;
4359        let panel_editor_style = panel_editor_style(true, window, cx);
4360        let enable_coauthors = self.render_co_authors(cx);
4361
4362        let editor_focus_handle = self.commit_editor.focus_handle(cx);
4363        let expand_tooltip_focus_handle = editor_focus_handle;
4364
4365        let branch = active_repository.read(cx).branch.clone();
4366        let head_commit = active_repository.read(cx).head_commit.clone();
4367
4368        let footer_size = px(32.);
4369        let gap = px(9.0);
4370        let max_height = panel_editor_style
4371            .text
4372            .line_height_in_pixels(window.rem_size())
4373            * MAX_PANEL_EDITOR_LINES
4374            + gap;
4375
4376        let git_panel = cx.entity();
4377        let display_name = SharedString::from(Arc::from(
4378            active_repository
4379                .read(cx)
4380                .display_name()
4381                .trim_end_matches("/"),
4382        ));
4383        let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
4384            editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
4385        });
4386
4387        let footer = v_flex()
4388            .child(PanelRepoFooter::new(
4389                display_name,
4390                branch,
4391                head_commit,
4392                Some(git_panel),
4393            ))
4394            .child(
4395                panel_editor_container(window, cx)
4396                    .id("commit-editor-container")
4397                    .relative()
4398                    .w_full()
4399                    .h(max_height + footer_size)
4400                    .border_t_1()
4401                    .border_color(cx.theme().colors().border)
4402                    .cursor_text()
4403                    .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
4404                        window.focus(&this.commit_editor.focus_handle(cx), cx);
4405                    }))
4406                    .child(
4407                        h_flex()
4408                            .id("commit-footer")
4409                            .border_t_1()
4410                            .when(editor_is_long, |el| {
4411                                el.border_color(cx.theme().colors().border_variant)
4412                            })
4413                            .absolute()
4414                            .bottom_0()
4415                            .left_0()
4416                            .w_full()
4417                            .px_2()
4418                            .h(footer_size)
4419                            .flex_none()
4420                            .justify_between()
4421                            .child(
4422                                self.render_generate_commit_message_button(cx)
4423                                    .unwrap_or_else(|| div().into_any_element()),
4424                            )
4425                            .child(
4426                                h_flex()
4427                                    .gap_0p5()
4428                                    .children(enable_coauthors)
4429                                    .child(self.render_commit_button(cx)),
4430                            ),
4431                    )
4432                    .child(
4433                        div()
4434                            .pr_2p5()
4435                            .on_action(|&zed_actions::editor::MoveUp, _, cx| {
4436                                cx.stop_propagation();
4437                            })
4438                            .on_action(|&zed_actions::editor::MoveDown, _, cx| {
4439                                cx.stop_propagation();
4440                            })
4441                            .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
4442                    )
4443                    .child(
4444                        h_flex()
4445                            .absolute()
4446                            .top_2()
4447                            .right_2()
4448                            .opacity(0.5)
4449                            .hover(|this| this.opacity(1.0))
4450                            .child(
4451                                panel_icon_button("expand-commit-editor", IconName::Maximize)
4452                                    .icon_size(IconSize::Small)
4453                                    .size(ui::ButtonSize::Default)
4454                                    .tooltip(move |_window, cx| {
4455                                        Tooltip::for_action_in(
4456                                            "Open Commit Modal",
4457                                            &git::ExpandCommitEditor,
4458                                            &expand_tooltip_focus_handle,
4459                                            cx,
4460                                        )
4461                                    })
4462                                    .on_click(cx.listener({
4463                                        move |_, _, window, cx| {
4464                                            window.dispatch_action(
4465                                                git::ExpandCommitEditor.boxed_clone(),
4466                                                cx,
4467                                            )
4468                                        }
4469                                    })),
4470                            ),
4471                    ),
4472            );
4473
4474        Some(footer)
4475    }
4476
4477    fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4478        let (can_commit, tooltip) = self.configure_commit_button(cx);
4479        let title = self.commit_button_title();
4480        let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
4481        let amend = self.amend_pending();
4482        let signoff = self.signoff_enabled;
4483
4484        let label_color = if self.pending_commit.is_some() {
4485            Color::Disabled
4486        } else {
4487            Color::Default
4488        };
4489
4490        div()
4491            .id("commit-wrapper")
4492            .on_hover(cx.listener(move |this, hovered, _, cx| {
4493                this.show_placeholders =
4494                    *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
4495                cx.notify()
4496            }))
4497            .child(SplitButton::new(
4498                ButtonLike::new_rounded_left(ElementId::Name(
4499                    format!("split-button-left-{}", title).into(),
4500                ))
4501                .layer(ElevationIndex::ModalSurface)
4502                .size(ButtonSize::Compact)
4503                .child(
4504                    Label::new(title)
4505                        .size(LabelSize::Small)
4506                        .color(label_color)
4507                        .mr_0p5(),
4508                )
4509                .on_click({
4510                    let git_panel = cx.weak_entity();
4511                    move |_, window, cx| {
4512                        telemetry::event!("Git Committed", source = "Git Panel");
4513                        git_panel
4514                            .update(cx, |git_panel, cx| {
4515                                git_panel.commit_changes(
4516                                    CommitOptions { amend, signoff },
4517                                    window,
4518                                    cx,
4519                                );
4520                            })
4521                            .ok();
4522                    }
4523                })
4524                .disabled(!can_commit || self.modal_open)
4525                .tooltip({
4526                    let handle = commit_tooltip_focus_handle.clone();
4527                    move |_window, cx| {
4528                        if can_commit {
4529                            Tooltip::with_meta_in(
4530                                tooltip,
4531                                Some(if amend { &git::Amend } else { &git::Commit }),
4532                                format!(
4533                                    "git commit{}{}",
4534                                    if amend { " --amend" } else { "" },
4535                                    if signoff { " --signoff" } else { "" }
4536                                ),
4537                                &handle.clone(),
4538                                cx,
4539                            )
4540                        } else {
4541                            Tooltip::simple(tooltip, cx)
4542                        }
4543                    }
4544                }),
4545                self.render_git_commit_menu(
4546                    ElementId::Name(format!("split-button-right-{}", title).into()),
4547                    Some(commit_tooltip_focus_handle),
4548                    cx,
4549                )
4550                .into_any_element(),
4551            ))
4552    }
4553
4554    fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
4555        h_flex()
4556            .py_1p5()
4557            .px_2()
4558            .gap_1p5()
4559            .justify_between()
4560            .border_t_1()
4561            .border_color(cx.theme().colors().border.opacity(0.8))
4562            .child(
4563                div()
4564                    .flex_grow()
4565                    .overflow_hidden()
4566                    .max_w(relative(0.85))
4567                    .child(
4568                        Label::new("This will update your most recent commit.")
4569                            .size(LabelSize::Small)
4570                            .truncate(),
4571                    ),
4572            )
4573            .child(
4574                panel_button("Cancel")
4575                    .size(ButtonSize::Default)
4576                    .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
4577            )
4578    }
4579
4580    fn render_previous_commit(
4581        &self,
4582        window: &mut Window,
4583        cx: &mut Context<Self>,
4584    ) -> Option<impl IntoElement> {
4585        let active_repository = self.active_repository.as_ref()?;
4586        let branch = active_repository.read(cx).branch.as_ref()?;
4587        let commit = branch.most_recent_commit.as_ref()?.clone();
4588        let workspace = self.workspace.clone();
4589        let this = cx.entity();
4590
4591        Some(
4592            h_flex()
4593                .p_1p5()
4594                .gap_1p5()
4595                .justify_between()
4596                .border_t_1()
4597                .border_color(cx.theme().colors().border.opacity(0.8))
4598                .child(
4599                    div()
4600                        .id("commit-msg-hover")
4601                        .cursor_pointer()
4602                        .px_1()
4603                        .rounded_sm()
4604                        .line_clamp(1)
4605                        .hover(|s| s.bg(cx.theme().colors().element_hover))
4606                        .child(
4607                            Label::new(commit.subject.clone())
4608                                .size(LabelSize::Small)
4609                                .truncate(),
4610                        )
4611                        .on_click({
4612                            let commit = commit.clone();
4613                            let repo = active_repository.downgrade();
4614                            move |_, window, cx| {
4615                                CommitView::open(
4616                                    commit.sha.to_string(),
4617                                    repo.clone(),
4618                                    workspace.clone(),
4619                                    None,
4620                                    None,
4621                                    window,
4622                                    cx,
4623                                );
4624                            }
4625                        })
4626                        .hoverable_tooltip({
4627                            let repo = active_repository.clone();
4628                            move |window, cx| {
4629                                GitPanelMessageTooltip::new(
4630                                    this.clone(),
4631                                    commit.sha.clone(),
4632                                    repo.clone(),
4633                                    window,
4634                                    cx,
4635                                )
4636                                .into()
4637                            }
4638                        }),
4639                )
4640                .child(
4641                    h_flex()
4642                        .gap_0p5()
4643                        .when(commit.has_parent, |this| {
4644                            let has_unstaged = self.has_unstaged_changes();
4645                            this.child(
4646                                panel_icon_button("undo", IconName::Undo)
4647                                    .icon_size(IconSize::Small)
4648                                    .tooltip(move |_window, cx| {
4649                                        Tooltip::with_meta(
4650                                            "Uncommit",
4651                                            Some(&git::Uncommit),
4652                                            if has_unstaged {
4653                                                "git reset HEAD^ --soft"
4654                                            } else {
4655                                                "git reset HEAD^"
4656                                            },
4657                                            cx,
4658                                        )
4659                                    })
4660                                    .on_click(
4661                                        cx.listener(|this, _, window, cx| {
4662                                            this.uncommit(window, cx)
4663                                        }),
4664                                    ),
4665                            )
4666                        })
4667                        .when(window.is_action_available(&Open, cx), |this| {
4668                            this.child(
4669                                panel_icon_button("git-graph-button", IconName::GitGraph)
4670                                    .icon_size(IconSize::Small)
4671                                    .tooltip(|_window, cx| {
4672                                        Tooltip::for_action("Open Git Graph", &Open, cx)
4673                                    })
4674                                    .on_click(|_, window, cx| {
4675                                        window.dispatch_action(Open.boxed_clone(), cx)
4676                                    }),
4677                            )
4678                        }),
4679                ),
4680        )
4681    }
4682
4683    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4684        let has_repo = self.active_repository.is_some();
4685        let has_no_repo = self.active_repository.is_none();
4686        let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
4687
4688        let should_show_branch_diff =
4689            has_repo && self.changes_count == 0 && !self.is_on_main_branch(cx);
4690
4691        let label = if has_repo {
4692            "No changes to commit"
4693        } else {
4694            "No Git repositories"
4695        };
4696
4697        v_flex()
4698            .gap_1p5()
4699            .flex_1()
4700            .items_center()
4701            .justify_center()
4702            .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
4703            .when(has_no_repo && worktree_count > 0, |this| {
4704                this.child(
4705                    panel_filled_button("Initialize Repository")
4706                        .tooltip(Tooltip::for_action_title_in(
4707                            "git init",
4708                            &git::Init,
4709                            &self.focus_handle,
4710                        ))
4711                        .on_click(move |_, _, cx| {
4712                            cx.defer(move |cx| {
4713                                cx.dispatch_action(&git::Init);
4714                            })
4715                        }),
4716                )
4717            })
4718            .when(should_show_branch_diff, |this| {
4719                this.child(
4720                    panel_filled_button("View Branch Diff")
4721                        .tooltip(move |_, cx| {
4722                            Tooltip::with_meta(
4723                                "Branch Diff",
4724                                Some(&BranchDiff),
4725                                "Show diff between working directory and default branch",
4726                                cx,
4727                            )
4728                        })
4729                        .on_click(move |_, _, cx| {
4730                            cx.defer(move |cx| {
4731                                cx.dispatch_action(&BranchDiff);
4732                            })
4733                        }),
4734                )
4735            })
4736    }
4737
4738    fn is_on_main_branch(&self, cx: &Context<Self>) -> bool {
4739        let Some(repo) = self.active_repository.as_ref() else {
4740            return false;
4741        };
4742
4743        let Some(branch) = repo.read(cx).branch.as_ref() else {
4744            return false;
4745        };
4746
4747        let branch_name = branch.name();
4748        matches!(branch_name, "main" | "master")
4749    }
4750
4751    fn render_buffer_header_controls(
4752        &self,
4753        entity: &Entity<Self>,
4754        file: &Arc<dyn File>,
4755        _: &Window,
4756        cx: &App,
4757    ) -> Option<AnyElement> {
4758        let repo = self.active_repository.as_ref()?.read(cx);
4759        let project_path = (file.worktree_id(cx), file.path().clone()).into();
4760        let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
4761        let ix = self.entry_by_path(&repo_path)?;
4762        let entry = self.entries.get(ix)?;
4763
4764        let is_staging_or_staged = repo
4765            .pending_ops_for_path(&repo_path)
4766            .map(|ops| ops.staging() || ops.staged())
4767            .or_else(|| {
4768                repo.status_for_path(&repo_path)
4769                    .and_then(|status| status.status.staging().as_bool())
4770            })
4771            .or_else(|| {
4772                entry
4773                    .status_entry()
4774                    .and_then(|entry| entry.staging.as_bool())
4775            });
4776
4777        let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into())
4778            .disabled(!self.has_write_access(cx))
4779            .fill()
4780            .elevation(ElevationIndex::Surface)
4781            .on_click({
4782                let entry = entry.clone();
4783                let git_panel = entity.downgrade();
4784                move |_, window, cx| {
4785                    git_panel
4786                        .update(cx, |this, cx| {
4787                            this.toggle_staged_for_entry(&entry, window, cx);
4788                            cx.stop_propagation();
4789                        })
4790                        .ok();
4791                }
4792            });
4793        Some(
4794            h_flex()
4795                .id("start-slot")
4796                .text_lg()
4797                .child(checkbox)
4798                .on_mouse_down(MouseButton::Left, |_, _, cx| {
4799                    // prevent the list item active state triggering when toggling checkbox
4800                    cx.stop_propagation();
4801                })
4802                .into_any_element(),
4803        )
4804    }
4805
4806    fn render_entries(
4807        &self,
4808        has_write_access: bool,
4809        repo: Entity<Repository>,
4810        window: &mut Window,
4811        cx: &mut Context<Self>,
4812    ) -> impl IntoElement {
4813        let (is_tree_view, entry_count) = match &self.view_mode {
4814            GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()),
4815            GitPanelViewMode::Flat => (false, self.entries.len()),
4816        };
4817        let repo = repo.downgrade();
4818
4819        v_flex()
4820            .flex_1()
4821            .size_full()
4822            .overflow_hidden()
4823            .relative()
4824            .child(
4825                h_flex()
4826                    .flex_1()
4827                    .size_full()
4828                    .relative()
4829                    .overflow_hidden()
4830                    .child(
4831                        uniform_list(
4832                            "entries",
4833                            entry_count,
4834                            cx.processor(move |this, range: Range<usize>, window, cx| {
4835                                let Some(repo) = repo.upgrade() else {
4836                                    return Vec::new();
4837                                };
4838                                let repo = repo.read(cx);
4839
4840                                let mut items = Vec::with_capacity(range.end - range.start);
4841
4842                                for ix in range.into_iter().map(|ix| match &this.view_mode {
4843                                    GitPanelViewMode::Tree(state) => state.logical_indices[ix],
4844                                    GitPanelViewMode::Flat => ix,
4845                                }) {
4846                                    match &this.entries.get(ix) {
4847                                        Some(GitListEntry::Status(entry)) => {
4848                                            items.push(this.render_status_entry(
4849                                                ix,
4850                                                entry,
4851                                                0,
4852                                                has_write_access,
4853                                                repo,
4854                                                window,
4855                                                cx,
4856                                            ));
4857                                        }
4858                                        Some(GitListEntry::TreeStatus(entry)) => {
4859                                            items.push(this.render_status_entry(
4860                                                ix,
4861                                                &entry.entry,
4862                                                entry.depth,
4863                                                has_write_access,
4864                                                repo,
4865                                                window,
4866                                                cx,
4867                                            ));
4868                                        }
4869                                        Some(GitListEntry::Directory(entry)) => {
4870                                            items.push(this.render_directory_entry(
4871                                                ix,
4872                                                entry,
4873                                                has_write_access,
4874                                                window,
4875                                                cx,
4876                                            ));
4877                                        }
4878                                        Some(GitListEntry::Header(header)) => {
4879                                            items.push(this.render_list_header(
4880                                                ix,
4881                                                header,
4882                                                has_write_access,
4883                                                window,
4884                                                cx,
4885                                            ));
4886                                        }
4887                                        None => {}
4888                                    }
4889                                }
4890
4891                                items
4892                            }),
4893                        )
4894                        .when(is_tree_view, |list| {
4895                            let indent_size = px(TREE_INDENT);
4896                            list.with_decoration(
4897                                ui::indent_guides(indent_size, IndentGuideColors::panel(cx))
4898                                    .with_compute_indents_fn(
4899                                        cx.entity(),
4900                                        |this, range, _window, _cx| {
4901                                            this.compute_visible_depths(range)
4902                                        },
4903                                    )
4904                                    .with_render_fn(cx.entity(), |_, params, _, _| {
4905                                        // Magic number to align the tree item is 3 here
4906                                        // because we're using 12px as the left-side padding
4907                                        // and 3 makes the alignment work with the bounding box of the icon
4908                                        let left_offset = px(TREE_INDENT + 3_f32);
4909                                        let indent_size = params.indent_size;
4910                                        let item_height = params.item_height;
4911
4912                                        params
4913                                            .indent_guides
4914                                            .into_iter()
4915                                            .map(|layout| {
4916                                                let bounds = Bounds::new(
4917                                                    point(
4918                                                        layout.offset.x * indent_size + left_offset,
4919                                                        layout.offset.y * item_height,
4920                                                    ),
4921                                                    size(px(1.), layout.length * item_height),
4922                                                );
4923                                                RenderedIndentGuide {
4924                                                    bounds,
4925                                                    layout,
4926                                                    is_active: false,
4927                                                    hitbox: None,
4928                                                }
4929                                            })
4930                                            .collect()
4931                                    }),
4932                            )
4933                        })
4934                        .size_full()
4935                        .flex_grow()
4936                        .with_width_from_item(self.max_width_item_index)
4937                        .track_scroll(&self.scroll_handle),
4938                    )
4939                    .on_mouse_down(
4940                        MouseButton::Right,
4941                        cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4942                            this.deploy_panel_context_menu(event.position, window, cx)
4943                        }),
4944                    )
4945                    .custom_scrollbars(
4946                        Scrollbars::for_settings::<GitPanelSettings>()
4947                            .tracked_scroll_handle(&self.scroll_handle)
4948                            .with_track_along(
4949                                ScrollAxes::Horizontal,
4950                                cx.theme().colors().panel_background,
4951                            ),
4952                        window,
4953                        cx,
4954                    ),
4955            )
4956    }
4957
4958    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
4959        Label::new(label.into()).color(color)
4960    }
4961
4962    fn list_item_height(&self) -> Rems {
4963        rems(1.75)
4964    }
4965
4966    fn render_list_header(
4967        &self,
4968        ix: usize,
4969        header: &GitHeaderEntry,
4970        _: bool,
4971        _: &Window,
4972        _: &Context<Self>,
4973    ) -> AnyElement {
4974        let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
4975
4976        h_flex()
4977            .id(id)
4978            .h(self.list_item_height())
4979            .w_full()
4980            .items_end()
4981            .px_3()
4982            .pb_1()
4983            .child(
4984                Label::new(header.title())
4985                    .color(Color::Muted)
4986                    .size(LabelSize::Small)
4987                    .line_height_style(LineHeightStyle::UiLabel)
4988                    .single_line(),
4989            )
4990            .into_any_element()
4991    }
4992
4993    pub fn load_commit_details(
4994        &self,
4995        sha: String,
4996        cx: &mut Context<Self>,
4997    ) -> Task<anyhow::Result<CommitDetails>> {
4998        let Some(repo) = self.active_repository.clone() else {
4999            return Task::ready(Err(anyhow::anyhow!("no active repo")));
5000        };
5001        repo.update(cx, |repo, cx| {
5002            let show = repo.show(sha);
5003            cx.spawn(async move |_, _| show.await?)
5004        })
5005    }
5006
5007    fn deploy_entry_context_menu(
5008        &mut self,
5009        position: Point<Pixels>,
5010        ix: usize,
5011        window: &mut Window,
5012        cx: &mut Context<Self>,
5013    ) {
5014        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
5015            return;
5016        };
5017        let stage_title = if entry.status.staging().is_fully_staged() {
5018            "Unstage File"
5019        } else {
5020            "Stage File"
5021        };
5022        let restore_title = if entry.status.is_created() {
5023            "Trash File"
5024        } else {
5025            "Discard Changes"
5026        };
5027        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
5028            let is_created = entry.status.is_created();
5029            context_menu
5030                .context(self.focus_handle.clone())
5031                .action(stage_title, ToggleStaged.boxed_clone())
5032                .action(restore_title, git::RestoreFile::default().boxed_clone())
5033                .action_disabled_when(
5034                    !is_created,
5035                    "Add to .gitignore",
5036                    git::AddToGitignore.boxed_clone(),
5037                )
5038                .separator()
5039                .action("Open Diff", menu::Confirm.boxed_clone())
5040                .action("Open File", menu::SecondaryConfirm.boxed_clone())
5041                .separator()
5042                .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
5043        });
5044        self.selected_entry = Some(ix);
5045        self.set_context_menu(context_menu, position, window, cx);
5046    }
5047
5048    fn deploy_panel_context_menu(
5049        &mut self,
5050        position: Point<Pixels>,
5051        window: &mut Window,
5052        cx: &mut Context<Self>,
5053    ) {
5054        let context_menu = git_panel_context_menu(
5055            self.focus_handle.clone(),
5056            GitMenuState {
5057                has_tracked_changes: self.has_tracked_changes(),
5058                has_staged_changes: self.has_staged_changes(),
5059                has_unstaged_changes: self.has_unstaged_changes(),
5060                has_new_changes: self.new_count > 0,
5061                sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
5062                has_stash_items: self.stash_entries.entries.len() > 0,
5063                tree_view: GitPanelSettings::get_global(cx).tree_view,
5064            },
5065            window,
5066            cx,
5067        );
5068        self.set_context_menu(context_menu, position, window, cx);
5069    }
5070
5071    fn set_context_menu(
5072        &mut self,
5073        context_menu: Entity<ContextMenu>,
5074        position: Point<Pixels>,
5075        window: &Window,
5076        cx: &mut Context<Self>,
5077    ) {
5078        let subscription = cx.subscribe_in(
5079            &context_menu,
5080            window,
5081            |this, _, _: &DismissEvent, window, cx| {
5082                if this.context_menu.as_ref().is_some_and(|context_menu| {
5083                    context_menu.0.focus_handle(cx).contains_focused(window, cx)
5084                }) {
5085                    cx.focus_self(window);
5086                }
5087                this.context_menu.take();
5088                cx.notify();
5089            },
5090        );
5091        self.context_menu = Some((context_menu, position, subscription));
5092        cx.notify();
5093    }
5094
5095    fn render_status_entry(
5096        &self,
5097        ix: usize,
5098        entry: &GitStatusEntry,
5099        depth: usize,
5100        has_write_access: bool,
5101        repo: &Repository,
5102        window: &Window,
5103        cx: &Context<Self>,
5104    ) -> AnyElement {
5105        let tree_view = GitPanelSettings::get_global(cx).tree_view;
5106        let path_style = self.project.read(cx).path_style(cx);
5107        let git_path_style = ProjectSettings::get_global(cx).git.path_style;
5108        let display_name = entry.display_name(path_style);
5109
5110        let selected = self.selected_entry == Some(ix);
5111        let marked = self.marked_entries.contains(&ix);
5112        let status_style = GitPanelSettings::get_global(cx).status_style;
5113        let status = entry.status;
5114
5115        let has_conflict = status.is_conflicted();
5116        let is_modified = status.is_modified();
5117        let is_deleted = status.is_deleted();
5118        let is_created = status.is_created();
5119
5120        let label_color = if status_style == StatusStyle::LabelColor {
5121            if has_conflict {
5122                Color::VersionControlConflict
5123            } else if is_created {
5124                Color::VersionControlAdded
5125            } else if is_modified {
5126                Color::VersionControlModified
5127            } else if is_deleted {
5128                // We don't want a bunch of red labels in the list
5129                Color::Disabled
5130            } else {
5131                Color::VersionControlAdded
5132            }
5133        } else {
5134            Color::Default
5135        };
5136
5137        let path_color = if status.is_deleted() {
5138            Color::Disabled
5139        } else {
5140            Color::Muted
5141        };
5142
5143        let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
5144        let checkbox_wrapper_id: ElementId =
5145            ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
5146        let checkbox_id: ElementId =
5147            ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
5148
5149        let stage_status = GitPanel::stage_status_for_entry(entry, &repo);
5150        let mut is_staged: ToggleState = match stage_status {
5151            StageStatus::Staged => ToggleState::Selected,
5152            StageStatus::Unstaged => ToggleState::Unselected,
5153            StageStatus::PartiallyStaged => ToggleState::Indeterminate,
5154        };
5155        if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
5156            is_staged = ToggleState::Selected;
5157        }
5158
5159        let handle = cx.weak_entity();
5160
5161        let selected_bg_alpha = 0.08;
5162        let marked_bg_alpha = 0.12;
5163        let state_opacity_step = 0.04;
5164
5165        let info_color = cx.theme().status().info;
5166
5167        let base_bg = match (selected, marked) {
5168            (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
5169            (true, false) => info_color.alpha(selected_bg_alpha),
5170            (false, true) => info_color.alpha(marked_bg_alpha),
5171            _ => cx.theme().colors().ghost_element_background,
5172        };
5173
5174        let (hover_bg, active_bg) = if selected {
5175            (
5176                info_color.alpha(selected_bg_alpha + state_opacity_step),
5177                info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
5178            )
5179        } else {
5180            (
5181                cx.theme().colors().ghost_element_hover,
5182                cx.theme().colors().ghost_element_active,
5183            )
5184        };
5185
5186        let name_row = h_flex()
5187            .min_w_0()
5188            .flex_1()
5189            .gap_1()
5190            .child(git_status_icon(status))
5191            .map(|this| {
5192                if tree_view {
5193                    this.pl(px(depth as f32 * TREE_INDENT)).child(
5194                        self.entry_label(display_name, label_color)
5195                            .when(status.is_deleted(), Label::strikethrough)
5196                            .truncate(),
5197                    )
5198                } else {
5199                    this.child(self.path_formatted(
5200                        entry.parent_dir(path_style),
5201                        path_color,
5202                        display_name,
5203                        label_color,
5204                        path_style,
5205                        git_path_style,
5206                        status.is_deleted(),
5207                    ))
5208                }
5209            });
5210
5211        let id_for_diff_stat = id.clone();
5212
5213        h_flex()
5214            .id(id)
5215            .h(self.list_item_height())
5216            .w_full()
5217            .pl_3()
5218            .pr_1()
5219            .gap_1p5()
5220            .border_1()
5221            .border_r_2()
5222            .when(selected && self.focus_handle.is_focused(window), |el| {
5223                el.border_color(cx.theme().colors().panel_focused_border)
5224            })
5225            .bg(base_bg)
5226            .hover(|s| s.bg(hover_bg))
5227            .active(|s| s.bg(active_bg))
5228            .child(name_row)
5229            .when(GitPanelSettings::get_global(cx).diff_stats, |el| {
5230                el.when_some(
5231                    self.diff_stats.get(&entry.repo_path).copied(),
5232                    move |this, stat| {
5233                        let id = format!("diff-stat-{}", id_for_diff_stat);
5234                        this.child(ui::DiffStat::new(
5235                            id,
5236                            stat.added as usize,
5237                            stat.deleted as usize,
5238                        ))
5239                    },
5240                )
5241            })
5242            .child(
5243                div()
5244                    .id(checkbox_wrapper_id)
5245                    .flex_none()
5246                    .occlude()
5247                    .cursor_pointer()
5248                    .child(
5249                        Checkbox::new(checkbox_id, is_staged)
5250                            .disabled(!has_write_access)
5251                            .fill()
5252                            .elevation(ElevationIndex::Surface)
5253                            .on_click_ext({
5254                                let entry = entry.clone();
5255                                let this = cx.weak_entity();
5256                                move |_, click, window, cx| {
5257                                    this.update(cx, |this, cx| {
5258                                        if !has_write_access {
5259                                            return;
5260                                        }
5261                                        if click.modifiers().shift {
5262                                            this.stage_bulk(ix, cx);
5263                                        } else {
5264                                            let list_entry =
5265                                                if GitPanelSettings::get_global(cx).tree_view {
5266                                                    GitListEntry::TreeStatus(GitTreeStatusEntry {
5267                                                        entry: entry.clone(),
5268                                                        depth,
5269                                                    })
5270                                                } else {
5271                                                    GitListEntry::Status(entry.clone())
5272                                                };
5273                                            this.toggle_staged_for_entry(&list_entry, window, cx);
5274                                        }
5275                                        cx.stop_propagation();
5276                                    })
5277                                    .ok();
5278                                }
5279                            })
5280                            .tooltip(move |_window, cx| {
5281                                let action = match stage_status {
5282                                    StageStatus::Staged => "Unstage",
5283                                    StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5284                                };
5285                                let tooltip_name = action.to_string();
5286
5287                                Tooltip::for_action(tooltip_name, &ToggleStaged, cx)
5288                            }),
5289                    ),
5290            )
5291            .on_click({
5292                cx.listener(move |this, event: &ClickEvent, window, cx| {
5293                    this.selected_entry = Some(ix);
5294                    cx.notify();
5295                    if event.click_count() > 1 || event.modifiers().secondary() {
5296                        this.open_file(&Default::default(), window, cx)
5297                    } else {
5298                        this.open_diff(&Default::default(), window, cx);
5299                        this.focus_handle.focus(window, cx);
5300                    }
5301                })
5302            })
5303            .on_mouse_down(
5304                MouseButton::Right,
5305                move |event: &MouseDownEvent, window, cx| {
5306                    // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
5307                    if event.button != MouseButton::Right {
5308                        return;
5309                    }
5310
5311                    let Some(this) = handle.upgrade() else {
5312                        return;
5313                    };
5314                    this.update(cx, |this, cx| {
5315                        this.deploy_entry_context_menu(event.position, ix, window, cx);
5316                    });
5317                    cx.stop_propagation();
5318                },
5319            )
5320            .into_any_element()
5321    }
5322
5323    fn render_directory_entry(
5324        &self,
5325        ix: usize,
5326        entry: &GitTreeDirEntry,
5327        has_write_access: bool,
5328        window: &Window,
5329        cx: &Context<Self>,
5330    ) -> AnyElement {
5331        // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that
5332        let selected = self.selected_entry == Some(ix);
5333        let label_color = Color::Muted;
5334
5335        let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into());
5336        let checkbox_id: ElementId =
5337            ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into());
5338        let checkbox_wrapper_id: ElementId =
5339            ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into());
5340
5341        let selected_bg_alpha = 0.08;
5342        let state_opacity_step = 0.04;
5343
5344        let info_color = cx.theme().status().info;
5345        let colors = cx.theme().colors();
5346
5347        let (base_bg, hover_bg, active_bg) = if selected {
5348            (
5349                info_color.alpha(selected_bg_alpha),
5350                info_color.alpha(selected_bg_alpha + state_opacity_step),
5351                info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
5352            )
5353        } else {
5354            (
5355                colors.ghost_element_background,
5356                colors.ghost_element_hover,
5357                colors.ghost_element_active,
5358            )
5359        };
5360
5361        let folder_icon = if entry.expanded {
5362            IconName::FolderOpen
5363        } else {
5364            IconName::Folder
5365        };
5366
5367        let stage_status = if let Some(repo) = &self.active_repository {
5368            self.stage_status_for_directory(entry, repo.read(cx))
5369        } else {
5370            util::debug_panic!(
5371                "Won't have entries to render without an active repository in Git Panel"
5372            );
5373            StageStatus::PartiallyStaged
5374        };
5375
5376        let toggle_state: ToggleState = match stage_status {
5377            StageStatus::Staged => ToggleState::Selected,
5378            StageStatus::Unstaged => ToggleState::Unselected,
5379            StageStatus::PartiallyStaged => ToggleState::Indeterminate,
5380        };
5381
5382        let name_row = h_flex()
5383            .min_w_0()
5384            .gap_1()
5385            .pl(px(entry.depth as f32 * TREE_INDENT))
5386            .child(
5387                Icon::new(folder_icon)
5388                    .size(IconSize::Small)
5389                    .color(Color::Muted),
5390            )
5391            .child(self.entry_label(entry.name.clone(), label_color).truncate());
5392
5393        h_flex()
5394            .id(id)
5395            .h(self.list_item_height())
5396            .min_w_0()
5397            .w_full()
5398            .pl_3()
5399            .pr_1()
5400            .gap_1p5()
5401            .justify_between()
5402            .border_1()
5403            .border_r_2()
5404            .when(selected && self.focus_handle.is_focused(window), |el| {
5405                el.border_color(cx.theme().colors().panel_focused_border)
5406            })
5407            .bg(base_bg)
5408            .hover(|s| s.bg(hover_bg))
5409            .active(|s| s.bg(active_bg))
5410            .child(name_row)
5411            .child(
5412                div()
5413                    .id(checkbox_wrapper_id)
5414                    .flex_none()
5415                    .occlude()
5416                    .cursor_pointer()
5417                    .child(
5418                        Checkbox::new(checkbox_id, toggle_state)
5419                            .disabled(!has_write_access)
5420                            .fill()
5421                            .elevation(ElevationIndex::Surface)
5422                            .on_click({
5423                                let entry = entry.clone();
5424                                let this = cx.weak_entity();
5425                                move |_, window, cx| {
5426                                    this.update(cx, |this, cx| {
5427                                        if !has_write_access {
5428                                            return;
5429                                        }
5430                                        this.toggle_staged_for_entry(
5431                                            &GitListEntry::Directory(entry.clone()),
5432                                            window,
5433                                            cx,
5434                                        );
5435                                        cx.stop_propagation();
5436                                    })
5437                                    .ok();
5438                                }
5439                            })
5440                            .tooltip(move |_window, cx| {
5441                                let action = match stage_status {
5442                                    StageStatus::Staged => "Unstage",
5443                                    StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5444                                };
5445                                Tooltip::simple(format!("{action} folder"), cx)
5446                            }),
5447                    ),
5448            )
5449            .on_click({
5450                let key = entry.key.clone();
5451                cx.listener(move |this, _event: &ClickEvent, window, cx| {
5452                    this.selected_entry = Some(ix);
5453                    this.toggle_directory(&key, window, cx);
5454                })
5455            })
5456            .into_any_element()
5457    }
5458
5459    fn path_formatted(
5460        &self,
5461        directory: Option<String>,
5462        path_color: Color,
5463        file_name: String,
5464        label_color: Color,
5465        path_style: PathStyle,
5466        git_path_style: GitPathStyle,
5467        strikethrough: bool,
5468    ) -> Div {
5469        let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
5470        let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
5471
5472        let file_name = format!("{} ", file_name);
5473
5474        h_flex()
5475            .min_w_0()
5476            .overflow_hidden()
5477            .when(file_path_first, |this| this.flex_row_reverse())
5478            .child(
5479                div().flex_none().child(
5480                    self.entry_label(file_name, label_color)
5481                        .when(strikethrough, Label::strikethrough),
5482                ),
5483            )
5484            .when_some(directory, |this, dir| {
5485                let path_name = if file_name_first {
5486                    dir
5487                } else {
5488                    format!("{dir}{}", path_style.primary_separator())
5489                };
5490
5491                this.child(
5492                    self.entry_label(path_name, path_color)
5493                        .truncate_start()
5494                        .when(strikethrough, Label::strikethrough),
5495                )
5496            })
5497    }
5498
5499    fn has_write_access(&self, cx: &App) -> bool {
5500        !self.project.read(cx).is_read_only(cx)
5501    }
5502
5503    pub fn amend_pending(&self) -> bool {
5504        self.amend_pending
5505    }
5506
5507    /// Sets the pending amend state, ensuring that the original commit message
5508    /// is either saved, when `value` is `true` and there's no pending amend, or
5509    /// restored, when `value` is `false` and there's a pending amend.
5510    pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
5511        if value && !self.amend_pending {
5512            let current_message = self.commit_message_buffer(cx).read(cx).text();
5513            self.original_commit_message = if current_message.trim().is_empty() {
5514                None
5515            } else {
5516                Some(current_message)
5517            };
5518        } else if !value && self.amend_pending {
5519            let message = self.original_commit_message.take().unwrap_or_default();
5520            self.commit_message_buffer(cx).update(cx, |buffer, cx| {
5521                let start = buffer.anchor_before(0);
5522                let end = buffer.anchor_after(buffer.len());
5523                buffer.edit([(start..end, message)], None, cx);
5524            });
5525        }
5526
5527        self.amend_pending = value;
5528        self.serialize(cx);
5529        cx.notify();
5530    }
5531
5532    pub fn signoff_enabled(&self) -> bool {
5533        self.signoff_enabled
5534    }
5535
5536    pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
5537        self.signoff_enabled = value;
5538        self.serialize(cx);
5539        cx.notify();
5540    }
5541
5542    pub fn toggle_signoff_enabled(
5543        &mut self,
5544        _: &Signoff,
5545        _window: &mut Window,
5546        cx: &mut Context<Self>,
5547    ) {
5548        self.set_signoff_enabled(!self.signoff_enabled, cx);
5549    }
5550
5551    pub async fn load(
5552        workspace: WeakEntity<Workspace>,
5553        mut cx: AsyncWindowContext,
5554    ) -> anyhow::Result<Entity<Self>> {
5555        let serialized_panel = match workspace
5556            .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
5557            .ok()
5558            .flatten()
5559        {
5560            Some(serialization_key) => cx
5561                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
5562                .await
5563                .context("loading git panel")
5564                .log_err()
5565                .flatten()
5566                .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
5567                .transpose()
5568                .log_err()
5569                .flatten(),
5570            None => None,
5571        };
5572
5573        workspace.update_in(&mut cx, |workspace, window, cx| {
5574            let panel = GitPanel::new(workspace, window, cx);
5575
5576            if let Some(serialized_panel) = serialized_panel {
5577                panel.update(cx, |panel, cx| {
5578                    panel.width = serialized_panel.width;
5579                    panel.amend_pending = serialized_panel.amend_pending;
5580                    panel.signoff_enabled = serialized_panel.signoff_enabled;
5581                    cx.notify();
5582                })
5583            }
5584
5585            panel
5586        })
5587    }
5588
5589    fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
5590        let Some(op) = self.bulk_staging.as_ref() else {
5591            return;
5592        };
5593        let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else {
5594            return;
5595        };
5596        if let Some(entry) = self.entries.get(index)
5597            && let Some(entry) = entry.status_entry()
5598        {
5599            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
5600        }
5601        if index < anchor_index {
5602            std::mem::swap(&mut index, &mut anchor_index);
5603        }
5604        let entries = self
5605            .entries
5606            .get(anchor_index..=index)
5607            .unwrap_or_default()
5608            .iter()
5609            .filter_map(|entry| entry.status_entry().cloned())
5610            .collect::<Vec<_>>();
5611        self.change_file_stage(true, entries, cx);
5612    }
5613
5614    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
5615        let Some(repo) = self.active_repository.as_ref() else {
5616            return;
5617        };
5618        self.bulk_staging = Some(BulkStaging {
5619            repo_id: repo.read(cx).id,
5620            anchor: path,
5621        });
5622    }
5623
5624    pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
5625        self.set_amend_pending(!self.amend_pending, cx);
5626        if self.amend_pending {
5627            self.load_last_commit_message(cx);
5628        }
5629    }
5630}
5631
5632impl Render for GitPanel {
5633    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5634        let project = self.project.read(cx);
5635        let has_entries = !self.entries.is_empty();
5636        let room = self.workspace.upgrade().and_then(|_workspace| {
5637            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
5638        });
5639
5640        let has_write_access = self.has_write_access(cx);
5641
5642        let has_co_authors = room.is_some_and(|room| {
5643            self.load_local_committer(cx);
5644            let room = room.read(cx);
5645            room.remote_participants()
5646                .values()
5647                .any(|remote_participant| remote_participant.can_write())
5648        });
5649
5650        v_flex()
5651            .id("git_panel")
5652            .key_context(self.dispatch_context(window, cx))
5653            .track_focus(&self.focus_handle)
5654            .when(has_write_access && !project.is_read_only(cx), |this| {
5655                this.on_action(cx.listener(Self::toggle_staged_for_selected))
5656                    .on_action(cx.listener(Self::stage_range))
5657                    .on_action(cx.listener(GitPanel::on_commit))
5658                    .on_action(cx.listener(GitPanel::on_amend))
5659                    .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
5660                    .on_action(cx.listener(Self::stage_all))
5661                    .on_action(cx.listener(Self::unstage_all))
5662                    .on_action(cx.listener(Self::stage_selected))
5663                    .on_action(cx.listener(Self::unstage_selected))
5664                    .on_action(cx.listener(Self::restore_tracked_files))
5665                    .on_action(cx.listener(Self::revert_selected))
5666                    .on_action(cx.listener(Self::add_to_gitignore))
5667                    .on_action(cx.listener(Self::clean_all))
5668                    .on_action(cx.listener(Self::generate_commit_message_action))
5669                    .on_action(cx.listener(Self::stash_all))
5670                    .on_action(cx.listener(Self::stash_pop))
5671            })
5672            .on_action(cx.listener(Self::collapse_selected_entry))
5673            .on_action(cx.listener(Self::expand_selected_entry))
5674            .on_action(cx.listener(Self::select_first))
5675            .on_action(cx.listener(Self::select_next))
5676            .on_action(cx.listener(Self::select_previous))
5677            .on_action(cx.listener(Self::select_last))
5678            .on_action(cx.listener(Self::first_entry))
5679            .on_action(cx.listener(Self::next_entry))
5680            .on_action(cx.listener(Self::previous_entry))
5681            .on_action(cx.listener(Self::last_entry))
5682            .on_action(cx.listener(Self::close_panel))
5683            .on_action(cx.listener(Self::open_diff))
5684            .on_action(cx.listener(Self::open_file))
5685            .on_action(cx.listener(Self::file_history))
5686            .on_action(cx.listener(Self::focus_changes_list))
5687            .on_action(cx.listener(Self::focus_editor))
5688            .on_action(cx.listener(Self::expand_commit_editor))
5689            .when(has_write_access && has_co_authors, |git_panel| {
5690                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
5691            })
5692            .on_action(cx.listener(Self::toggle_sort_by_path))
5693            .on_action(cx.listener(Self::toggle_tree_view))
5694            .size_full()
5695            .overflow_hidden()
5696            .bg(cx.theme().colors().panel_background)
5697            .child(
5698                v_flex()
5699                    .size_full()
5700                    .children(self.render_panel_header(window, cx))
5701                    .map(|this| {
5702                        if let Some(repo) = self.active_repository.clone()
5703                            && has_entries
5704                        {
5705                            this.child(self.render_entries(has_write_access, repo, window, cx))
5706                        } else {
5707                            this.child(self.render_empty_state(cx).into_any_element())
5708                        }
5709                    })
5710                    .children(self.render_footer(window, cx))
5711                    .when(self.amend_pending, |this| {
5712                        this.child(self.render_pending_amend(cx))
5713                    })
5714                    .when(!self.amend_pending, |this| {
5715                        this.children(self.render_previous_commit(window, cx))
5716                    })
5717                    .into_any_element(),
5718            )
5719            .children(self.context_menu.as_ref().map(|(menu, position, _)| {
5720                deferred(
5721                    anchored()
5722                        .position(*position)
5723                        .anchor(Corner::TopLeft)
5724                        .child(menu.clone()),
5725                )
5726                .with_priority(1)
5727            }))
5728    }
5729}
5730
5731impl Focusable for GitPanel {
5732    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
5733        if self.entries.is_empty() {
5734            self.commit_editor.focus_handle(cx)
5735        } else {
5736            self.focus_handle.clone()
5737        }
5738    }
5739}
5740
5741impl EventEmitter<Event> for GitPanel {}
5742
5743impl EventEmitter<PanelEvent> for GitPanel {}
5744
5745pub(crate) struct GitPanelAddon {
5746    pub(crate) workspace: WeakEntity<Workspace>,
5747}
5748
5749impl editor::Addon for GitPanelAddon {
5750    fn to_any(&self) -> &dyn std::any::Any {
5751        self
5752    }
5753
5754    fn render_buffer_header_controls(
5755        &self,
5756        excerpt_info: &ExcerptInfo,
5757        window: &Window,
5758        cx: &App,
5759    ) -> Option<AnyElement> {
5760        let file = excerpt_info.buffer.file()?;
5761        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
5762
5763        git_panel
5764            .read(cx)
5765            .render_buffer_header_controls(&git_panel, file, window, cx)
5766    }
5767}
5768
5769impl Panel for GitPanel {
5770    fn persistent_name() -> &'static str {
5771        "GitPanel"
5772    }
5773
5774    fn panel_key() -> &'static str {
5775        GIT_PANEL_KEY
5776    }
5777
5778    fn position(&self, _: &Window, cx: &App) -> DockPosition {
5779        GitPanelSettings::get_global(cx).dock
5780    }
5781
5782    fn position_is_valid(&self, position: DockPosition) -> bool {
5783        matches!(position, DockPosition::Left | DockPosition::Right)
5784    }
5785
5786    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
5787        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
5788            settings.git_panel.get_or_insert_default().dock = Some(position.into())
5789        });
5790    }
5791
5792    fn size(&self, _: &Window, cx: &App) -> Pixels {
5793        self.width
5794            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
5795    }
5796
5797    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
5798        self.width = size;
5799        self.serialize(cx);
5800        cx.notify();
5801    }
5802
5803    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
5804        Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
5805    }
5806
5807    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
5808        Some("Git Panel")
5809    }
5810
5811    fn toggle_action(&self) -> Box<dyn Action> {
5812        Box::new(ToggleFocus)
5813    }
5814
5815    fn activation_priority(&self) -> u32 {
5816        2
5817    }
5818}
5819
5820impl PanelHeader for GitPanel {}
5821
5822pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
5823    v_flex()
5824        .size_full()
5825        .gap(px(8.))
5826        .p_2()
5827        .bg(cx.theme().colors().editor_background)
5828}
5829
5830pub(crate) fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorStyle {
5831    let settings = ThemeSettings::get_global(cx);
5832
5833    let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
5834
5835    let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
5836        (
5837            settings.buffer_font.family.clone(),
5838            settings.buffer_font.fallbacks.clone(),
5839            settings.buffer_font.features.clone(),
5840            settings.buffer_font.weight,
5841            font_size * settings.buffer_line_height.value(),
5842        )
5843    } else {
5844        (
5845            settings.ui_font.family.clone(),
5846            settings.ui_font.fallbacks.clone(),
5847            settings.ui_font.features.clone(),
5848            settings.ui_font.weight,
5849            window.line_height(),
5850        )
5851    };
5852
5853    EditorStyle {
5854        background: cx.theme().colors().editor_background,
5855        local_player: cx.theme().players().local(),
5856        text: TextStyle {
5857            color: cx.theme().colors().text,
5858            font_family,
5859            font_fallbacks,
5860            font_features,
5861            font_size: TextSize::Small.rems(cx).into(),
5862            font_weight,
5863            line_height: line_height.into(),
5864            ..Default::default()
5865        },
5866        syntax: cx.theme().syntax().clone(),
5867        ..Default::default()
5868    }
5869}
5870
5871struct GitPanelMessageTooltip {
5872    commit_tooltip: Option<Entity<CommitTooltip>>,
5873}
5874
5875impl GitPanelMessageTooltip {
5876    fn new(
5877        git_panel: Entity<GitPanel>,
5878        sha: SharedString,
5879        repository: Entity<Repository>,
5880        window: &mut Window,
5881        cx: &mut App,
5882    ) -> Entity<Self> {
5883        let remote_url = repository.read(cx).default_remote_url();
5884        cx.new(|cx| {
5885            cx.spawn_in(window, async move |this, cx| {
5886                let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
5887                    (
5888                        git_panel.load_commit_details(sha.to_string(), cx),
5889                        git_panel.workspace.clone(),
5890                    )
5891                });
5892                let details = details.await?;
5893                let provider_registry = cx
5894                    .update(|_, app| GitHostingProviderRegistry::default_global(app))
5895                    .ok();
5896
5897                let commit_details = crate::commit_tooltip::CommitDetails {
5898                    sha: details.sha.clone(),
5899                    author_name: details.author_name.clone(),
5900                    author_email: details.author_email.clone(),
5901                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
5902                    message: Some(ParsedCommitMessage::parse(
5903                        details.sha.to_string(),
5904                        details.message.to_string(),
5905                        remote_url.as_deref(),
5906                        provider_registry,
5907                    )),
5908                };
5909
5910                this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
5911                    this.commit_tooltip = Some(cx.new(move |cx| {
5912                        CommitTooltip::new(commit_details, repository, workspace, cx)
5913                    }));
5914                    cx.notify();
5915                })
5916            })
5917            .detach();
5918
5919            Self {
5920                commit_tooltip: None,
5921            }
5922        })
5923    }
5924}
5925
5926impl Render for GitPanelMessageTooltip {
5927    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5928        if let Some(commit_tooltip) = &self.commit_tooltip {
5929            commit_tooltip.clone().into_any_element()
5930        } else {
5931            gpui::Empty.into_any_element()
5932        }
5933    }
5934}
5935
5936#[derive(IntoElement, RegisterComponent)]
5937pub struct PanelRepoFooter {
5938    active_repository: SharedString,
5939    branch: Option<Branch>,
5940    head_commit: Option<CommitDetails>,
5941
5942    // Getting a GitPanel in previews will be difficult.
5943    //
5944    // For now just take an option here, and we won't bind handlers to buttons in previews.
5945    git_panel: Option<Entity<GitPanel>>,
5946}
5947
5948impl PanelRepoFooter {
5949    pub fn new(
5950        active_repository: SharedString,
5951        branch: Option<Branch>,
5952        head_commit: Option<CommitDetails>,
5953        git_panel: Option<Entity<GitPanel>>,
5954    ) -> Self {
5955        Self {
5956            active_repository,
5957            branch,
5958            head_commit,
5959            git_panel,
5960        }
5961    }
5962
5963    pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
5964        Self {
5965            active_repository,
5966            branch,
5967            head_commit: None,
5968            git_panel: None,
5969        }
5970    }
5971}
5972
5973impl RenderOnce for PanelRepoFooter {
5974    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
5975        let project = self
5976            .git_panel
5977            .as_ref()
5978            .map(|panel| panel.read(cx).project.clone());
5979
5980        let (workspace, repo) = self
5981            .git_panel
5982            .as_ref()
5983            .map(|panel| {
5984                let panel = panel.read(cx);
5985                (panel.workspace.clone(), panel.active_repository.clone())
5986            })
5987            .unzip();
5988
5989        let single_repo = project
5990            .as_ref()
5991            .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
5992            .unwrap_or(true);
5993
5994        const MAX_BRANCH_LEN: usize = 16;
5995        const MAX_REPO_LEN: usize = 16;
5996        const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
5997        const MAX_SHORT_SHA_LEN: usize = 8;
5998        let branch_name = self
5999            .branch
6000            .as_ref()
6001            .map(|branch| branch.name().to_owned())
6002            .or_else(|| {
6003                self.head_commit.as_ref().map(|commit| {
6004                    commit
6005                        .sha
6006                        .chars()
6007                        .take(MAX_SHORT_SHA_LEN)
6008                        .collect::<String>()
6009                })
6010            })
6011            .unwrap_or_else(|| " (no branch)".to_owned());
6012        let show_separator = self.branch.is_some() || self.head_commit.is_some();
6013
6014        let active_repo_name = self.active_repository.clone();
6015
6016        let branch_actual_len = branch_name.len();
6017        let repo_actual_len = active_repo_name.len();
6018
6019        // ideally, show the whole branch and repo names but
6020        // when we can't, use a budget to allocate space between the two
6021        let (repo_display_len, branch_display_len) =
6022            if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
6023                (repo_actual_len, branch_actual_len)
6024            } else if branch_actual_len <= MAX_BRANCH_LEN {
6025                let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
6026                (repo_space, branch_actual_len)
6027            } else if repo_actual_len <= MAX_REPO_LEN {
6028                let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
6029                (repo_actual_len, branch_space)
6030            } else {
6031                (MAX_REPO_LEN, MAX_BRANCH_LEN)
6032            };
6033
6034        let truncated_repo_name = if repo_actual_len <= repo_display_len {
6035            active_repo_name.to_string()
6036        } else {
6037            util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
6038        };
6039
6040        let truncated_branch_name = if branch_actual_len <= branch_display_len {
6041            branch_name
6042        } else {
6043            util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
6044        };
6045
6046        let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
6047            .size(ButtonSize::None)
6048            .label_size(LabelSize::Small);
6049
6050        let repo_selector = PopoverMenu::new("repository-switcher")
6051            .menu({
6052                let project = project;
6053                move |window, cx| {
6054                    let project = project.clone()?;
6055                    Some(cx.new(|cx| RepositorySelector::new(project, rems(20.), window, cx)))
6056                }
6057            })
6058            .trigger_with_tooltip(
6059                repo_selector_trigger
6060                    .when(single_repo, |this| this.disabled(true).color(Color::Muted))
6061                    .truncate(true),
6062                move |_, cx| {
6063                    if single_repo {
6064                        cx.new(|_| Empty).into()
6065                    } else {
6066                        Tooltip::simple("Switch Active Repository", cx)
6067                    }
6068                },
6069            )
6070            .anchor(Corner::BottomLeft)
6071            .offset(gpui::Point {
6072                x: px(0.0),
6073                y: px(-2.0),
6074            })
6075            .into_any_element();
6076
6077        let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
6078            .size(ButtonSize::None)
6079            .label_size(LabelSize::Small)
6080            .truncate(true)
6081            .on_click(|_, window, cx| {
6082                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
6083            });
6084
6085        let branch_selector = PopoverMenu::new("popover-button")
6086            .menu(move |window, cx| {
6087                let workspace = workspace.clone()?;
6088                let repo = repo.clone().flatten();
6089                Some(branch_picker::popover(workspace, false, repo, window, cx))
6090            })
6091            .trigger_with_tooltip(
6092                branch_selector_button,
6093                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
6094            )
6095            .anchor(Corner::BottomLeft)
6096            .offset(gpui::Point {
6097                x: px(0.0),
6098                y: px(-2.0),
6099            });
6100
6101        h_flex()
6102            .h(px(36.))
6103            .w_full()
6104            .px_2()
6105            .justify_between()
6106            .gap_1()
6107            .child(
6108                h_flex()
6109                    .flex_1()
6110                    .overflow_hidden()
6111                    .gap_px()
6112                    .child(
6113                        Icon::new(IconName::GitBranchAlt)
6114                            .size(IconSize::Small)
6115                            .color(if single_repo {
6116                                Color::Disabled
6117                            } else {
6118                                Color::Muted
6119                            }),
6120                    )
6121                    .child(repo_selector)
6122                    .when(show_separator, |this| {
6123                        this.child(
6124                            div()
6125                                .text_sm()
6126                                .text_color(cx.theme().colors().icon_muted.opacity(0.5))
6127                                .child("/"),
6128                        )
6129                    })
6130                    .child(branch_selector),
6131            )
6132            .children(if let Some(git_panel) = self.git_panel {
6133                git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
6134            } else {
6135                None
6136            })
6137    }
6138}
6139
6140impl Component for PanelRepoFooter {
6141    fn scope() -> ComponentScope {
6142        ComponentScope::VersionControl
6143    }
6144
6145    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
6146        let unknown_upstream = None;
6147        let no_remote_upstream = Some(UpstreamTracking::Gone);
6148        let ahead_of_upstream = Some(
6149            UpstreamTrackingStatus {
6150                ahead: 2,
6151                behind: 0,
6152            }
6153            .into(),
6154        );
6155        let behind_upstream = Some(
6156            UpstreamTrackingStatus {
6157                ahead: 0,
6158                behind: 2,
6159            }
6160            .into(),
6161        );
6162        let ahead_and_behind_upstream = Some(
6163            UpstreamTrackingStatus {
6164                ahead: 3,
6165                behind: 1,
6166            }
6167            .into(),
6168        );
6169
6170        let not_ahead_or_behind_upstream = Some(
6171            UpstreamTrackingStatus {
6172                ahead: 0,
6173                behind: 0,
6174            }
6175            .into(),
6176        );
6177
6178        fn branch(upstream: Option<UpstreamTracking>) -> Branch {
6179            Branch {
6180                is_head: true,
6181                ref_name: "some-branch".into(),
6182                upstream: upstream.map(|tracking| Upstream {
6183                    ref_name: "origin/some-branch".into(),
6184                    tracking,
6185                }),
6186                most_recent_commit: Some(CommitSummary {
6187                    sha: "abc123".into(),
6188                    subject: "Modify stuff".into(),
6189                    commit_timestamp: 1710932954,
6190                    author_name: "John Doe".into(),
6191                    has_parent: true,
6192                }),
6193            }
6194        }
6195
6196        fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
6197            Branch {
6198                is_head: true,
6199                ref_name: branch_name.to_string().into(),
6200                upstream: upstream.map(|tracking| Upstream {
6201                    ref_name: format!("zed/{}", branch_name).into(),
6202                    tracking,
6203                }),
6204                most_recent_commit: Some(CommitSummary {
6205                    sha: "abc123".into(),
6206                    subject: "Modify stuff".into(),
6207                    commit_timestamp: 1710932954,
6208                    author_name: "John Doe".into(),
6209                    has_parent: true,
6210                }),
6211            }
6212        }
6213
6214        fn active_repository(id: usize) -> SharedString {
6215            format!("repo-{}", id).into()
6216        }
6217
6218        let example_width = px(340.);
6219        Some(
6220            v_flex()
6221                .gap_6()
6222                .w_full()
6223                .flex_none()
6224                .children(vec![
6225                    example_group_with_title(
6226                        "Action Button States",
6227                        vec![
6228                            single_example(
6229                                "No Branch",
6230                                div()
6231                                    .w(example_width)
6232                                    .overflow_hidden()
6233                                    .child(PanelRepoFooter::new_preview(active_repository(1), None))
6234                                    .into_any_element(),
6235                            ),
6236                            single_example(
6237                                "Remote status unknown",
6238                                div()
6239                                    .w(example_width)
6240                                    .overflow_hidden()
6241                                    .child(PanelRepoFooter::new_preview(
6242                                        active_repository(2),
6243                                        Some(branch(unknown_upstream)),
6244                                    ))
6245                                    .into_any_element(),
6246                            ),
6247                            single_example(
6248                                "No Remote Upstream",
6249                                div()
6250                                    .w(example_width)
6251                                    .overflow_hidden()
6252                                    .child(PanelRepoFooter::new_preview(
6253                                        active_repository(3),
6254                                        Some(branch(no_remote_upstream)),
6255                                    ))
6256                                    .into_any_element(),
6257                            ),
6258                            single_example(
6259                                "Not Ahead or Behind",
6260                                div()
6261                                    .w(example_width)
6262                                    .overflow_hidden()
6263                                    .child(PanelRepoFooter::new_preview(
6264                                        active_repository(4),
6265                                        Some(branch(not_ahead_or_behind_upstream)),
6266                                    ))
6267                                    .into_any_element(),
6268                            ),
6269                            single_example(
6270                                "Behind remote",
6271                                div()
6272                                    .w(example_width)
6273                                    .overflow_hidden()
6274                                    .child(PanelRepoFooter::new_preview(
6275                                        active_repository(5),
6276                                        Some(branch(behind_upstream)),
6277                                    ))
6278                                    .into_any_element(),
6279                            ),
6280                            single_example(
6281                                "Ahead of remote",
6282                                div()
6283                                    .w(example_width)
6284                                    .overflow_hidden()
6285                                    .child(PanelRepoFooter::new_preview(
6286                                        active_repository(6),
6287                                        Some(branch(ahead_of_upstream)),
6288                                    ))
6289                                    .into_any_element(),
6290                            ),
6291                            single_example(
6292                                "Ahead and behind remote",
6293                                div()
6294                                    .w(example_width)
6295                                    .overflow_hidden()
6296                                    .child(PanelRepoFooter::new_preview(
6297                                        active_repository(7),
6298                                        Some(branch(ahead_and_behind_upstream)),
6299                                    ))
6300                                    .into_any_element(),
6301                            ),
6302                        ],
6303                    )
6304                    .grow()
6305                    .vertical(),
6306                ])
6307                .children(vec![
6308                    example_group_with_title(
6309                        "Labels",
6310                        vec![
6311                            single_example(
6312                                "Short Branch & Repo",
6313                                div()
6314                                    .w(example_width)
6315                                    .overflow_hidden()
6316                                    .child(PanelRepoFooter::new_preview(
6317                                        SharedString::from("zed"),
6318                                        Some(custom("main", behind_upstream)),
6319                                    ))
6320                                    .into_any_element(),
6321                            ),
6322                            single_example(
6323                                "Long Branch",
6324                                div()
6325                                    .w(example_width)
6326                                    .overflow_hidden()
6327                                    .child(PanelRepoFooter::new_preview(
6328                                        SharedString::from("zed"),
6329                                        Some(custom(
6330                                            "redesign-and-update-git-ui-list-entry-style",
6331                                            behind_upstream,
6332                                        )),
6333                                    ))
6334                                    .into_any_element(),
6335                            ),
6336                            single_example(
6337                                "Long Repo",
6338                                div()
6339                                    .w(example_width)
6340                                    .overflow_hidden()
6341                                    .child(PanelRepoFooter::new_preview(
6342                                        SharedString::from("zed-industries-community-examples"),
6343                                        Some(custom("gpui", ahead_of_upstream)),
6344                                    ))
6345                                    .into_any_element(),
6346                            ),
6347                            single_example(
6348                                "Long Repo & Branch",
6349                                div()
6350                                    .w(example_width)
6351                                    .overflow_hidden()
6352                                    .child(PanelRepoFooter::new_preview(
6353                                        SharedString::from("zed-industries-community-examples"),
6354                                        Some(custom(
6355                                            "redesign-and-update-git-ui-list-entry-style",
6356                                            behind_upstream,
6357                                        )),
6358                                    ))
6359                                    .into_any_element(),
6360                            ),
6361                            single_example(
6362                                "Uppercase Repo",
6363                                div()
6364                                    .w(example_width)
6365                                    .overflow_hidden()
6366                                    .child(PanelRepoFooter::new_preview(
6367                                        SharedString::from("LICENSES"),
6368                                        Some(custom("main", ahead_of_upstream)),
6369                                    ))
6370                                    .into_any_element(),
6371                            ),
6372                            single_example(
6373                                "Uppercase Branch",
6374                                div()
6375                                    .w(example_width)
6376                                    .overflow_hidden()
6377                                    .child(PanelRepoFooter::new_preview(
6378                                        SharedString::from("zed"),
6379                                        Some(custom("update-README", behind_upstream)),
6380                                    ))
6381                                    .into_any_element(),
6382                            ),
6383                        ],
6384                    )
6385                    .grow()
6386                    .vertical(),
6387                ])
6388                .into_any_element(),
6389        )
6390    }
6391}
6392
6393fn open_output(
6394    operation: impl Into<SharedString>,
6395    workspace: &mut Workspace,
6396    output: &str,
6397    window: &mut Window,
6398    cx: &mut Context<Workspace>,
6399) {
6400    let operation = operation.into();
6401    let buffer = cx.new(|cx| Buffer::local(output, cx));
6402    buffer.update(cx, |buffer, cx| {
6403        buffer.set_capability(language::Capability::ReadOnly, cx);
6404    });
6405    let editor = cx.new(|cx| {
6406        let mut editor = Editor::for_buffer(buffer, None, window, cx);
6407        editor.buffer().update(cx, |buffer, cx| {
6408            buffer.set_title(format!("Output from git {operation}"), cx);
6409        });
6410        editor.set_read_only(true);
6411        editor
6412    });
6413
6414    workspace.add_item_to_center(Box::new(editor), window, cx);
6415}
6416
6417pub(crate) fn show_error_toast(
6418    workspace: Entity<Workspace>,
6419    action: impl Into<SharedString>,
6420    e: anyhow::Error,
6421    cx: &mut App,
6422) {
6423    let action = action.into();
6424    let message = e.to_string().trim().to_string();
6425    if message
6426        .matches(git::repository::REMOTE_CANCELLED_BY_USER)
6427        .next()
6428        .is_some()
6429    { // Hide the cancelled by user message
6430    } else {
6431        workspace.update(cx, |workspace, cx| {
6432            let workspace_weak = cx.weak_entity();
6433            let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
6434                this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
6435                    .action("View Log", move |window, cx| {
6436                        let message = message.clone();
6437                        let action = action.clone();
6438                        workspace_weak
6439                            .update(cx, move |workspace, cx| {
6440                                open_output(action, workspace, &message, window, cx)
6441                            })
6442                            .ok();
6443                    })
6444            });
6445            workspace.toggle_status_toast(toast, cx)
6446        });
6447    }
6448}
6449
6450#[cfg(test)]
6451mod tests {
6452    use git::{
6453        repository::repo_path,
6454        status::{StatusCode, UnmergedStatus, UnmergedStatusCode},
6455    };
6456    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
6457    use indoc::indoc;
6458    use project::FakeFs;
6459    use serde_json::json;
6460    use settings::SettingsStore;
6461    use theme::LoadThemes;
6462    use util::path;
6463    use util::rel_path::rel_path;
6464
6465    use workspace::MultiWorkspace;
6466
6467    use super::*;
6468
6469    fn init_test(cx: &mut gpui::TestAppContext) {
6470        zlog::init_test();
6471
6472        cx.update(|cx| {
6473            let settings_store = SettingsStore::test(cx);
6474            cx.set_global(settings_store);
6475            theme::init(LoadThemes::JustBase, cx);
6476            editor::init(cx);
6477            crate::init(cx);
6478        });
6479    }
6480
6481    #[gpui::test]
6482    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
6483        init_test(cx);
6484        let fs = FakeFs::new(cx.background_executor.clone());
6485        fs.insert_tree(
6486            "/root",
6487            json!({
6488                "zed": {
6489                    ".git": {},
6490                    "crates": {
6491                        "gpui": {
6492                            "gpui.rs": "fn main() {}"
6493                        },
6494                        "util": {
6495                            "util.rs": "fn do_it() {}"
6496                        }
6497                    }
6498                },
6499            }),
6500        )
6501        .await;
6502
6503        fs.set_status_for_repo(
6504            Path::new(path!("/root/zed/.git")),
6505            &[
6506                ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()),
6507                ("crates/util/util.rs", StatusCode::Modified.worktree()),
6508            ],
6509        );
6510
6511        let project =
6512            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
6513        let window_handle =
6514            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6515        let workspace = window_handle
6516            .read_with(cx, |mw, _| mw.workspace().clone())
6517            .unwrap();
6518        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
6519
6520        cx.read(|cx| {
6521            project
6522                .read(cx)
6523                .worktrees(cx)
6524                .next()
6525                .unwrap()
6526                .read(cx)
6527                .as_local()
6528                .unwrap()
6529                .scan_complete()
6530        })
6531        .await;
6532
6533        cx.executor().run_until_parked();
6534
6535        let panel = workspace.update_in(cx, GitPanel::new);
6536
6537        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6538            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6539        });
6540        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6541        handle.await;
6542
6543        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6544        pretty_assertions::assert_eq!(
6545            entries,
6546            [
6547                GitListEntry::Header(GitHeaderEntry {
6548                    header: Section::Tracked
6549                }),
6550                GitListEntry::Status(GitStatusEntry {
6551                    repo_path: repo_path("crates/gpui/gpui.rs"),
6552                    status: StatusCode::Modified.worktree(),
6553                    staging: StageStatus::Unstaged,
6554                }),
6555                GitListEntry::Status(GitStatusEntry {
6556                    repo_path: repo_path("crates/util/util.rs"),
6557                    status: StatusCode::Modified.worktree(),
6558                    staging: StageStatus::Unstaged,
6559                },),
6560            ],
6561        );
6562
6563        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6564            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6565        });
6566        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6567        handle.await;
6568        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6569        pretty_assertions::assert_eq!(
6570            entries,
6571            [
6572                GitListEntry::Header(GitHeaderEntry {
6573                    header: Section::Tracked
6574                }),
6575                GitListEntry::Status(GitStatusEntry {
6576                    repo_path: repo_path("crates/gpui/gpui.rs"),
6577                    status: StatusCode::Modified.worktree(),
6578                    staging: StageStatus::Unstaged,
6579                }),
6580                GitListEntry::Status(GitStatusEntry {
6581                    repo_path: repo_path("crates/util/util.rs"),
6582                    status: StatusCode::Modified.worktree(),
6583                    staging: StageStatus::Unstaged,
6584                },),
6585            ],
6586        );
6587    }
6588
6589    #[gpui::test]
6590    async fn test_bulk_staging(cx: &mut TestAppContext) {
6591        use GitListEntry::*;
6592
6593        init_test(cx);
6594        let fs = FakeFs::new(cx.background_executor.clone());
6595        fs.insert_tree(
6596            "/root",
6597            json!({
6598                "project": {
6599                    ".git": {},
6600                    "src": {
6601                        "main.rs": "fn main() {}",
6602                        "lib.rs": "pub fn hello() {}",
6603                        "utils.rs": "pub fn util() {}"
6604                    },
6605                    "tests": {
6606                        "test.rs": "fn test() {}"
6607                    },
6608                    "new_file.txt": "new content",
6609                    "another_new.rs": "// new file",
6610                    "conflict.txt": "conflicted content"
6611                }
6612            }),
6613        )
6614        .await;
6615
6616        fs.set_status_for_repo(
6617            Path::new(path!("/root/project/.git")),
6618            &[
6619                ("src/main.rs", StatusCode::Modified.worktree()),
6620                ("src/lib.rs", StatusCode::Modified.worktree()),
6621                ("tests/test.rs", StatusCode::Modified.worktree()),
6622                ("new_file.txt", FileStatus::Untracked),
6623                ("another_new.rs", FileStatus::Untracked),
6624                ("src/utils.rs", FileStatus::Untracked),
6625                (
6626                    "conflict.txt",
6627                    UnmergedStatus {
6628                        first_head: UnmergedStatusCode::Updated,
6629                        second_head: UnmergedStatusCode::Updated,
6630                    }
6631                    .into(),
6632                ),
6633            ],
6634        );
6635
6636        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6637        let window_handle =
6638            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6639        let workspace = window_handle
6640            .read_with(cx, |mw, _| mw.workspace().clone())
6641            .unwrap();
6642        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
6643
6644        cx.read(|cx| {
6645            project
6646                .read(cx)
6647                .worktrees(cx)
6648                .next()
6649                .unwrap()
6650                .read(cx)
6651                .as_local()
6652                .unwrap()
6653                .scan_complete()
6654        })
6655        .await;
6656
6657        cx.executor().run_until_parked();
6658
6659        let panel = workspace.update_in(cx, GitPanel::new);
6660
6661        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6662            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6663        });
6664        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6665        handle.await;
6666
6667        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6668        #[rustfmt::skip]
6669        pretty_assertions::assert_matches!(
6670            entries.as_slice(),
6671            &[
6672                Header(GitHeaderEntry { header: Section::Conflict }),
6673                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6674                Header(GitHeaderEntry { header: Section::Tracked }),
6675                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6676                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6677                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6678                Header(GitHeaderEntry { header: Section::New }),
6679                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6680                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6681                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6682            ],
6683        );
6684
6685        let second_status_entry = entries[3].clone();
6686        panel.update_in(cx, |panel, window, cx| {
6687            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6688        });
6689
6690        panel.update_in(cx, |panel, window, cx| {
6691            panel.selected_entry = Some(7);
6692            panel.stage_range(&git::StageRange, window, cx);
6693        });
6694
6695        cx.read(|cx| {
6696            project
6697                .read(cx)
6698                .worktrees(cx)
6699                .next()
6700                .unwrap()
6701                .read(cx)
6702                .as_local()
6703                .unwrap()
6704                .scan_complete()
6705        })
6706        .await;
6707
6708        cx.executor().run_until_parked();
6709
6710        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6711            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6712        });
6713        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6714        handle.await;
6715
6716        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6717        #[rustfmt::skip]
6718        pretty_assertions::assert_matches!(
6719            entries.as_slice(),
6720            &[
6721                Header(GitHeaderEntry { header: Section::Conflict }),
6722                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6723                Header(GitHeaderEntry { header: Section::Tracked }),
6724                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6725                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6726                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6727                Header(GitHeaderEntry { header: Section::New }),
6728                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6729                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6730                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6731            ],
6732        );
6733
6734        let third_status_entry = entries[4].clone();
6735        panel.update_in(cx, |panel, window, cx| {
6736            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
6737        });
6738
6739        panel.update_in(cx, |panel, window, cx| {
6740            panel.selected_entry = Some(9);
6741            panel.stage_range(&git::StageRange, window, cx);
6742        });
6743
6744        cx.read(|cx| {
6745            project
6746                .read(cx)
6747                .worktrees(cx)
6748                .next()
6749                .unwrap()
6750                .read(cx)
6751                .as_local()
6752                .unwrap()
6753                .scan_complete()
6754        })
6755        .await;
6756
6757        cx.executor().run_until_parked();
6758
6759        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6760            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6761        });
6762        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6763        handle.await;
6764
6765        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6766        #[rustfmt::skip]
6767        pretty_assertions::assert_matches!(
6768            entries.as_slice(),
6769            &[
6770                Header(GitHeaderEntry { header: Section::Conflict }),
6771                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6772                Header(GitHeaderEntry { header: Section::Tracked }),
6773                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6774                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6775                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6776                Header(GitHeaderEntry { header: Section::New }),
6777                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6778                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6779                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6780            ],
6781        );
6782    }
6783
6784    #[gpui::test]
6785    async fn test_bulk_staging_with_sort_by_paths(cx: &mut TestAppContext) {
6786        use GitListEntry::*;
6787
6788        init_test(cx);
6789        let fs = FakeFs::new(cx.background_executor.clone());
6790        fs.insert_tree(
6791            "/root",
6792            json!({
6793                "project": {
6794                    ".git": {},
6795                    "src": {
6796                        "main.rs": "fn main() {}",
6797                        "lib.rs": "pub fn hello() {}",
6798                        "utils.rs": "pub fn util() {}"
6799                    },
6800                    "tests": {
6801                        "test.rs": "fn test() {}"
6802                    },
6803                    "new_file.txt": "new content",
6804                    "another_new.rs": "// new file",
6805                    "conflict.txt": "conflicted content"
6806                }
6807            }),
6808        )
6809        .await;
6810
6811        fs.set_status_for_repo(
6812            Path::new(path!("/root/project/.git")),
6813            &[
6814                ("src/main.rs", StatusCode::Modified.worktree()),
6815                ("src/lib.rs", StatusCode::Modified.worktree()),
6816                ("tests/test.rs", StatusCode::Modified.worktree()),
6817                ("new_file.txt", FileStatus::Untracked),
6818                ("another_new.rs", FileStatus::Untracked),
6819                ("src/utils.rs", FileStatus::Untracked),
6820                (
6821                    "conflict.txt",
6822                    UnmergedStatus {
6823                        first_head: UnmergedStatusCode::Updated,
6824                        second_head: UnmergedStatusCode::Updated,
6825                    }
6826                    .into(),
6827                ),
6828            ],
6829        );
6830
6831        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6832        let window_handle =
6833            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6834        let workspace = window_handle
6835            .read_with(cx, |mw, _| mw.workspace().clone())
6836            .unwrap();
6837        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
6838
6839        cx.read(|cx| {
6840            project
6841                .read(cx)
6842                .worktrees(cx)
6843                .next()
6844                .unwrap()
6845                .read(cx)
6846                .as_local()
6847                .unwrap()
6848                .scan_complete()
6849        })
6850        .await;
6851
6852        cx.executor().run_until_parked();
6853
6854        let panel = workspace.update_in(cx, GitPanel::new);
6855
6856        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6857            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6858        });
6859        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6860        handle.await;
6861
6862        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6863        #[rustfmt::skip]
6864        pretty_assertions::assert_matches!(
6865            entries.as_slice(),
6866            &[
6867                Header(GitHeaderEntry { header: Section::Conflict }),
6868                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6869                Header(GitHeaderEntry { header: Section::Tracked }),
6870                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6871                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6872                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6873                Header(GitHeaderEntry { header: Section::New }),
6874                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6875                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6876                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6877            ],
6878        );
6879
6880        assert_entry_paths(
6881            &entries,
6882            &[
6883                None,
6884                Some("conflict.txt"),
6885                None,
6886                Some("src/lib.rs"),
6887                Some("src/main.rs"),
6888                Some("tests/test.rs"),
6889                None,
6890                Some("another_new.rs"),
6891                Some("new_file.txt"),
6892                Some("src/utils.rs"),
6893            ],
6894        );
6895
6896        let second_status_entry = entries[3].clone();
6897        panel.update_in(cx, |panel, window, cx| {
6898            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6899        });
6900
6901        cx.update(|_window, cx| {
6902            SettingsStore::update_global(cx, |store, cx| {
6903                store.update_user_settings(cx, |settings| {
6904                    settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
6905                })
6906            });
6907        });
6908
6909        panel.update_in(cx, |panel, window, cx| {
6910            panel.selected_entry = Some(7);
6911            panel.stage_range(&git::StageRange, window, cx);
6912        });
6913
6914        cx.read(|cx| {
6915            project
6916                .read(cx)
6917                .worktrees(cx)
6918                .next()
6919                .unwrap()
6920                .read(cx)
6921                .as_local()
6922                .unwrap()
6923                .scan_complete()
6924        })
6925        .await;
6926
6927        cx.executor().run_until_parked();
6928
6929        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6930            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6931        });
6932        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6933        handle.await;
6934
6935        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6936        #[rustfmt::skip]
6937        pretty_assertions::assert_matches!(
6938            entries.as_slice(),
6939            &[
6940                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6941                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
6942                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6943                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
6944                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
6945                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6946                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
6947            ],
6948        );
6949
6950        assert_entry_paths(
6951            &entries,
6952            &[
6953                Some("another_new.rs"),
6954                Some("conflict.txt"),
6955                Some("new_file.txt"),
6956                Some("src/lib.rs"),
6957                Some("src/main.rs"),
6958                Some("src/utils.rs"),
6959                Some("tests/test.rs"),
6960            ],
6961        );
6962
6963        let third_status_entry = entries[4].clone();
6964        panel.update_in(cx, |panel, window, cx| {
6965            panel.toggle_staged_for_entry(&third_status_entry, window, cx);
6966        });
6967
6968        panel.update_in(cx, |panel, window, cx| {
6969            panel.selected_entry = Some(9);
6970            panel.stage_range(&git::StageRange, window, cx);
6971        });
6972
6973        cx.read(|cx| {
6974            project
6975                .read(cx)
6976                .worktrees(cx)
6977                .next()
6978                .unwrap()
6979                .read(cx)
6980                .as_local()
6981                .unwrap()
6982                .scan_complete()
6983        })
6984        .await;
6985
6986        cx.executor().run_until_parked();
6987
6988        let handle = cx.update_window_entity(&panel, |panel, _, _| {
6989            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6990        });
6991        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6992        handle.await;
6993
6994        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6995        #[rustfmt::skip]
6996        pretty_assertions::assert_matches!(
6997            entries.as_slice(),
6998            &[
6999                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7000                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
7001                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7002                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
7003                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
7004                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
7005                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
7006            ],
7007        );
7008
7009        assert_entry_paths(
7010            &entries,
7011            &[
7012                Some("another_new.rs"),
7013                Some("conflict.txt"),
7014                Some("new_file.txt"),
7015                Some("src/lib.rs"),
7016                Some("src/main.rs"),
7017                Some("src/utils.rs"),
7018                Some("tests/test.rs"),
7019            ],
7020        );
7021    }
7022
7023    #[gpui::test]
7024    async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
7025        init_test(cx);
7026        let fs = FakeFs::new(cx.background_executor.clone());
7027        fs.insert_tree(
7028            "/root",
7029            json!({
7030                "project": {
7031                    ".git": {},
7032                    "src": {
7033                        "main.rs": "fn main() {}"
7034                    }
7035                }
7036            }),
7037        )
7038        .await;
7039
7040        fs.set_status_for_repo(
7041            Path::new(path!("/root/project/.git")),
7042            &[("src/main.rs", StatusCode::Modified.worktree())],
7043        );
7044
7045        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
7046        let window_handle =
7047            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7048        let workspace = window_handle
7049            .read_with(cx, |mw, _| mw.workspace().clone())
7050            .unwrap();
7051        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7052
7053        let panel = workspace.update_in(cx, GitPanel::new);
7054
7055        // Test: User has commit message, enables amend (saves message), then disables (restores message)
7056        panel.update(cx, |panel, cx| {
7057            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7058                let start = buffer.anchor_before(0);
7059                let end = buffer.anchor_after(buffer.len());
7060                buffer.edit([(start..end, "Initial commit message")], None, cx);
7061            });
7062
7063            panel.set_amend_pending(true, cx);
7064            assert!(panel.original_commit_message.is_some());
7065
7066            panel.set_amend_pending(false, cx);
7067            let current_message = panel.commit_message_buffer(cx).read(cx).text();
7068            assert_eq!(current_message, "Initial commit message");
7069            assert!(panel.original_commit_message.is_none());
7070        });
7071
7072        // Test: User has empty commit message, enables amend, then disables (clears message)
7073        panel.update(cx, |panel, cx| {
7074            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7075                let start = buffer.anchor_before(0);
7076                let end = buffer.anchor_after(buffer.len());
7077                buffer.edit([(start..end, "")], None, cx);
7078            });
7079
7080            panel.set_amend_pending(true, cx);
7081            assert!(panel.original_commit_message.is_none());
7082
7083            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7084                let start = buffer.anchor_before(0);
7085                let end = buffer.anchor_after(buffer.len());
7086                buffer.edit([(start..end, "Previous commit message")], None, cx);
7087            });
7088
7089            panel.set_amend_pending(false, cx);
7090            let current_message = panel.commit_message_buffer(cx).read(cx).text();
7091            assert_eq!(current_message, "");
7092        });
7093    }
7094
7095    #[gpui::test]
7096    async fn test_amend(cx: &mut TestAppContext) {
7097        init_test(cx);
7098        let fs = FakeFs::new(cx.background_executor.clone());
7099        fs.insert_tree(
7100            "/root",
7101            json!({
7102                "project": {
7103                    ".git": {},
7104                    "src": {
7105                        "main.rs": "fn main() {}"
7106                    }
7107                }
7108            }),
7109        )
7110        .await;
7111
7112        fs.set_status_for_repo(
7113            Path::new(path!("/root/project/.git")),
7114            &[("src/main.rs", StatusCode::Modified.worktree())],
7115        );
7116
7117        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
7118        let window_handle =
7119            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7120        let workspace = window_handle
7121            .read_with(cx, |mw, _| mw.workspace().clone())
7122            .unwrap();
7123        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7124
7125        // Wait for the project scanning to finish so that `head_commit(cx)` is
7126        // actually set, otherwise no head commit would be available from which
7127        // to fetch the latest commit message from.
7128        cx.executor().run_until_parked();
7129
7130        let panel = workspace.update_in(cx, GitPanel::new);
7131        panel.read_with(cx, |panel, cx| {
7132            assert!(panel.active_repository.is_some());
7133            assert!(panel.head_commit(cx).is_some());
7134        });
7135
7136        panel.update_in(cx, |panel, window, cx| {
7137            // Update the commit editor's message to ensure that its contents
7138            // are later restored, after amending is finished.
7139            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
7140                buffer.set_text("refactor: update main.rs", cx);
7141            });
7142
7143            // Start amending the previous commit.
7144            panel.focus_editor(&Default::default(), window, cx);
7145            panel.on_amend(&Amend, window, cx);
7146        });
7147
7148        // Since `GitPanel.amend` attempts to fetch the latest commit message in
7149        // a background task, we need to wait for it to complete before being
7150        // able to assert that the commit message editor's state has been
7151        // updated.
7152        cx.run_until_parked();
7153
7154        panel.update_in(cx, |panel, window, cx| {
7155            assert_eq!(
7156                panel.commit_message_buffer(cx).read(cx).text(),
7157                "initial commit"
7158            );
7159            assert_eq!(
7160                panel.original_commit_message,
7161                Some("refactor: update main.rs".to_string())
7162            );
7163
7164            // Finish amending the previous commit.
7165            panel.focus_editor(&Default::default(), window, cx);
7166            panel.on_amend(&Amend, window, cx);
7167        });
7168
7169        // Since the actual commit logic is run in a background task, we need to
7170        // await its completion to actually ensure that the commit message
7171        // editor's contents are set to the original message and haven't been
7172        // cleared.
7173        cx.run_until_parked();
7174
7175        panel.update_in(cx, |panel, _window, cx| {
7176            // After amending, the commit editor's message should be restored to
7177            // the original message.
7178            assert_eq!(
7179                panel.commit_message_buffer(cx).read(cx).text(),
7180                "refactor: update main.rs"
7181            );
7182            assert!(panel.original_commit_message.is_none());
7183        });
7184    }
7185
7186    #[gpui::test]
7187    async fn test_open_diff(cx: &mut TestAppContext) {
7188        init_test(cx);
7189
7190        let fs = FakeFs::new(cx.background_executor.clone());
7191        fs.insert_tree(
7192            path!("/project"),
7193            json!({
7194                ".git": {},
7195                "tracked": "tracked\n",
7196                "untracked": "\n",
7197            }),
7198        )
7199        .await;
7200
7201        fs.set_head_and_index_for_repo(
7202            path!("/project/.git").as_ref(),
7203            &[("tracked", "old tracked\n".into())],
7204        );
7205
7206        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7207        let window_handle =
7208            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7209        let workspace = window_handle
7210            .read_with(cx, |mw, _| mw.workspace().clone())
7211            .unwrap();
7212        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7213        let panel = workspace.update_in(cx, GitPanel::new);
7214
7215        // Enable the `sort_by_path` setting and wait for entries to be updated,
7216        // as there should no longer be separators between Tracked and Untracked
7217        // files.
7218        cx.update(|_window, cx| {
7219            SettingsStore::update_global(cx, |store, cx| {
7220                store.update_user_settings(cx, |settings| {
7221                    settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
7222                })
7223            });
7224        });
7225
7226        cx.update_window_entity(&panel, |panel, _, _| {
7227            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7228        })
7229        .await;
7230
7231        // Confirm that `Open Diff` still works for the untracked file, updating
7232        // the Project Diff's active path.
7233        panel.update_in(cx, |panel, window, cx| {
7234            panel.selected_entry = Some(1);
7235            panel.open_diff(&menu::Confirm, window, cx);
7236        });
7237        cx.run_until_parked();
7238
7239        workspace.update_in(cx, |workspace, _window, cx| {
7240            let active_path = workspace
7241                .item_of_type::<ProjectDiff>(cx)
7242                .expect("ProjectDiff should exist")
7243                .read(cx)
7244                .active_path(cx)
7245                .expect("active_path should exist");
7246
7247            assert_eq!(active_path.path, rel_path("untracked").into_arc());
7248        });
7249    }
7250
7251    #[gpui::test]
7252    async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
7253        cx: &mut TestAppContext,
7254    ) {
7255        init_test(cx);
7256
7257        let fs = FakeFs::new(cx.background_executor.clone());
7258        fs.insert_tree(
7259            path!("/project"),
7260            json!({
7261                ".git": {},
7262                "src": {
7263                    "a": {
7264                        "foo.rs": "fn foo() {}",
7265                    },
7266                    "b": {
7267                        "bar.rs": "fn bar() {}",
7268                    },
7269                },
7270            }),
7271        )
7272        .await;
7273
7274        fs.set_status_for_repo(
7275            path!("/project/.git").as_ref(),
7276            &[
7277                ("src/a/foo.rs", StatusCode::Modified.worktree()),
7278                ("src/b/bar.rs", StatusCode::Modified.worktree()),
7279            ],
7280        );
7281
7282        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7283        let window_handle =
7284            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7285        let workspace = window_handle
7286            .read_with(cx, |mw, _| mw.workspace().clone())
7287            .unwrap();
7288        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7289
7290        cx.read(|cx| {
7291            project
7292                .read(cx)
7293                .worktrees(cx)
7294                .next()
7295                .unwrap()
7296                .read(cx)
7297                .as_local()
7298                .unwrap()
7299                .scan_complete()
7300        })
7301        .await;
7302
7303        cx.executor().run_until_parked();
7304
7305        cx.update(|_window, cx| {
7306            SettingsStore::update_global(cx, |store, cx| {
7307                store.update_user_settings(cx, |settings| {
7308                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
7309                })
7310            });
7311        });
7312
7313        let panel = workspace.update_in(cx, GitPanel::new);
7314
7315        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7316            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7317        });
7318        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7319        handle.await;
7320
7321        let src_key = panel.read_with(cx, |panel, _| {
7322            panel
7323                .entries
7324                .iter()
7325                .find_map(|entry| match entry {
7326                    GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
7327                        Some(dir.key.clone())
7328                    }
7329                    _ => None,
7330                })
7331                .expect("src directory should exist in tree view")
7332        });
7333
7334        panel.update_in(cx, |panel, window, cx| {
7335            panel.toggle_directory(&src_key, window, cx);
7336        });
7337
7338        panel.read_with(cx, |panel, _| {
7339            let state = panel
7340                .view_mode
7341                .tree_state()
7342                .expect("tree view state should exist");
7343            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
7344        });
7345
7346        let worktree_id =
7347            cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7348        let project_path = ProjectPath {
7349            worktree_id,
7350            path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(),
7351        };
7352
7353        panel.update_in(cx, |panel, window, cx| {
7354            panel.select_entry_by_path(project_path, window, cx);
7355        });
7356
7357        panel.read_with(cx, |panel, _| {
7358            let state = panel
7359                .view_mode
7360                .tree_state()
7361                .expect("tree view state should exist");
7362            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
7363
7364            let selected_ix = panel.selected_entry.expect("selection should be set");
7365            assert!(state.logical_indices.contains(&selected_ix));
7366
7367            let selected_entry = panel
7368                .entries
7369                .get(selected_ix)
7370                .and_then(|entry| entry.status_entry())
7371                .expect("selected entry should be a status entry");
7372            assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
7373        });
7374    }
7375
7376    #[gpui::test]
7377    async fn test_tree_view_select_next_at_last_visible_collapsed_directory(
7378        cx: &mut TestAppContext,
7379    ) {
7380        init_test(cx);
7381
7382        let fs = FakeFs::new(cx.background_executor.clone());
7383        fs.insert_tree(
7384            path!("/project"),
7385            json!({
7386                ".git": {},
7387                "bar": {
7388                    "bar1.py": "print('bar1')",
7389                    "bar2.py": "print('bar2')",
7390                },
7391                "foo": {
7392                    "foo1.py": "print('foo1')",
7393                    "foo2.py": "print('foo2')",
7394                },
7395                "foobar.py": "print('foobar')",
7396            }),
7397        )
7398        .await;
7399
7400        fs.set_status_for_repo(
7401            path!("/project/.git").as_ref(),
7402            &[
7403                ("bar/bar1.py", StatusCode::Modified.worktree()),
7404                ("bar/bar2.py", StatusCode::Modified.worktree()),
7405                ("foo/foo1.py", StatusCode::Modified.worktree()),
7406                ("foo/foo2.py", StatusCode::Modified.worktree()),
7407                ("foobar.py", FileStatus::Untracked),
7408            ],
7409        );
7410
7411        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7412        let window_handle =
7413            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7414        let workspace = window_handle
7415            .read_with(cx, |mw, _| mw.workspace().clone())
7416            .unwrap();
7417        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7418
7419        cx.read(|cx| {
7420            project
7421                .read(cx)
7422                .worktrees(cx)
7423                .next()
7424                .unwrap()
7425                .read(cx)
7426                .as_local()
7427                .unwrap()
7428                .scan_complete()
7429        })
7430        .await;
7431
7432        cx.executor().run_until_parked();
7433        cx.update(|_window, cx| {
7434            SettingsStore::update_global(cx, |store, cx| {
7435                store.update_user_settings(cx, |settings| {
7436                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
7437                })
7438            });
7439        });
7440
7441        let panel = workspace.update_in(cx, GitPanel::new);
7442        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7443            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7444        });
7445
7446        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7447        handle.await;
7448
7449        let foo_key = panel.read_with(cx, |panel, _| {
7450            panel
7451                .entries
7452                .iter()
7453                .find_map(|entry| match entry {
7454                    GitListEntry::Directory(dir) if dir.key.path == repo_path("foo") => {
7455                        Some(dir.key.clone())
7456                    }
7457                    _ => None,
7458                })
7459                .expect("foo directory should exist in tree view")
7460        });
7461
7462        panel.update_in(cx, |panel, window, cx| {
7463            panel.toggle_directory(&foo_key, window, cx);
7464        });
7465
7466        let foo_idx = panel.read_with(cx, |panel, _| {
7467            let state = panel
7468                .view_mode
7469                .tree_state()
7470                .expect("tree view state should exist");
7471            assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false));
7472
7473            let foo_idx = panel
7474                .entries
7475                .iter()
7476                .enumerate()
7477                .find_map(|(index, entry)| match entry {
7478                    GitListEntry::Directory(dir) if dir.key.path == repo_path("foo") => Some(index),
7479                    _ => None,
7480                })
7481                .expect("foo directory should exist in tree view");
7482
7483            let foo_logical_idx = state
7484                .logical_indices
7485                .iter()
7486                .position(|&index| index == foo_idx)
7487                .expect("foo directory should be visible");
7488            let next_logical_idx = state.logical_indices[foo_logical_idx + 1];
7489            assert!(matches!(
7490                panel.entries.get(next_logical_idx),
7491                Some(GitListEntry::Header(GitHeaderEntry {
7492                    header: Section::New
7493                }))
7494            ));
7495
7496            foo_idx
7497        });
7498
7499        panel.update_in(cx, |panel, window, cx| {
7500            panel.selected_entry = Some(foo_idx);
7501            panel.select_next(&menu::SelectNext, window, cx);
7502        });
7503
7504        panel.read_with(cx, |panel, _| {
7505            let selected_idx = panel.selected_entry.expect("selection should be set");
7506            let selected_entry = panel
7507                .entries
7508                .get(selected_idx)
7509                .and_then(|entry| entry.status_entry())
7510                .expect("selected entry should be a status entry");
7511            assert_eq!(selected_entry.repo_path, repo_path("foobar.py"));
7512        });
7513    }
7514
7515    fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
7516        assert_eq!(entries.len(), expected_paths.len());
7517        for (entry, expected_path) in entries.iter().zip(expected_paths) {
7518            assert_eq!(
7519                entry.status_entry().map(|status| status
7520                    .repo_path
7521                    .as_ref()
7522                    .as_std_path()
7523                    .to_string_lossy()
7524                    .to_string()),
7525                expected_path.map(|s| s.to_string())
7526            );
7527        }
7528    }
7529
7530    #[test]
7531    fn test_compress_diff_no_truncation() {
7532        let diff = indoc! {"
7533            --- a/file.txt
7534            +++ b/file.txt
7535            @@ -1,2 +1,2 @@
7536            -old
7537            +new
7538        "};
7539        let result = GitPanel::compress_commit_diff(diff, 1000);
7540        assert_eq!(result, diff);
7541    }
7542
7543    #[test]
7544    fn test_compress_diff_truncate_long_lines() {
7545        let long_line = "🦀".repeat(300);
7546        let diff = indoc::formatdoc! {"
7547            --- a/file.txt
7548            +++ b/file.txt
7549            @@ -1,2 +1,3 @@
7550             context
7551            +{}
7552             more context
7553        ", long_line};
7554        let result = GitPanel::compress_commit_diff(&diff, 100);
7555        assert!(result.contains("...[truncated]"));
7556        assert!(result.len() < diff.len());
7557    }
7558
7559    #[test]
7560    fn test_compress_diff_truncate_hunks() {
7561        let diff = indoc! {"
7562            --- a/file.txt
7563            +++ b/file.txt
7564            @@ -1,2 +1,2 @@
7565             context
7566            -old1
7567            +new1
7568            @@ -5,2 +5,2 @@
7569             context 2
7570            -old2
7571            +new2
7572            @@ -10,2 +10,2 @@
7573             context 3
7574            -old3
7575            +new3
7576        "};
7577        let result = GitPanel::compress_commit_diff(diff, 100);
7578        let expected = indoc! {"
7579            --- a/file.txt
7580            +++ b/file.txt
7581            @@ -1,2 +1,2 @@
7582             context
7583            -old1
7584            +new1
7585            [...skipped 2 hunks...]
7586        "};
7587        assert_eq!(result, expected);
7588    }
7589
7590    #[gpui::test]
7591    async fn test_suggest_commit_message(cx: &mut TestAppContext) {
7592        init_test(cx);
7593
7594        let fs = FakeFs::new(cx.background_executor.clone());
7595        fs.insert_tree(
7596            path!("/project"),
7597            json!({
7598                ".git": {},
7599                "tracked": "tracked\n",
7600                "untracked": "\n",
7601            }),
7602        )
7603        .await;
7604
7605        fs.set_head_and_index_for_repo(
7606            path!("/project/.git").as_ref(),
7607            &[("tracked", "old tracked\n".into())],
7608        );
7609
7610        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7611        let window_handle =
7612            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
7613        let workspace = window_handle
7614            .read_with(cx, |mw, _| mw.workspace().clone())
7615            .unwrap();
7616        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
7617        let panel = workspace.update_in(cx, GitPanel::new);
7618
7619        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7620            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7621        });
7622        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7623        handle.await;
7624
7625        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
7626
7627        // GitPanel
7628        // - Tracked:
7629        // - [] tracked
7630        // - Untracked
7631        // - [] untracked
7632        //
7633        // The commit message should now read:
7634        // "Update tracked"
7635        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7636        assert_eq!(message, Some("Update tracked".to_string()));
7637
7638        let first_status_entry = entries[1].clone();
7639        panel.update_in(cx, |panel, window, cx| {
7640            panel.toggle_staged_for_entry(&first_status_entry, window, cx);
7641        });
7642
7643        cx.read(|cx| {
7644            project
7645                .read(cx)
7646                .worktrees(cx)
7647                .next()
7648                .unwrap()
7649                .read(cx)
7650                .as_local()
7651                .unwrap()
7652                .scan_complete()
7653        })
7654        .await;
7655
7656        cx.executor().run_until_parked();
7657
7658        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7659            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7660        });
7661        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7662        handle.await;
7663
7664        // GitPanel
7665        // - Tracked:
7666        // - [x] tracked
7667        // - Untracked
7668        // - [] untracked
7669        //
7670        // The commit message should still read:
7671        // "Update tracked"
7672        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7673        assert_eq!(message, Some("Update tracked".to_string()));
7674
7675        let second_status_entry = entries[3].clone();
7676        panel.update_in(cx, |panel, window, cx| {
7677            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
7678        });
7679
7680        cx.read(|cx| {
7681            project
7682                .read(cx)
7683                .worktrees(cx)
7684                .next()
7685                .unwrap()
7686                .read(cx)
7687                .as_local()
7688                .unwrap()
7689                .scan_complete()
7690        })
7691        .await;
7692
7693        cx.executor().run_until_parked();
7694
7695        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7696            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7697        });
7698        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7699        handle.await;
7700
7701        // GitPanel
7702        // - Tracked:
7703        // - [x] tracked
7704        // - Untracked
7705        // - [x] untracked
7706        //
7707        // The commit message should now read:
7708        // "Enter commit message"
7709        // (which means we should see None returned).
7710        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7711        assert!(message.is_none());
7712
7713        panel.update_in(cx, |panel, window, cx| {
7714            panel.toggle_staged_for_entry(&first_status_entry, window, cx);
7715        });
7716
7717        cx.read(|cx| {
7718            project
7719                .read(cx)
7720                .worktrees(cx)
7721                .next()
7722                .unwrap()
7723                .read(cx)
7724                .as_local()
7725                .unwrap()
7726                .scan_complete()
7727        })
7728        .await;
7729
7730        cx.executor().run_until_parked();
7731
7732        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7733            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7734        });
7735        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7736        handle.await;
7737
7738        // GitPanel
7739        // - Tracked:
7740        // - [] tracked
7741        // - Untracked
7742        // - [x] untracked
7743        //
7744        // The commit message should now read:
7745        // "Update untracked"
7746        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7747        assert_eq!(message, Some("Create untracked".to_string()));
7748
7749        panel.update_in(cx, |panel, window, cx| {
7750            panel.toggle_staged_for_entry(&second_status_entry, window, cx);
7751        });
7752
7753        cx.read(|cx| {
7754            project
7755                .read(cx)
7756                .worktrees(cx)
7757                .next()
7758                .unwrap()
7759                .read(cx)
7760                .as_local()
7761                .unwrap()
7762                .scan_complete()
7763        })
7764        .await;
7765
7766        cx.executor().run_until_parked();
7767
7768        let handle = cx.update_window_entity(&panel, |panel, _, _| {
7769            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7770        });
7771        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7772        handle.await;
7773
7774        // GitPanel
7775        // - Tracked:
7776        // - [] tracked
7777        // - Untracked
7778        // - [] untracked
7779        //
7780        // The commit message should now read:
7781        // "Update tracked"
7782        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7783        assert_eq!(message, Some("Update tracked".to_string()));
7784    }
7785}