git_panel.rs

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