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