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