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