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