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