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