git_panel.rs

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