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