Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:29:04.028Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

git_panel.rs

12559 lines · 473.4 KB · rust
1use crate::askpass_modal::AskPassModal;
2use crate::commit_context_menu::{
3    CommitContextMenuData, CommitContextMenuSource, commit_context_menu,
4};
5use crate::commit_modal::CommitModal;
6use crate::commit_tooltip::{CommitAvatar, CommitTooltip};
7use crate::commit_view::CommitView;
8use crate::git_panel_settings::GitPanelScrollbarAccessor;
9use crate::project_diff::{DeployBranchDiff, Diff, ProjectDiff};
10use crate::remote_output::{self, RemoteAction, SuccessMessage};
11use crate::solo_diff_view::SoloDiffView;
12use crate::staged_diff::StagedDiff;
13use crate::unstaged_diff::UnstagedDiff;
14use crate::{branch_picker, picker_prompt, render_remote_button};
15use crate::{
16    git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
17};
18use agent_settings::{AgentSettings, UserAgentsMd};
19use anyhow::Context as _;
20use askpass::AskPassDelegate;
21use client::zed_urls;
22use collections::{BTreeMap, HashMap, HashSet};
23use db::kvp::KeyValueStore;
24use editor::{Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset, SizingBehavior};
25use editor::{EditorStyle, RewrapOptions};
26use file_icons::FileIcons;
27use futures::StreamExt as _;
28use futures::channel::oneshot::Canceled;
29use git::Oid;
30use git::commit::ParsedCommitMessage;
31use git::repository::{
32    Branch, CommitData, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions,
33    GitCommitTemplate, GitCommitter, InitialGraphCommitData, LogOrder, LogSource, PushOptions,
34    Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
35    get_git_committer,
36};
37use git::stash::GitStash;
38use git::status::{DiffStat, StageStatus};
39use git::{
40    Amend, Commit, Signoff, SkipHooks, ToggleStaged, repository::RepoPath, status::FileStatus,
41};
42use git::{
43    ExpandCommitEditor, GitHostingProviderRegistry, GitRemote, RestoreTrackedFiles, StageAll,
44    StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll,
45    ViewFile, parse_git_remote_url,
46};
47use gpui::{
48    AbsoluteLength, Action, Anchor, AnyElement, AsyncApp, AsyncWindowContext, ClickEvent,
49    DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton,
50    MouseDownEvent, Pixels, Point, PromptLevel, ScrollStrategy, Subscription, Task, TaskExt,
51    TextStyle, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, uniform_list,
52};
53use itertools::Itertools;
54use language::{Buffer, BufferEvent, File};
55use language_model::{
56    CompletionIntent, ConfiguredModel, Event as LanguageModelEvent, LanguageModelRegistry,
57    LanguageModelRequest, LanguageModelRequestMessage, Role,
58};
59use menu;
60use multi_buffer::ExcerptBoundaryInfo;
61use notifications::status_toast::StatusToast;
62use panel::PanelHeader;
63use project::git_store::GitAccess;
64use project::{
65    Fs, Project, ProjectPath,
66    git_store::{
67        CommitDataState, GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op,
68    },
69    project_settings::{GitPathStyle, ProjectSettings},
70};
71use prompt_store::RULES_FILE_NAMES;
72use proto::RpcError;
73use serde::{Deserialize, Serialize};
74use settings::{
75    GitPanelClickBehavior, GitPanelGroupBy, GitPanelSortBy, Settings, SettingsStore, StatusStyle,
76    update_settings_file,
77};
78use smallvec::SmallVec;
79use std::cell::Cell;
80use std::future::Future;
81use std::ops::Range;
82use std::path::Path;
83use std::rc::Rc;
84use std::{sync::Arc, time::Duration, usize};
85use strum::{IntoEnumIterator, VariantNames};
86use theme_settings::ThemeSettings;
87use time::OffsetDateTime;
88use ui::{
89    ButtonLike, Checkbox, Chip, ContextMenu, ContextMenuEntry, Divider, DocumentationSide,
90    ElevationIndex, IndentGuideColors, KeyBinding, PopoverMenu, PopoverMenuHandle,
91    ProjectEmptyState, ScrollAxes, Scrollbars, SplitButton, Tab, TintColor, Tooltip, WithScrollbar,
92    prelude::*,
93};
94use util::paths::PathStyle;
95use util::{ResultExt, TryFutureExt, markdown::MarkdownInlineCode, maybe, rel_path::RelPath};
96use workspace::SERIALIZATION_THROTTLE_TIME;
97use workspace::{
98    Item, Workspace,
99    dock::{DockPosition, Panel, PanelEvent},
100    notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
101};
102use zed_actions::{
103    DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, git_panel::ToggleFocus,
104};
105
106const GIT_PANEL_KEY: &str = "GitPanel";
107const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
108// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
109const TREE_INDENT: f32 = 16.0;
110const MAX_HISTORY_TAG_CHIPS: usize = 3;
111// Horizontal offset that aligns the tree indent guides with the row icon column.
112const INDENT_GUIDE_LEFT_OFFSET: gpui::Pixels = gpui::px(19.);
113
114actions!(
115    git_panel,
116    [
117        /// Closes the git panel.
118        Close,
119        /// Toggles the git panel.
120        Toggle,
121        /// Opens the git panel menu.
122        OpenMenu,
123        /// Focuses on the commit message editor.
124        FocusEditor,
125        /// Focuses on the changes list.
126        FocusChanges,
127        /// Select next git panel menu item, and show it in the diff view
128        NextEntry,
129        /// Select previous git panel menu item, and show it in the diff view
130        PreviousEntry,
131        /// Select first git panel menu item, and show it in the diff view
132        FirstEntry,
133        /// Select last git panel menu item, and show it in the diff view
134        LastEntry,
135        /// Toggles automatic co-author suggestions.
136        ToggleFillCoAuthors,
137        /// Sorts entries by path.
138        SetSortByPath,
139        /// Sorts entries by name.
140        SetSortByName,
141        /// Disables grouping entries by status.
142        SetGroupByNone,
143        /// Groups entries by status.
144        SetGroupByStatus,
145        /// Groups entries by staging state.
146        SetGroupByStaging,
147        /// Toggles showing entries in tree vs flat view.
148        ToggleTreeView,
149        /// Expands the selected entry to show its children.
150        ExpandSelectedEntry,
151        /// Collapses the selected entry to hide its children.
152        CollapseSelectedEntry,
153        /// View unstaged changes
154        ViewUnstagedChanges,
155        /// View staged changes
156        ViewStagedChanges,
157        /// Activates the Changes tab.
158        ActivateChangesTab,
159        /// Activates the History tab.
160        ActivateHistoryTab,
161    ]
162);
163
164actions!(
165    dev,
166    [
167        /// Shows the current git job queue debug state for the active repository.
168        ShowGitJobQueue,
169    ]
170);
171
172fn prompt<T>(
173    msg: &str,
174    detail: Option<&str>,
175    window: &mut Window,
176    cx: &mut App,
177) -> Task<anyhow::Result<T>>
178where
179    T: IntoEnumIterator + VariantNames + 'static,
180{
181    let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx);
182    cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
183}
184
185#[derive(strum::EnumIter, strum::VariantNames)]
186#[strum(serialize_all = "title_case")]
187enum TrashCancel {
188    Trash,
189    Cancel,
190}
191
192#[derive(Clone, Copy)]
193struct GitPanelViewOptionsMenuState {
194    sort_by: GitPanelSortBy,
195    group_by: GitPanelGroupBy,
196    tree_view: bool,
197}
198
199fn git_panel_context_menu(
200    has_tracked_changes: bool,
201    has_staged_changes: bool,
202    has_unstaged_changes: bool,
203    has_new_changes: bool,
204    has_stash_items: bool,
205    focus_handle: FocusHandle,
206    window: &mut Window,
207    cx: &mut App,
208) -> Entity<ContextMenu> {
209    ContextMenu::build(window, cx, |context_menu, _, _| {
210        context_menu
211            .context(focus_handle.clone())
212            .action_disabled_when(!has_unstaged_changes, "Stage All", StageAll.boxed_clone())
213            .action_disabled_when(!has_staged_changes, "Unstage All", UnstageAll.boxed_clone())
214            .action_disabled_when(
215                !has_tracked_changes,
216                "Restore All Changes",
217                RestoreTrackedFiles.boxed_clone(),
218            )
219            .separator()
220            .action_disabled_when(
221                !(has_new_changes || has_tracked_changes),
222                "Stash All",
223                StashAll.boxed_clone(),
224            )
225            .action_disabled_when(!has_stash_items, "Stash Pop", StashPop.boxed_clone())
226            .action("View Stash", zed_actions::git::ViewStash.boxed_clone())
227            .separator()
228            .action_disabled_when(
229                !has_tracked_changes,
230                "Discard Tracked Changes",
231                RestoreTrackedFiles.boxed_clone(),
232            )
233            .action_disabled_when(
234                !has_new_changes,
235                "Trash Untracked Files",
236                TrashUntrackedFiles.boxed_clone(),
237            )
238    })
239}
240
241fn git_panel_view_options_menu(
242    focus_handle: FocusHandle,
243    window: &mut Window,
244    cx: &mut App,
245) -> Entity<ContextMenu> {
246    let view_options_menu_state = Rc::new(Cell::new(GitPanelViewOptionsMenuState {
247        sort_by: GitPanelSettings::get_global(cx).sort_by,
248        group_by: GitPanelSettings::get_global(cx).group_by,
249        tree_view: GitPanelSettings::get_global(cx).tree_view,
250    }));
251
252    ContextMenu::build_persistent(window, cx, move |context_menu, _, _| {
253        let state = view_options_menu_state.get();
254
255        context_menu
256            .context(focus_handle.clone())
257            .header("View")
258            .item({
259                let view_options_menu_state = view_options_menu_state.clone();
260                ContextMenuEntry::new("List")
261                    .toggle(IconPosition::End, !state.tree_view)
262                    .handler(move |window, cx| {
263                        if state.tree_view {
264                            view_options_menu_state.set(GitPanelViewOptionsMenuState {
265                                tree_view: false,
266                                ..state
267                            });
268                            window.dispatch_action(Box::new(ToggleTreeView), cx);
269                        }
270                    })
271            })
272            .item({
273                let view_options_menu_state = view_options_menu_state.clone();
274                ContextMenuEntry::new("Tree")
275                    .toggle(IconPosition::End, state.tree_view)
276                    .handler(move |window, cx| {
277                        if !state.tree_view {
278                            view_options_menu_state.set(GitPanelViewOptionsMenuState {
279                                tree_view: true,
280                                ..state
281                            });
282                            window.dispatch_action(Box::new(ToggleTreeView), cx);
283                        }
284                    })
285            })
286            .when(!state.tree_view, |this| {
287                this.separator()
288                    .header("Sort By")
289                    .item({
290                        let view_options_menu_state = view_options_menu_state.clone();
291                        ContextMenuEntry::new("Path")
292                            .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Path)
293                            .handler(move |window, cx| {
294                                if !state.tree_view {
295                                    view_options_menu_state.set(GitPanelViewOptionsMenuState {
296                                        sort_by: GitPanelSortBy::Path,
297                                        ..state
298                                    });
299                                    window.dispatch_action(Box::new(SetSortByPath), cx);
300                                }
301                            })
302                    })
303                    .item({
304                        let view_options_menu_state = view_options_menu_state.clone();
305                        ContextMenuEntry::new("Name")
306                            .toggle(IconPosition::End, state.sort_by == GitPanelSortBy::Name)
307                            .handler(move |window, cx| {
308                                if !state.tree_view {
309                                    view_options_menu_state.set(GitPanelViewOptionsMenuState {
310                                        sort_by: GitPanelSortBy::Name,
311                                        ..state
312                                    });
313                                    window.dispatch_action(Box::new(SetSortByName), cx);
314                                }
315                            })
316                    })
317            })
318            .separator()
319            .header("Group By")
320            .item({
321                let view_options_menu_state = view_options_menu_state.clone();
322                ContextMenuEntry::new("None")
323                    .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::None)
324                    .handler(move |window, cx| {
325                        if state.group_by != GitPanelGroupBy::None {
326                            view_options_menu_state.set(GitPanelViewOptionsMenuState {
327                                group_by: GitPanelGroupBy::None,
328                                ..state
329                            });
330                            window.dispatch_action(Box::new(SetGroupByNone), cx);
331                        }
332                    })
333            })
334            .item({
335                let view_options_menu_state = view_options_menu_state.clone();
336                ContextMenuEntry::new("Tracked & Untracked")
337                    .toggle(IconPosition::End, state.group_by == GitPanelGroupBy::Status)
338                    .handler(move |window, cx| {
339                        if state.group_by != GitPanelGroupBy::Status {
340                            view_options_menu_state.set(GitPanelViewOptionsMenuState {
341                                group_by: GitPanelGroupBy::Status,
342                                ..state
343                            });
344                            window.dispatch_action(Box::new(SetGroupByStatus), cx);
345                        }
346                    })
347            })
348            .item({
349                let view_options_menu_state = view_options_menu_state.clone();
350                ContextMenuEntry::new("Staged & Unstaged")
351                    .toggle(
352                        IconPosition::End,
353                        state.group_by == GitPanelGroupBy::Staging,
354                    )
355                    .handler(move |window, cx| {
356                        if state.group_by != GitPanelGroupBy::Staging {
357                            view_options_menu_state.set(GitPanelViewOptionsMenuState {
358                                group_by: GitPanelGroupBy::Staging,
359                                ..state
360                            });
361                            window.dispatch_action(Box::new(SetGroupByStaging), cx);
362                        }
363                    })
364            })
365    })
366}
367
368// We only allow a single remote operation at a time to avoid concurrent
369// credential prompts and competing ref/working-tree updates.
370#[derive(Clone, Copy)]
371pub(crate) enum RemoteOperationKind {
372    Fetch,
373    Pull,
374    Push,
375}
376
377pub fn register(workspace: &mut Workspace) {
378    workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
379        workspace.toggle_panel_focus::<GitPanel>(window, cx);
380    });
381    workspace.register_action(|workspace, _: &Toggle, window, cx| {
382        if !workspace.toggle_panel_focus::<GitPanel>(window, cx) {
383            workspace.close_panel::<GitPanel>(window, cx);
384        }
385    });
386    workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
387        CommitModal::toggle(workspace, None, window, cx)
388    });
389    workspace.register_action(|workspace, _: &ToggleFillCommitEditor, window, cx| {
390        if let Some(panel) = workspace.panel::<GitPanel>(cx) {
391            panel.update(cx, |panel, cx| {
392                panel.toggle_fill_commit_editor(&Default::default(), window, cx)
393            });
394        }
395    });
396    workspace.register_action(|workspace, _: &git::Init, window, cx| {
397        if let Some(panel) = workspace.panel::<GitPanel>(cx) {
398            panel.update(cx, |panel, cx| panel.git_init(window, cx));
399        }
400    });
401    workspace.register_action(|workspace, _: &ShowGitJobQueue, window, cx| {
402        if let Some(panel) = workspace.panel::<GitPanel>(cx) {
403            panel.update(cx, |panel, cx| {
404                panel.show_git_job_queue(window, cx);
405            });
406        }
407    });
408}
409
410#[derive(Debug, Clone)]
411pub enum Event {
412    Focus,
413}
414
415#[derive(Default, Serialize, Deserialize)]
416struct SerializedGitPanel {
417    #[serde(default)]
418    signoff_enabled: bool,
419    #[serde(default)]
420    commit_messages: BTreeMap<String, SerializedCommitMessage>,
421}
422
423#[derive(Clone, Default, Serialize, Deserialize)]
424struct SerializedCommitMessage {
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    message: Option<String>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    original_message: Option<String>,
429    #[serde(default)]
430    amend_pending: bool,
431}
432
433#[derive(Debug, PartialEq, Eq, Clone, Copy)]
434enum GitPanelTab {
435    Changes,
436    History,
437}
438
439#[derive(Debug, PartialEq, Eq, Clone)]
440enum CommitHistory {
441    Loading,
442    /// A non-empty list can still grow on later fetches.
443    /// An empty list means the repository has no commits.
444    Loaded(Rc<[CommitHistoryEntry]>),
445    Error(SharedString),
446}
447
448fn commit_history_from_response(
449    entries: Rc<[CommitHistoryEntry]>,
450    is_loading: bool,
451    error: Option<SharedString>,
452) -> CommitHistory {
453    if !entries.is_empty() {
454        CommitHistory::Loaded(entries)
455    } else if let Some(error) = error {
456        CommitHistory::Error(error)
457    } else if is_loading {
458        CommitHistory::Loading
459    } else {
460        CommitHistory::Loaded(Rc::from([]))
461    }
462}
463
464#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
465enum Section {
466    Conflict,
467    Tracked,
468    New,
469    Staged,
470    Unstaged,
471}
472
473#[derive(Debug, PartialEq, Eq, Clone)]
474struct GitHeaderEntry {
475    header: Section,
476}
477
478#[derive(Debug, PartialEq, Eq, Clone, Copy)]
479struct ProjectedChangeEntry {
480    section: Section,
481    index: usize,
482}
483
484/// What clicking a staging control should do.
485///
486/// In the "staged & unstaged" grouping, a partially staged file appears in both the
487/// "Staged" and "Unstaged" sections at once, so a row's meaning comes from
488/// the section it is rendered in rather than from the file's own state.
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490enum StageIntent {
491    Stage,
492    Unstage,
493    Toggle,
494}
495
496impl StageIntent {
497    fn for_section(section: Section) -> Self {
498        match section {
499            Section::Staged => StageIntent::Unstage,
500            Section::Unstaged => StageIntent::Stage,
501            _ => StageIntent::Toggle,
502        }
503    }
504
505    /// Resolves to a concrete direction (`true` = stage), consulting the
506    /// current stage status only when no section dictates one.
507    fn resolve_with(self, stage_status: impl FnOnce() -> StageStatus) -> bool {
508        match self {
509            StageIntent::Stage => true,
510            StageIntent::Unstage => false,
511            StageIntent::Toggle => match stage_status() {
512                StageStatus::Staged => false,
513                StageStatus::Unstaged | StageStatus::PartiallyStaged => true,
514            },
515        }
516    }
517
518    fn checkbox_state(self, entry_state: impl FnOnce() -> ToggleState) -> ToggleState {
519        match self {
520            StageIntent::Stage => ToggleState::Unselected,
521            StageIntent::Unstage => ToggleState::Selected,
522            StageIntent::Toggle => entry_state(),
523        }
524    }
525
526    fn label(self, stage_status: impl FnOnce() -> StageStatus) -> &'static str {
527        if self.resolve_with(stage_status) {
528            "Stage"
529        } else {
530            "Unstage"
531        }
532    }
533}
534
535#[derive(Clone, Copy, PartialEq, Eq)]
536enum DiffTarget {
537    Uncommitted,
538    Staged,
539    Unstaged,
540}
541
542impl GitHeaderEntry {
543    pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
544        let this = &self.header;
545        let status = status_entry.status;
546        match this {
547            Section::Conflict => {
548                repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
549            }
550            Section::Tracked => !status.is_created(),
551            Section::New => status.is_created(),
552            // Conflicted files render only under the Conflict section, so the
553            // Staged/Unstaged bulk operations must not sweep them up: "Unstage
554            // All" would silently un-resolve conflicts, and "Stage All" would
555            // silently mark them resolved.
556            Section::Staged => {
557                !repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
558                    && GitPanel::stage_status_for_entry(status_entry, repo).has_staged()
559            }
560            Section::Unstaged => {
561                !repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
562                    && GitPanel::stage_status_for_entry(status_entry, repo).has_unstaged()
563            }
564        }
565    }
566    pub fn title(&self) -> &'static str {
567        match self.header {
568            Section::Conflict => "Conflicts",
569            Section::Tracked => "Tracked",
570            Section::New => "Untracked",
571            Section::Staged => "Staged",
572            Section::Unstaged => "Unstaged",
573        }
574    }
575}
576
577#[derive(Debug, PartialEq, Eq, Clone)]
578enum GitListEntry {
579    Status(GitStatusEntry),
580    TreeStatus(GitTreeStatusEntry),
581    Directory(GitTreeDirEntry),
582    Header(GitHeaderEntry),
583    EmptySection(Section),
584}
585
586impl GitListEntry {
587    fn status_entry(&self) -> Option<&GitStatusEntry> {
588        match self {
589            GitListEntry::Status(entry) => Some(entry),
590            GitListEntry::TreeStatus(entry) => Some(&entry.entry),
591            _ => None,
592        }
593    }
594
595    fn directory_entry(&self) -> Option<&GitTreeDirEntry> {
596        match self {
597            GitListEntry::Directory(entry) => Some(entry),
598            _ => None,
599        }
600    }
601
602    /// Returns the tree indentation depth for this entry.
603    fn depth(&self) -> usize {
604        match self {
605            GitListEntry::Directory(dir) => dir.depth,
606            GitListEntry::TreeStatus(status) => status.depth,
607            _ => 0,
608        }
609    }
610
611    fn is_selectable(&self) -> bool {
612        matches!(
613            self,
614            GitListEntry::Status(_) | GitListEntry::TreeStatus(_) | GitListEntry::Directory(_)
615        )
616    }
617}
618
619enum GitPanelViewMode {
620    Flat,
621    Tree(TreeViewState),
622}
623
624impl GitPanelViewMode {
625    fn from_settings(cx: &App) -> Self {
626        if GitPanelSettings::get_global(cx).tree_view {
627            GitPanelViewMode::Tree(TreeViewState::default())
628        } else {
629            GitPanelViewMode::Flat
630        }
631    }
632
633    fn tree_state(&self) -> Option<&TreeViewState> {
634        match self {
635            GitPanelViewMode::Tree(state) => Some(state),
636            GitPanelViewMode::Flat => None,
637        }
638    }
639
640    fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> {
641        match self {
642            GitPanelViewMode::Tree(state) => Some(state),
643            GitPanelViewMode::Flat => None,
644        }
645    }
646}
647
648#[derive(Default)]
649struct TreeViewState {
650    // Maps visible index to actual entry index.
651    // Length equals the number of visible entries.
652    // This is needed because some entries (like collapsed directories) may be hidden.
653    logical_indices: Vec<usize>,
654    expanded_dirs: HashMap<TreeKey, bool>,
655    directory_descendants: HashMap<TreeKey, Vec<GitStatusEntry>>,
656}
657
658impl TreeViewState {
659    fn build_tree_entries(
660        &mut self,
661        section: Section,
662        mut entries: Vec<GitStatusEntry>,
663        seen_directories: &mut HashSet<TreeKey>,
664    ) -> Vec<(GitListEntry, bool)> {
665        if entries.is_empty() {
666            return Vec::new();
667        }
668
669        entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
670
671        let mut root = TreeNode::default();
672        for entry in entries {
673            let components: Vec<&str> = entry.repo_path.components().collect();
674            if components.is_empty() {
675                root.files.push(entry);
676                continue;
677            }
678
679            let mut current = &mut root;
680            let mut current_path = String::new();
681
682            for (ix, component) in components.iter().enumerate() {
683                if ix == components.len() - 1 {
684                    current.files.push(entry.clone());
685                } else {
686                    if !current_path.is_empty() {
687                        current_path.push('/');
688                    }
689                    current_path.push_str(component);
690                    let dir_path = RepoPath::new(&current_path)
691                        .expect("repo path from status entry component");
692
693                    let component = SharedString::from(component.to_string());
694
695                    current = current
696                        .children
697                        .entry(component.clone())
698                        .or_insert_with(|| TreeNode {
699                            name: component,
700                            path: Some(dir_path),
701                            ..Default::default()
702                        });
703                }
704            }
705        }
706
707        let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories);
708        flattened
709    }
710
711    fn flatten_tree(
712        &mut self,
713        node: &TreeNode,
714        section: Section,
715        depth: usize,
716        seen_directories: &mut HashSet<TreeKey>,
717    ) -> (Vec<(GitListEntry, bool)>, Vec<GitStatusEntry>) {
718        let mut all_statuses = Vec::new();
719        let mut flattened = Vec::new();
720
721        for child in node.children.values() {
722            let (terminal, name) = Self::compact_directory_chain(child);
723            let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else {
724                continue;
725            };
726            let (child_flattened, mut child_statuses) =
727                self.flatten_tree(terminal, section, depth + 1, seen_directories);
728            let key = TreeKey { section, path };
729            let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true);
730            self.expanded_dirs.entry(key.clone()).or_insert(true);
731            seen_directories.insert(key.clone());
732
733            self.directory_descendants
734                .insert(key.clone(), child_statuses.clone());
735
736            flattened.push((
737                GitListEntry::Directory(GitTreeDirEntry {
738                    key,
739                    name,
740                    depth,
741                    expanded,
742                }),
743                true,
744            ));
745
746            if expanded {
747                flattened.extend(child_flattened);
748            } else {
749                flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false)));
750            }
751
752            all_statuses.append(&mut child_statuses);
753        }
754
755        for file in &node.files {
756            all_statuses.push(file.clone());
757            flattened.push((
758                GitListEntry::TreeStatus(GitTreeStatusEntry {
759                    entry: file.clone(),
760                    depth,
761                }),
762                true,
763            ));
764        }
765
766        (flattened, all_statuses)
767    }
768
769    fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) {
770        let mut parts = vec![node.name.clone()];
771        while node.files.is_empty() && node.children.len() == 1 {
772            let Some(child) = node.children.values().next() else {
773                continue;
774            };
775            if child.path.is_none() {
776                break;
777            }
778            parts.push(child.name.clone());
779            node = child;
780        }
781        let name = parts.join("/");
782        (node, SharedString::from(name))
783    }
784}
785
786#[derive(Debug, PartialEq, Eq, Clone)]
787struct GitTreeStatusEntry {
788    entry: GitStatusEntry,
789    depth: usize,
790}
791
792#[derive(Debug, PartialEq, Eq, Clone, Hash)]
793struct TreeKey {
794    section: Section,
795    path: RepoPath,
796}
797
798#[derive(Debug, PartialEq, Eq, Clone)]
799struct GitTreeDirEntry {
800    key: TreeKey,
801    name: SharedString,
802    depth: usize,
803    // staged_state: ToggleState,
804    expanded: bool,
805}
806
807#[derive(Default)]
808struct TreeNode {
809    name: SharedString,
810    path: Option<RepoPath>,
811    children: BTreeMap<SharedString, TreeNode>,
812    files: Vec<GitStatusEntry>,
813}
814
815#[derive(Debug, PartialEq, Eq, Clone)]
816pub struct GitStatusEntry {
817    pub(crate) repo_path: RepoPath,
818    pub(crate) status: FileStatus,
819    pub(crate) staging: StageStatus,
820    pub(crate) diff_stat: Option<DiffStat>,
821}
822
823impl GitStatusEntry {
824    fn display_name(&self, path_style: PathStyle) -> String {
825        self.repo_path
826            .file_name()
827            .map(|name| name.to_owned())
828            .unwrap_or_else(|| self.repo_path.display(path_style).to_string())
829    }
830
831    fn parent_dir(&self, path_style: PathStyle) -> Option<String> {
832        self.repo_path
833            .parent()
834            .map(|parent| parent.display(path_style).to_string())
835    }
836}
837
838struct TruncatedPatch {
839    header: String,
840    hunks: Vec<String>,
841    hunks_to_keep: usize,
842}
843
844impl TruncatedPatch {
845    fn from_unified_diff(patch_str: &str) -> Option<Self> {
846        let lines: Vec<&str> = patch_str.lines().collect();
847        if lines.len() < 2 {
848            return None;
849        }
850        let header = format!("{}\n{}\n", lines[0], lines[1]);
851        let mut hunks = Vec::new();
852        let mut current_hunk = String::new();
853        for line in &lines[2..] {
854            if line.starts_with("@@") {
855                if !current_hunk.is_empty() {
856                    hunks.push(current_hunk);
857                }
858                current_hunk = format!("{}\n", line);
859            } else if !current_hunk.is_empty() {
860                current_hunk.push_str(line);
861                current_hunk.push('\n');
862            }
863        }
864        if !current_hunk.is_empty() {
865            hunks.push(current_hunk);
866        }
867        if hunks.is_empty() {
868            return None;
869        }
870        let hunks_to_keep = hunks.len();
871        Some(TruncatedPatch {
872            header,
873            hunks,
874            hunks_to_keep,
875        })
876    }
877    fn calculate_size(&self) -> usize {
878        let mut size = self.header.len();
879        for (i, hunk) in self.hunks.iter().enumerate() {
880            if i < self.hunks_to_keep {
881                size += hunk.len();
882            }
883        }
884        size
885    }
886    fn to_string(&self) -> String {
887        let mut out = self.header.clone();
888        for (i, hunk) in self.hunks.iter().enumerate() {
889            if i < self.hunks_to_keep {
890                out.push_str(hunk);
891            }
892        }
893        let skipped_hunks = self.hunks.len() - self.hunks_to_keep;
894        if skipped_hunks > 0 {
895            out.push_str(&format!("[...skipped {} hunks...]\n", skipped_hunks));
896        }
897        out
898    }
899}
900
901struct GitPanelContextMenu {
902    menu: Entity<ContextMenu>,
903    position: Point<Pixels>,
904    target_entry_index: Option<usize>,
905    _subscription: Subscription,
906}
907
908pub struct GitPanel {
909    pub(crate) active_repository: Option<Entity<Repository>>,
910    pub(crate) commit_editor: Entity<Editor>,
911    /// Whether the commit editor should fill the vertical height of the panel.
912    commit_editor_expanded: bool,
913    conflicted_count: usize,
914    conflicted_staged_count: usize,
915    add_coauthors: bool,
916    generate_commit_message_task: Option<Task<Option<()>>>,
917    entries: Vec<GitListEntry>,
918    view_mode: GitPanelViewMode,
919    tree_expanded_dirs: HashMap<TreeKey, bool>,
920    projected_entries_by_path: HashMap<RepoPath, SmallVec<[ProjectedChangeEntry; 2]>>,
921    single_staged_entry: Option<GitStatusEntry>,
922    single_tracked_entry: Option<GitStatusEntry>,
923    focus_handle: FocusHandle,
924    fs: Arc<dyn Fs>,
925    new_count: usize,
926    entry_count: usize,
927    changes_count: usize,
928    diff_stat_total: DiffStat,
929    new_staged_count: usize,
930    pending_commit: Option<Task<()>>,
931    pending_remote_operation: Option<RemoteOperationKind>,
932    amend_pending: bool,
933    original_commit_message: Option<String>,
934    pending_commit_message_restores: BTreeMap<String, SerializedCommitMessage>,
935    signoff_enabled: bool,
936    skip_hooks_enabled: bool,
937    pending_serialization: Task<()>,
938    pub(crate) project: Entity<Project>,
939    scroll_handle: UniformListScrollHandle,
940    max_width_item_index: Option<usize>,
941    selected_entry: Option<usize>,
942    marked_entries: Vec<usize>,
943    tracked_count: usize,
944    tracked_staged_count: usize,
945    update_visible_entries_task: Task<()>,
946    reopen_commit_buffer_task: Task<()>,
947    pub(crate) workspace: WeakEntity<Workspace>,
948    context_menu: Option<GitPanelContextMenu>,
949    modal_open: bool,
950    show_placeholders: bool,
951    // Only read to compute collaborative co-authors, which requires the `call` feature.
952    #[cfg_attr(not(feature = "call"), allow(dead_code))]
953    local_committer: Option<GitCommitter>,
954    local_committer_task: Option<Task<()>>,
955    commit_template: Option<GitCommitTemplate>,
956    bulk_staging: Option<BulkStaging>,
957    stash_entries: GitStash,
958    active_tab: GitPanelTab,
959    commit_history_scroll_handle: UniformListScrollHandle,
960    commit_history: CommitHistory,
961    focused_history_entry: Option<usize>,
962    history_keyboard_nav: bool,
963    _commit_message_buffer_subscription: Option<Subscription>,
964    _repo_subscriptions: Vec<Subscription>,
965    _settings_subscription: Subscription,
966    git_access: Option<GitAccess>,
967    commit_menu_handle: PopoverMenuHandle<ContextMenu>,
968    changes_actions_menu_handle: PopoverMenuHandle<ContextMenu>,
969    remote_action_menu_handle: PopoverMenuHandle<ContextMenu>,
970}
971
972#[derive(Clone, Debug, PartialEq, Eq)]
973struct BulkStaging {
974    repo_id: RepositoryId,
975    anchor: RepoPath,
976}
977
978#[derive(Clone, Debug, PartialEq, Eq)]
979struct CommitHistoryEntry {
980    sha: Oid,
981    tag_names: Vec<SharedString>,
982}
983
984impl From<&Arc<InitialGraphCommitData>> for CommitHistoryEntry {
985    fn from(commit: &Arc<InitialGraphCommitData>) -> Self {
986        Self {
987            sha: commit.sha,
988            tag_names: commit
989                .tag_names()
990                .into_iter()
991                .map(|tag_name| SharedString::from(tag_name.to_string()))
992                .collect(),
993        }
994    }
995}
996
997const MAX_PANEL_EDITOR_LINES: usize = 6;
998
999pub(crate) fn commit_message_editor(
1000    commit_message_buffer: Entity<Buffer>,
1001    placeholder: Option<SharedString>,
1002    project: Entity<Project>,
1003    in_panel: bool,
1004    window: &mut Window,
1005    cx: &mut Context<Editor>,
1006) -> Editor {
1007    let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
1008    let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
1009    let mut commit_editor = Editor::new(
1010        EditorMode::AutoHeight {
1011            min_lines: max_lines,
1012            max_lines: Some(max_lines),
1013        },
1014        buffer,
1015        None,
1016        window,
1017        cx,
1018    );
1019    commit_editor.set_collaboration_hub(Box::new(project));
1020    commit_editor.set_use_autoclose(false);
1021    commit_editor.set_show_gutter(false, cx);
1022    commit_editor.set_use_modal_editing(true);
1023    commit_editor.set_show_wrap_guides(false, cx);
1024    commit_editor.set_show_indent_guides(false, cx);
1025    let placeholder = placeholder.unwrap_or("Enter commit message".into());
1026    commit_editor.set_placeholder_text(&placeholder, window, cx);
1027    commit_editor
1028}
1029
1030impl GitPanel {
1031    // Only the test-support constructors call this thin wrapper now; production
1032    // registration goes through `new_with_serialized_panel` directly. Gate it to
1033    // the same cfg as `new_test` so the non-test lib build doesn't see it as dead.
1034    #[cfg(any(test, feature = "test-support"))]
1035    fn new(
1036        workspace: &mut Workspace,
1037        window: &mut Window,
1038        cx: &mut Context<Workspace>,
1039    ) -> Entity<Self> {
1040        Self::new_with_serialized_panel(workspace, None, window, cx)
1041    }
1042
1043    fn new_with_serialized_panel(
1044        workspace: &mut Workspace,
1045        serialized_panel: Option<SerializedGitPanel>,
1046        window: &mut Window,
1047        cx: &mut Context<Workspace>,
1048    ) -> Entity<Self> {
1049        let project = workspace.project().clone();
1050        let app_state = workspace.app_state().clone();
1051        let fs = app_state.fs.clone();
1052        let git_store = project.read(cx).git_store().clone();
1053        let active_repository = project.read(cx).active_repository(cx);
1054        let signoff_enabled = serialized_panel
1055            .as_ref()
1056            .is_some_and(|panel| panel.signoff_enabled);
1057        let active_work_directory_abs_path = active_repository.as_ref().map(|repository| {
1058            repository
1059                .read(cx)
1060                .work_directory_abs_path
1061                .to_string_lossy()
1062                .into_owned()
1063        });
1064        let active_draft = serialized_panel.as_ref().and_then(|panel| {
1065            let path = active_work_directory_abs_path.as_ref()?;
1066            panel.commit_messages.get(path)
1067        });
1068        // Seed the placeholder editor with the restored draft when the active
1069        // repository already matches the serialized one, so the message is
1070        // present immediately on restart instead of only after the commit
1071        // buffer finishes loading in `reopen_commit_buffer`. Sourced from the
1072        // serialized draft rather than a live buffer snapshot and scoped to the
1073        // matching repository, so it neither replays cleared text nor leaks a
1074        // draft across repositories. `reopen_commit_buffer` still performs the
1075        // one-shot restore into the loaded buffer; applying the same draft
1076        // there is idempotent.
1077        let amend_pending = active_draft.is_some_and(|draft| draft.amend_pending);
1078        let original_commit_message = active_draft.and_then(|draft| draft.original_message.clone());
1079        let initial_commit_message = active_draft
1080            .and_then(|draft| draft.message.clone())
1081            .unwrap_or_default();
1082        let pending_commit_message_restores = serialized_panel
1083            .map(|panel| panel.commit_messages)
1084            .unwrap_or_default();
1085
1086        cx.new(|cx| {
1087            let focus_handle = cx.focus_handle();
1088            cx.on_focus(&focus_handle, window, Self::focus_in).detach();
1089
1090            let mut was_sort_by = GitPanelSettings::get_global(cx).sort_by;
1091            let mut was_group_by = GitPanelSettings::get_global(cx).group_by;
1092            let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
1093            let mut was_file_icons = GitPanelSettings::get_global(cx).file_icons;
1094            let mut was_folder_icons = GitPanelSettings::get_global(cx).folder_icons;
1095            let mut was_diff_stats = GitPanelSettings::get_global(cx).diff_stats;
1096            cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
1097                let settings = GitPanelSettings::get_global(cx);
1098                let sort_by = settings.sort_by;
1099                let group_by = settings.group_by;
1100                let tree_view = settings.tree_view;
1101                let file_icons = settings.file_icons;
1102                let folder_icons = settings.folder_icons;
1103                let diff_stats = settings.diff_stats;
1104                if tree_view != was_tree_view {
1105                    match (&mut this.view_mode, tree_view) {
1106                        (GitPanelViewMode::Tree(state), false) => {
1107                            this.tree_expanded_dirs = state.expanded_dirs.clone();
1108                            this.view_mode = GitPanelViewMode::Flat;
1109                        }
1110                        (GitPanelViewMode::Flat, true) => {
1111                            this.view_mode = GitPanelViewMode::Tree(TreeViewState {
1112                                expanded_dirs: this.tree_expanded_dirs.clone(),
1113                                ..Default::default()
1114                            });
1115                        }
1116                        _ => {}
1117                    }
1118                }
1119
1120                let mut update_entries = false;
1121                if sort_by != was_sort_by || group_by != was_group_by || tree_view != was_tree_view
1122                {
1123                    this.bulk_staging.take();
1124                    update_entries = true;
1125                }
1126                if (diff_stats != was_diff_stats) || update_entries {
1127                    this.update_visible_entries(window, cx);
1128                }
1129                if file_icons != was_file_icons || folder_icons != was_folder_icons {
1130                    cx.notify();
1131                }
1132                was_sort_by = sort_by;
1133                was_group_by = group_by;
1134                was_tree_view = tree_view;
1135                was_file_icons = file_icons;
1136                was_folder_icons = folder_icons;
1137                was_diff_stats = diff_stats;
1138            })
1139            .detach();
1140
1141            cx.observe_global::<FileIcons>(|_, cx| {
1142                cx.notify();
1143            })
1144            .detach();
1145
1146            // just to let us render a placeholder editor.
1147            // Once the active git repo is set, this buffer will be replaced.
1148            let temporary_buffer = cx.new(|cx| Buffer::local(initial_commit_message, cx));
1149            let commit_editor = cx.new(|cx| {
1150                commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
1151            });
1152
1153            let scroll_handle = UniformListScrollHandle::new();
1154
1155            let mut was_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
1156            let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
1157                let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
1158                if was_ai_enabled != is_ai_enabled {
1159                    was_ai_enabled = is_ai_enabled;
1160                    cx.notify();
1161                }
1162            });
1163
1164            let registry = LanguageModelRegistry::global(cx);
1165            cx.subscribe(&registry, |_, _, event, cx| match event {
1166                LanguageModelEvent::CommitMessageModelChanged
1167                | LanguageModelEvent::DefaultModelChanged
1168                | LanguageModelEvent::ProviderStateChanged(_)
1169                | LanguageModelEvent::AddedProvider(_)
1170                | LanguageModelEvent::RemovedProvider(_)
1171                | LanguageModelEvent::ProvidersChanged => {
1172                    cx.notify();
1173                }
1174                _ => {}
1175            })
1176            .detach();
1177
1178            cx.subscribe_in(
1179                &git_store,
1180                window,
1181                move |this, _git_store, event, window, cx| match event {
1182                    GitStoreEvent::RepositoryUpdated(
1183                        _,
1184                        RepositoryEvent::StatusesChanged | RepositoryEvent::HeadChanged,
1185                        true,
1186                    )
1187                    | GitStoreEvent::RepositoryAdded
1188                    | GitStoreEvent::RepositoryRemoved(_)
1189                    | GitStoreEvent::ActiveRepositoryChanged(_) => {
1190                        this.schedule_update(window, cx);
1191                    }
1192                    GitStoreEvent::RepositoryUpdated(
1193                        _,
1194                        RepositoryEvent::GitDirectoryChanged,
1195                        true,
1196                    )
1197                    | GitStoreEvent::GlobalConfigurationUpdated => {
1198                        this.git_access = None;
1199                        this.schedule_update(window, cx);
1200                    }
1201                    GitStoreEvent::IndexWriteError(error) => {
1202                        this.workspace
1203                            .update(cx, |workspace, cx| {
1204                                workspace.show_error(format!("{error}"), cx);
1205                            })
1206                            .ok();
1207                    }
1208                    GitStoreEvent::RepositoryUpdated(_, _, _) => {}
1209                    GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
1210                },
1211            )
1212            .detach();
1213
1214            let mut this = Self {
1215                active_repository,
1216                commit_editor,
1217                commit_editor_expanded: false,
1218                conflicted_count: 0,
1219                conflicted_staged_count: 0,
1220                add_coauthors: true,
1221                generate_commit_message_task: None,
1222                entries: Vec::new(),
1223                view_mode: GitPanelViewMode::from_settings(cx),
1224                tree_expanded_dirs: HashMap::default(),
1225                projected_entries_by_path: HashMap::default(),
1226                focus_handle: cx.focus_handle(),
1227                fs,
1228                new_count: 0,
1229                new_staged_count: 0,
1230                changes_count: 0,
1231                diff_stat_total: DiffStat::default(),
1232                pending_commit: None,
1233                pending_remote_operation: None,
1234                amend_pending,
1235                original_commit_message,
1236                pending_commit_message_restores,
1237                signoff_enabled,
1238                skip_hooks_enabled: false,
1239                pending_serialization: Task::ready(()),
1240                single_staged_entry: None,
1241                single_tracked_entry: None,
1242                project,
1243                scroll_handle,
1244                max_width_item_index: None,
1245                selected_entry: None,
1246                marked_entries: Vec::new(),
1247                tracked_count: 0,
1248                tracked_staged_count: 0,
1249                update_visible_entries_task: Task::ready(()),
1250                reopen_commit_buffer_task: Task::ready(()),
1251                show_placeholders: false,
1252                local_committer: None,
1253                local_committer_task: None,
1254                commit_template: None,
1255                context_menu: None,
1256                workspace: workspace.weak_handle(),
1257                modal_open: false,
1258                entry_count: 0,
1259                bulk_staging: None,
1260                stash_entries: Default::default(),
1261                active_tab: GitPanelTab::Changes,
1262                commit_history_scroll_handle: UniformListScrollHandle::new(),
1263                commit_history: CommitHistory::Loading,
1264                focused_history_entry: None,
1265                history_keyboard_nav: false,
1266                _commit_message_buffer_subscription: None,
1267                _repo_subscriptions: Vec::new(),
1268                _settings_subscription,
1269                git_access: None,
1270                commit_menu_handle: PopoverMenuHandle::default(),
1271                changes_actions_menu_handle: PopoverMenuHandle::default(),
1272                remote_action_menu_handle: PopoverMenuHandle::default(),
1273            };
1274
1275            this.schedule_update(window, cx);
1276            this
1277        })
1278    }
1279
1280    pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
1281        self.projected_entries_by_path
1282            .get(path)?
1283            .first()
1284            .map(|entry| entry.index)
1285    }
1286
1287    fn entry_by_path_in_section(&self, path: &RepoPath, section: Section) -> Option<usize> {
1288        self.projected_entries_by_path
1289            .get(path)?
1290            .iter()
1291            .find(|entry| entry.section == section)
1292            .map(|entry| entry.index)
1293    }
1294
1295    pub fn select_entry_by_path(
1296        &mut self,
1297        path: ProjectPath,
1298        window: &mut Window,
1299        cx: &mut Context<Self>,
1300    ) {
1301        let Some(git_repo) = self.active_repository.as_ref() else {
1302            return;
1303        };
1304
1305        let (repo_path, default_section) = {
1306            let repo = git_repo.read(cx);
1307            let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
1308                return;
1309            };
1310
1311            let section = repo
1312                .status_for_path(&repo_path)
1313                .map(|status| status.status)
1314                .map(|status| {
1315                    if GitPanelSettings::get_global(cx).group_by == GitPanelGroupBy::Staging {
1316                        if repo.had_conflict_on_last_merge_head_change(&repo_path) {
1317                            Section::Conflict
1318                        } else if status.staging().has_staged() {
1319                            Section::Staged
1320                        } else {
1321                            Section::Unstaged
1322                        }
1323                    } else if repo.had_conflict_on_last_merge_head_change(&repo_path) {
1324                        Section::Conflict
1325                    } else if status.is_created() {
1326                        Section::New
1327                    } else {
1328                        Section::Tracked
1329                    }
1330                });
1331
1332            (repo_path, section)
1333        };
1334        let selected_section = self.selected_entry.and_then(|index| {
1335            let selected_entry = self.entries.get(index)?.status_entry()?;
1336            if selected_entry.repo_path == repo_path {
1337                self.section_for_entry_index(index)
1338            } else {
1339                None
1340            }
1341        });
1342        let section = selected_section.or(default_section);
1343
1344        let mut needs_rebuild = false;
1345        if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
1346            let mut current_dir = repo_path.parent();
1347            while let Some(dir) = current_dir {
1348                let key = TreeKey {
1349                    section,
1350                    path: RepoPath::from_rel_path(dir),
1351                };
1352
1353                if tree_state.expanded_dirs.get(&key) == Some(&false) {
1354                    tree_state.expanded_dirs.insert(key, true);
1355                    needs_rebuild = true;
1356                }
1357
1358                current_dir = dir.parent();
1359            }
1360        }
1361
1362        if needs_rebuild {
1363            self.update_visible_entries(window, cx);
1364        }
1365
1366        let Some(ix) = section
1367            .and_then(|section| self.entry_by_path_in_section(&repo_path, section))
1368            .or_else(|| self.entry_by_path(&repo_path))
1369        else {
1370            return;
1371        };
1372
1373        self.selected_entry = Some(ix);
1374        self.scroll_to_selected_entry(cx);
1375    }
1376
1377    fn serialization_key(workspace: &Workspace) -> Option<String> {
1378        workspace
1379            .database_id()
1380            .map(|id| i64::from(id).to_string())
1381            .or(workspace.session_id())
1382            .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
1383    }
1384
1385    fn serialize(&mut self, cx: &mut Context<Self>) {
1386        let signoff_enabled = self.signoff_enabled;
1387        let commit_messages = self.serialized_commit_messages(cx);
1388        let kvp = KeyValueStore::global(cx);
1389
1390        self.pending_serialization = cx.spawn(async move |git_panel, cx| {
1391            cx.background_executor()
1392                .timer(SERIALIZATION_THROTTLE_TIME)
1393                .await;
1394            let Some(serialization_key) = git_panel
1395                .update(cx, |git_panel, cx| {
1396                    git_panel
1397                        .workspace
1398                        .read_with(cx, |workspace, _| Self::serialization_key(workspace))
1399                        .ok()
1400                        .flatten()
1401                })
1402                .ok()
1403                .flatten()
1404            else {
1405                return;
1406            };
1407            cx.background_spawn(
1408                async move {
1409                    kvp.write_kvp(
1410                        serialization_key,
1411                        serde_json::to_string(&SerializedGitPanel {
1412                            signoff_enabled,
1413                            commit_messages,
1414                        })?,
1415                    )
1416                    .await?;
1417                    anyhow::Ok(())
1418                }
1419                .log_err(),
1420            )
1421            .await;
1422        });
1423    }
1424
1425    fn serialized_commit_messages(&self, cx: &App) -> BTreeMap<String, SerializedCommitMessage> {
1426        let active_work_directory_abs_path = self.active_repository.as_ref().map(|repository| {
1427            repository
1428                .read(cx)
1429                .work_directory_abs_path
1430                .to_string_lossy()
1431                .into_owned()
1432        });
1433        let git_store = self.project.read(cx).git_store().clone();
1434        let mut commit_messages = self.pending_commit_message_restores.clone();
1435        for repository in git_store.read(cx).repositories().values() {
1436            let repository = repository.read(cx);
1437            let work_directory_abs_path = repository
1438                .work_directory_abs_path
1439                .to_string_lossy()
1440                .into_owned();
1441            if active_work_directory_abs_path.as_deref() == Some(work_directory_abs_path.as_str()) {
1442                continue;
1443            }
1444            if let Some(buffer) = repository.commit_message_buffer() {
1445                let text = buffer.read(cx).text();
1446                if text.trim().is_empty() {
1447                    commit_messages.remove(&work_directory_abs_path);
1448                } else {
1449                    commit_messages.insert(
1450                        work_directory_abs_path,
1451                        SerializedCommitMessage {
1452                            message: Some(text),
1453                            original_message: None,
1454                            amend_pending: false,
1455                        },
1456                    );
1457                }
1458            }
1459        }
1460        if let Some(work_directory_abs_path) = active_work_directory_abs_path {
1461            let text = self.commit_message_buffer(cx).read(cx).text();
1462            let message = (!text.trim().is_empty()).then_some(text);
1463            let original_message = self.original_commit_message.clone();
1464            let amend_pending = self.amend_pending;
1465            if message.is_some() || original_message.is_some() || amend_pending {
1466                commit_messages.insert(
1467                    work_directory_abs_path,
1468                    SerializedCommitMessage {
1469                        message,
1470                        original_message,
1471                        amend_pending,
1472                    },
1473                );
1474            } else {
1475                commit_messages.remove(&work_directory_abs_path);
1476            }
1477        }
1478        commit_messages
1479    }
1480
1481    pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
1482        self.modal_open = open;
1483        cx.notify();
1484    }
1485
1486    fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
1487        let mut dispatch_context = KeyContext::new_with_defaults();
1488        dispatch_context.add("GitPanel");
1489
1490        if self.commit_editor.read(cx).is_focused(window) {
1491            dispatch_context.add("CommitEditor");
1492        } else if self.focus_handle.contains_focused(window, cx) {
1493            dispatch_context.add("menu");
1494            dispatch_context.add("ChangesList");
1495        }
1496
1497        dispatch_context
1498    }
1499
1500    fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
1501        cx.emit(PanelEvent::Close);
1502    }
1503
1504    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1505        if !self.focus_handle.contains_focused(window, cx) {
1506            cx.emit(Event::Focus);
1507        }
1508        if self.active_tab == GitPanelTab::History && self.focused_history_entry.is_some() {
1509            self.history_keyboard_nav = true;
1510            cx.notify();
1511        }
1512    }
1513
1514    fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
1515        let Some(selected_entry) = self.selected_entry else {
1516            cx.notify();
1517            return;
1518        };
1519
1520        let visible_index = match &self.view_mode {
1521            GitPanelViewMode::Flat => Some(selected_entry),
1522            GitPanelViewMode::Tree(state) => state
1523                .logical_indices
1524                .iter()
1525                .position(|&ix| ix == selected_entry),
1526        };
1527
1528        if let Some(visible_index) = visible_index {
1529            self.scroll_handle
1530                .scroll_to_item(visible_index, ScrollStrategy::Center);
1531        }
1532
1533        cx.notify();
1534    }
1535
1536    fn expand_selected_entry(
1537        &mut self,
1538        _: &ExpandSelectedEntry,
1539        window: &mut Window,
1540        cx: &mut Context<Self>,
1541    ) {
1542        let Some(entry) = self.get_selected_entry().cloned() else {
1543            return;
1544        };
1545
1546        if let GitListEntry::Directory(dir_entry) = entry {
1547            if dir_entry.expanded {
1548                self.select_next(&menu::SelectNext, window, cx);
1549            } else {
1550                self.toggle_directory(&dir_entry.key, window, cx);
1551            }
1552        } else {
1553            self.select_next(&menu::SelectNext, window, cx);
1554        }
1555    }
1556
1557    fn collapse_selected_entry(
1558        &mut self,
1559        _: &CollapseSelectedEntry,
1560        window: &mut Window,
1561        cx: &mut Context<Self>,
1562    ) {
1563        let Some(entry) = self.get_selected_entry().cloned() else {
1564            return;
1565        };
1566
1567        if let GitListEntry::Directory(dir_entry) = entry {
1568            if dir_entry.expanded {
1569                self.toggle_directory(&dir_entry.key, window, cx);
1570            } else {
1571                self.select_previous(&menu::SelectPrevious, window, cx);
1572            }
1573        } else {
1574            self.select_previous(&menu::SelectPrevious, window, cx);
1575        }
1576    }
1577
1578    fn select_first(
1579        &mut self,
1580        _: &menu::SelectFirst,
1581        _window: &mut Window,
1582        cx: &mut Context<Self>,
1583    ) {
1584        let first_entry = match &self.view_mode {
1585            GitPanelViewMode::Flat => self
1586                .entries
1587                .iter()
1588                .position(|entry| entry.status_entry().is_some()),
1589            GitPanelViewMode::Tree(state) => {
1590                let index = self.entries.iter().position(|entry| {
1591                    entry.status_entry().is_some() || entry.directory_entry().is_some()
1592                });
1593
1594                index.map(|index| state.logical_indices[index])
1595            }
1596        };
1597
1598        if let Some(first_entry) = first_entry {
1599            self.selected_entry = Some(first_entry);
1600            self.scroll_to_selected_entry(cx);
1601        }
1602    }
1603
1604    fn select_previous(
1605        &mut self,
1606        _: &menu::SelectPrevious,
1607        _window: &mut Window,
1608        cx: &mut Context<Self>,
1609    ) {
1610        if self.active_tab == GitPanelTab::History {
1611            self.select_previous_history_entry(cx);
1612            return;
1613        }
1614
1615        let item_count = self.entries.len();
1616        if item_count == 0 {
1617            return;
1618        }
1619
1620        let Some(selected_entry) = self.selected_entry else {
1621            return;
1622        };
1623
1624        let new_index = match &self.view_mode {
1625            GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
1626            GitPanelViewMode::Tree(state) => {
1627                let Some(current_logical_index) = state
1628                    .logical_indices
1629                    .iter()
1630                    .position(|&i| i == selected_entry)
1631                else {
1632                    return;
1633                };
1634
1635                state.logical_indices[current_logical_index.saturating_sub(1)]
1636            }
1637        };
1638
1639        if selected_entry == 0 && new_index == 0 {
1640            return;
1641        }
1642
1643        let candidate = match &self.view_mode {
1644            GitPanelViewMode::Flat => {
1645                let mut candidate = new_index;
1646                loop {
1647                    match self.entries.get(candidate) {
1648                        Some(entry) if entry.is_selectable() => break Some(candidate),
1649                        Some(_) => {
1650                            if candidate == 0 {
1651                                break None;
1652                            }
1653                            candidate -= 1;
1654                        }
1655                        None => break None,
1656                    }
1657                }
1658            }
1659            GitPanelViewMode::Tree(state) => {
1660                let mut position = state.logical_indices.iter().position(|&i| i == new_index);
1661                loop {
1662                    let Some(current) = position else { break None };
1663                    let Some(&index) = state.logical_indices.get(current) else {
1664                        break None;
1665                    };
1666                    match self.entries.get(index) {
1667                        Some(entry) if entry.is_selectable() => break Some(index),
1668                        _ => position = current.checked_sub(1),
1669                    }
1670                }
1671            }
1672        };
1673
1674        let Some(candidate) = candidate else {
1675            return;
1676        };
1677
1678        self.selected_entry = Some(candidate);
1679        self.scroll_to_selected_entry(cx);
1680    }
1681
1682    fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1683        if self.active_tab == GitPanelTab::History {
1684            self.select_next_history_entry(cx);
1685            return;
1686        }
1687
1688        let item_count = self.entries.len();
1689        if item_count == 0 {
1690            return;
1691        }
1692
1693        let Some(selected_entry) = self.selected_entry else {
1694            return;
1695        };
1696
1697        let new_index = match &self.view_mode {
1698            GitPanelViewMode::Flat => {
1699                if selected_entry >= item_count.saturating_sub(1) {
1700                    return;
1701                }
1702
1703                selected_entry.saturating_add(1)
1704            }
1705            GitPanelViewMode::Tree(state) => {
1706                let Some(current_logical_index) = state
1707                    .logical_indices
1708                    .iter()
1709                    .position(|&i| i == selected_entry)
1710                else {
1711                    return;
1712                };
1713
1714                let Some(new_index) = state
1715                    .logical_indices
1716                    .get(current_logical_index.saturating_add(1))
1717                    .copied()
1718                else {
1719                    return;
1720                };
1721
1722                new_index
1723            }
1724        };
1725
1726        let candidate = match &self.view_mode {
1727            GitPanelViewMode::Flat => {
1728                let mut candidate = new_index;
1729                loop {
1730                    match self.entries.get(candidate) {
1731                        Some(entry) if entry.is_selectable() => break Some(candidate),
1732                        Some(_) => candidate += 1,
1733                        None => break None,
1734                    }
1735                }
1736            }
1737            GitPanelViewMode::Tree(state) => {
1738                let mut position = state.logical_indices.iter().position(|&i| i == new_index);
1739                loop {
1740                    let Some(current) = position else { break None };
1741                    let Some(&index) = state.logical_indices.get(current) else {
1742                        break None;
1743                    };
1744                    match self.entries.get(index) {
1745                        Some(entry) if entry.is_selectable() => break Some(index),
1746                        _ => position = Some(current + 1),
1747                    }
1748                }
1749            }
1750        };
1751
1752        let Some(candidate) = candidate else {
1753            return;
1754        };
1755
1756        self.selected_entry = Some(candidate);
1757        self.scroll_to_selected_entry(cx);
1758    }
1759
1760    fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1761        let last_entry = match &self.view_mode {
1762            GitPanelViewMode::Flat => self.entries.iter().rposition(GitListEntry::is_selectable),
1763            GitPanelViewMode::Tree(state) => {
1764                state.logical_indices.iter().rev().copied().find(|&index| {
1765                    self.entries
1766                        .get(index)
1767                        .is_some_and(GitListEntry::is_selectable)
1768                })
1769            }
1770        };
1771
1772        if let Some(last_entry) = last_entry {
1773            self.selected_entry = Some(last_entry);
1774            self.scroll_to_selected_entry(cx);
1775        }
1776    }
1777
1778    /// Show diff view at selected entry, only if the diff view is open
1779    fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1780        maybe!({
1781            let workspace = self.workspace.upgrade()?;
1782            let selected_index = self.selected_entry?;
1783            let entry = self.entries.get(selected_index)?.status_entry()?.clone();
1784            let target =
1785                Self::diff_target_for_section(self.section_for_entry_index(selected_index));
1786
1787            match target {
1788                DiffTarget::Staged => {
1789                    if let Some(staged_diff) = workspace.read(cx).item_of_type::<StagedDiff>(cx) {
1790                        staged_diff.update(cx, |staged_diff, cx| {
1791                            staged_diff.move_to_entry(entry, window, cx);
1792                        });
1793                    }
1794                }
1795                DiffTarget::Unstaged => {
1796                    if let Some(unstaged_diff) = workspace.read(cx).item_of_type::<UnstagedDiff>(cx)
1797                    {
1798                        unstaged_diff.update(cx, |unstaged_diff, cx| {
1799                            unstaged_diff.move_to_entry(entry, window, cx);
1800                        });
1801                    }
1802                }
1803                DiffTarget::Uncommitted => {
1804                    if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
1805                        project_diff.update(cx, |project_diff, cx| {
1806                            project_diff.move_to_entry(entry, window, cx);
1807                        });
1808                    }
1809                }
1810            }
1811
1812            Some(())
1813        });
1814    }
1815
1816    fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
1817        self.select_first(&menu::SelectFirst, window, cx);
1818        self.move_diff_to_entry(window, cx);
1819    }
1820
1821    fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
1822        self.select_last(&menu::SelectLast, window, cx);
1823        self.move_diff_to_entry(window, cx);
1824    }
1825
1826    fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
1827        self.select_next(&menu::SelectNext, window, cx);
1828        self.move_diff_to_entry(window, cx);
1829    }
1830
1831    fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
1832        self.select_previous(&menu::SelectPrevious, window, cx);
1833        self.move_diff_to_entry(window, cx);
1834    }
1835
1836    fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
1837        self.commit_editor.update(cx, |editor, cx| {
1838            window.focus(&editor.focus_handle(cx), cx);
1839        });
1840        cx.notify();
1841    }
1842
1843    fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1844        let have_entries = self
1845            .active_repository
1846            .as_ref()
1847            .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
1848        if have_entries && self.selected_entry.is_none() {
1849            self.select_first(&menu::SelectFirst, window, cx);
1850        }
1851    }
1852
1853    fn select_last_entry_if_out_of_bounds(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1854        if let Some(idx) = self.selected_entry
1855            && idx >= self.entries.len()
1856        {
1857            self.select_last(&menu::SelectLast, window, cx);
1858        }
1859    }
1860
1861    fn focus_changes_list(
1862        &mut self,
1863        _: &FocusChanges,
1864        window: &mut Window,
1865        cx: &mut Context<Self>,
1866    ) {
1867        self.focus_handle.focus(window, cx);
1868        self.select_first_entry_if_none(window, cx);
1869    }
1870
1871    fn get_selected_entry(&self) -> Option<&GitListEntry> {
1872        self.selected_entry.and_then(|i| self.entries.get(i))
1873    }
1874
1875    fn change_entries_by_path(&self) -> impl Iterator<Item = &GitStatusEntry> {
1876        // A grouping can project one changed file into multiple list rows.
1877        self.entries
1878            .iter()
1879            .filter_map(GitListEntry::status_entry)
1880            .unique_by(|entry| entry.repo_path.clone())
1881    }
1882
1883    fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1884        if self.active_tab == GitPanelTab::History {
1885            self.open_selected_history_commit(window, cx);
1886            return;
1887        }
1888        if let Some(GitListEntry::Directory(dir_entry)) = self
1889            .selected_entry
1890            .and_then(|i| self.entries.get(i))
1891            .cloned()
1892        {
1893            self.toggle_directory(&dir_entry.key, window, cx);
1894            return;
1895        }
1896        maybe!({
1897            let selected_index = self.selected_entry?;
1898            let entry = self.entries.get(selected_index)?.status_entry()?;
1899            let workspace = self.workspace.upgrade()?;
1900            let git_repo = self.active_repository.as_ref()?;
1901            let target =
1902                Self::diff_target_for_section(self.section_for_entry_index(selected_index));
1903
1904            if target == DiffTarget::Uncommitted
1905                && let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
1906                && let Some(project_path) = project_diff.read(cx).active_project_path(cx)
1907                && Some(&entry.repo_path)
1908                    == git_repo
1909                        .read(cx)
1910                        .project_path_to_repo_path(&project_path, cx)
1911                        .as_ref()
1912            {
1913                project_diff.focus_handle(cx).focus(window, cx);
1914                project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
1915                return None;
1916            };
1917
1918            self.workspace
1919                .update(cx, |workspace, cx| match target {
1920                    DiffTarget::Uncommitted => {
1921                        ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
1922                    }
1923                    DiffTarget::Staged => {
1924                        StagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
1925                    }
1926                    DiffTarget::Unstaged => {
1927                        UnstagedDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
1928                    }
1929                })
1930                .ok();
1931            self.focus_handle.focus(window, cx);
1932
1933            Some(())
1934        });
1935    }
1936
1937    fn open_solo_diff(
1938        &mut self,
1939        _: &menu::SecondaryConfirm,
1940        window: &mut Window,
1941        cx: &mut Context<Self>,
1942    ) {
1943        maybe!({
1944            let entry = self
1945                .entries
1946                .get(self.selected_entry?)?
1947                .status_entry()?
1948                .clone();
1949            let repository = self.active_repository.clone()?;
1950
1951            SoloDiffView::open_or_focus(entry, repository, self.workspace.clone(), window, cx)
1952                .detach_and_notify_err(self.workspace.clone(), window, cx);
1953
1954            Some(())
1955        });
1956    }
1957
1958    fn view_file(&mut self, _: &ViewFile, window: &mut Window, cx: &mut Context<Self>) {
1959        maybe!({
1960            let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1961            let project_path = self
1962                .active_repository
1963                .as_ref()?
1964                .read(cx)
1965                .repo_path_to_project_path(&entry.repo_path, cx)?;
1966
1967            self.workspace
1968                .update(cx, |workspace, cx| {
1969                    workspace
1970                        .open_path_preview(project_path, None, false, false, true, window, cx)
1971                        .detach_and_log_err(cx);
1972                })
1973                .ok()?;
1974
1975            Some(())
1976        });
1977    }
1978
1979    fn open_selected_entry_on_click(
1980        &mut self,
1981        secondary: bool,
1982        window: &mut Window,
1983        cx: &mut Context<Self>,
1984    ) {
1985        let entry_primary_click_action =
1986            GitPanelSettings::get_global(cx).entry_primary_click_action;
1987        let action = match (entry_primary_click_action, secondary) {
1988            (GitPanelClickBehavior::ProjectDiff, false) => GitPanelClickBehavior::ProjectDiff,
1989            (GitPanelClickBehavior::ProjectDiff, true) => GitPanelClickBehavior::FileDiff,
1990            (GitPanelClickBehavior::FileDiff, false) => GitPanelClickBehavior::FileDiff,
1991            (GitPanelClickBehavior::FileDiff, true) => GitPanelClickBehavior::ProjectDiff,
1992            (GitPanelClickBehavior::ViewFile, false) => GitPanelClickBehavior::ViewFile,
1993            (GitPanelClickBehavior::ViewFile, true) => GitPanelClickBehavior::ProjectDiff,
1994        };
1995        match action {
1996            GitPanelClickBehavior::ProjectDiff => {
1997                self.open_diff(&Default::default(), window, cx);
1998                self.focus_handle.focus(window, cx);
1999            }
2000            GitPanelClickBehavior::FileDiff => {
2001                self.open_solo_diff(&Default::default(), window, cx);
2002            }
2003            GitPanelClickBehavior::ViewFile => {
2004                self.view_file(&Default::default(), window, cx);
2005            }
2006        }
2007    }
2008
2009    fn revert_selected(
2010        &mut self,
2011        action: &git::RestoreFile,
2012        window: &mut Window,
2013        cx: &mut Context<Self>,
2014    ) {
2015        let path_style = self.project.read(cx).path_style(cx);
2016        maybe!({
2017            let list_entry = self.entries.get(self.selected_entry?)?.clone();
2018            let entry = list_entry.status_entry()?.to_owned();
2019            let skip_prompt = action.skip_prompt || entry.status.is_created();
2020
2021            let prompt = if skip_prompt {
2022                Task::ready(Ok(0))
2023            } else {
2024                let prompt = window.prompt(
2025                    PromptLevel::Warning,
2026                    &format!(
2027                        "Are you sure you want to discard changes to {}?",
2028                        MarkdownInlineCode(
2029                            entry
2030                                .repo_path
2031                                .file_name()
2032                                .unwrap_or(entry.repo_path.display(path_style).as_ref())
2033                        ),
2034                    ),
2035                    None,
2036                    &["Discard Changes", "Cancel"],
2037                    cx,
2038                );
2039                cx.background_spawn(prompt)
2040            };
2041
2042            let this = cx.weak_entity();
2043            window
2044                .spawn(cx, async move |cx| {
2045                    if prompt.await? != 0 {
2046                        return anyhow::Ok(());
2047                    }
2048
2049                    this.update_in(cx, |this, window, cx| {
2050                        this.revert_entry(&entry, window, cx);
2051                    })?;
2052
2053                    Ok(())
2054                })
2055                .detach();
2056            Some(())
2057        });
2058    }
2059
2060    fn add_to_gitignore(
2061        &mut self,
2062        _: &git::AddToGitignore,
2063        _window: &mut Window,
2064        cx: &mut Context<Self>,
2065    ) {
2066        maybe!({
2067            let list_entry = self.entries.get(self.selected_entry?)?.clone();
2068            let entry = list_entry.status_entry()?.to_owned();
2069
2070            if !entry.status.is_created() {
2071                return Some(());
2072            }
2073
2074            let active_repository = self.active_repository.clone()?;
2075            let workspace = self.workspace.clone();
2076            let repo_path = entry.repo_path;
2077
2078            let receiver = active_repository
2079                .update(cx, |repo, _| repo.add_path_to_gitignore(&repo_path, false));
2080
2081            cx.spawn(async move |_, cx| {
2082                if let Err(e) = receiver.await? {
2083                    if let Some(workspace) = workspace.upgrade() {
2084                        cx.update(|cx| {
2085                            show_error_toast(workspace, "add to .gitignore", e, cx);
2086                        });
2087                    }
2088                }
2089                anyhow::Ok(())
2090            })
2091            .detach_and_log_err(cx);
2092
2093            Some(())
2094        });
2095    }
2096
2097    fn add_to_git_info_exclude(
2098        &mut self,
2099        _: &git::AddToGitInfoExclude,
2100        _window: &mut Window,
2101        cx: &mut Context<Self>,
2102    ) {
2103        maybe!({
2104            let list_entry = self.entries.get(self.selected_entry?)?.clone();
2105            let entry = list_entry.status_entry()?.to_owned();
2106
2107            if !entry.status.is_created() {
2108                return Some(());
2109            }
2110
2111            let active_repository = self.active_repository.clone()?;
2112            let workspace = self.workspace.clone();
2113            let repo_path = entry.repo_path;
2114
2115            let receiver = active_repository.update(cx, |repo, _| {
2116                repo.add_path_to_git_info_exclude(&repo_path, false)
2117            });
2118
2119            cx.spawn(async move |_, cx| {
2120                if let Err(e) = receiver.await? {
2121                    if let Some(workspace) = workspace.upgrade() {
2122                        cx.update(|cx| {
2123                            show_error_toast(workspace, "add to .git/info/exclude", e, cx);
2124                        });
2125                    }
2126                }
2127                anyhow::Ok(())
2128            })
2129            .detach_and_log_err(cx);
2130
2131            Some(())
2132        });
2133    }
2134
2135    fn revert_entry(
2136        &mut self,
2137        entry: &GitStatusEntry,
2138        window: &mut Window,
2139        cx: &mut Context<Self>,
2140    ) {
2141        maybe!({
2142            let active_repo = self.active_repository.clone()?;
2143            let path = active_repo
2144                .read(cx)
2145                .repo_path_to_project_path(&entry.repo_path, cx)?;
2146            let workspace = self.workspace.clone();
2147
2148            if entry.status.staging().has_staged() {
2149                self.change_file_stage(false, vec![entry.clone()], cx);
2150            }
2151            let filename = path.path.file_name()?.to_string();
2152
2153            if !entry.status.is_created() {
2154                self.perform_checkout(vec![entry.clone()], window, cx);
2155            } else {
2156                let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
2157                cx.spawn_in(window, async move |_, cx| {
2158                    match prompt.await? {
2159                        TrashCancel::Trash => {}
2160                        TrashCancel::Cancel => return Ok(()),
2161                    }
2162                    let task = workspace.update(cx, |workspace, cx| {
2163                        workspace
2164                            .project()
2165                            .update(cx, |project, cx| project.trash_file(path, cx))
2166                    })?;
2167                    if let Some(task) = task {
2168                        task.await?;
2169                    }
2170                    Ok(())
2171                })
2172                .detach_and_prompt_err(
2173                    "Failed to trash file",
2174                    window,
2175                    cx,
2176                    |e, _, _| Some(format!("{e}")),
2177                );
2178            }
2179            Some(())
2180        });
2181    }
2182
2183    fn perform_checkout(
2184        &mut self,
2185        entries: Vec<GitStatusEntry>,
2186        window: &mut Window,
2187        cx: &mut Context<Self>,
2188    ) {
2189        let workspace = self.workspace.clone();
2190        let Some(active_repository) = self.active_repository.clone() else {
2191            return;
2192        };
2193
2194        let task = cx.spawn_in(window, async move |this, cx| {
2195            let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
2196                workspace.project().update(cx, |project, cx| {
2197                    entries
2198                        .iter()
2199                        .filter_map(|entry| {
2200                            let path = active_repository
2201                                .read(cx)
2202                                .repo_path_to_project_path(&entry.repo_path, cx)?;
2203                            Some(project.open_buffer(path, cx))
2204                        })
2205                        .collect()
2206                })
2207            })?;
2208
2209            let buffers = futures::future::join_all(tasks).await;
2210
2211            this.update_in(cx, |this, window, cx| {
2212                let task = active_repository.update(cx, |repo, cx| {
2213                    repo.checkout_files(
2214                        "HEAD",
2215                        entries
2216                            .into_iter()
2217                            .map(|entries| entries.repo_path)
2218                            .collect(),
2219                        cx,
2220                    )
2221                });
2222                this.update_visible_entries(window, cx);
2223                cx.notify();
2224                task
2225            })?
2226            .await?;
2227
2228            let tasks: Vec<_> = cx.update(|_, cx| {
2229                buffers
2230                    .iter()
2231                    .filter_map(|buffer| {
2232                        buffer.as_ref().ok()?.update(cx, |buffer, cx| {
2233                            buffer.is_dirty().then(|| buffer.reload(cx))
2234                        })
2235                    })
2236                    .collect()
2237            })?;
2238
2239            futures::future::join_all(tasks).await;
2240
2241            Ok(())
2242        });
2243
2244        cx.spawn_in(window, async move |this, cx| {
2245            let result = task.await;
2246
2247            this.update_in(cx, |this, window, cx| {
2248                if let Err(err) = result {
2249                    this.update_visible_entries(window, cx);
2250                    this.show_error_toast("checkout", err, cx);
2251                }
2252            })
2253            .ok();
2254        })
2255        .detach();
2256    }
2257
2258    fn restore_tracked_files(
2259        &mut self,
2260        _: &RestoreTrackedFiles,
2261        window: &mut Window,
2262        cx: &mut Context<Self>,
2263    ) {
2264        let entries = self
2265            .change_entries_by_path()
2266            .filter(|status_entry| !status_entry.status.is_created())
2267            .cloned()
2268            .collect::<Vec<_>>();
2269
2270        match entries.len() {
2271            0 => return,
2272            1 => return self.revert_entry(&entries[0], window, cx),
2273            _ => {}
2274        }
2275        let mut details = entries
2276            .iter()
2277            .filter_map(|entry| entry.repo_path.as_ref().file_name())
2278            .map(|filename| filename.to_string())
2279            .take(5)
2280            .join("\n");
2281        if entries.len() > 5 {
2282            details.push_str(&format!("\nand {} more…", entries.len() - 5))
2283        }
2284
2285        #[derive(strum::EnumIter, strum::VariantNames)]
2286        #[strum(serialize_all = "title_case")]
2287        enum RestoreCancel {
2288            RestoreTrackedFiles,
2289            Cancel,
2290        }
2291        let prompt = prompt(
2292            "Discard changes to these files?",
2293            Some(&details),
2294            window,
2295            cx,
2296        );
2297        cx.spawn_in(window, async move |this, cx| {
2298            if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
2299                this.update_in(cx, |this, window, cx| {
2300                    this.perform_checkout(entries, window, cx);
2301                })
2302                .ok();
2303            }
2304        })
2305        .detach();
2306    }
2307
2308    fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
2309        let workspace = self.workspace.clone();
2310        let Some(active_repo) = self.active_repository.clone() else {
2311            return;
2312        };
2313        let to_delete = self
2314            .change_entries_by_path()
2315            .filter(|status_entry| status_entry.status.is_created())
2316            .cloned()
2317            .collect::<Vec<_>>();
2318
2319        match to_delete.len() {
2320            0 => return,
2321            1 => return self.revert_entry(&to_delete[0], window, cx),
2322            _ => {}
2323        };
2324
2325        let mut details = to_delete
2326            .iter()
2327            .map(|entry| {
2328                entry
2329                    .repo_path
2330                    .as_ref()
2331                    .file_name()
2332                    .map(|f| f.to_string())
2333                    .unwrap_or_default()
2334            })
2335            .take(5)
2336            .join("\n");
2337
2338        if to_delete.len() > 5 {
2339            details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
2340        }
2341
2342        let prompt = prompt("Trash these files?", Some(&details), window, cx);
2343        cx.spawn_in(window, async move |this, cx| {
2344            match prompt.await? {
2345                TrashCancel::Trash => {}
2346                TrashCancel::Cancel => return Ok(()),
2347            }
2348            let tasks = workspace.update(cx, |workspace, cx| {
2349                to_delete
2350                    .iter()
2351                    .filter_map(|entry| {
2352                        workspace.project().update(cx, |project, cx| {
2353                            let project_path = active_repo
2354                                .read(cx)
2355                                .repo_path_to_project_path(&entry.repo_path, cx)?;
2356                            project.trash_file(project_path, cx)
2357                        })
2358                    })
2359                    .collect::<Vec<_>>()
2360            })?;
2361            let to_unstage = to_delete
2362                .into_iter()
2363                .filter(|entry| !entry.status.staging().is_fully_unstaged())
2364                .collect();
2365            this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
2366            for task in tasks {
2367                task.await?;
2368            }
2369            Ok(())
2370        })
2371        .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
2372            Some(format!("{e}"))
2373        });
2374    }
2375
2376    fn change_all_files_stage(&mut self, stage: bool, cx: &mut Context<Self>) {
2377        let Some(active_repository) = self.active_repository.clone() else {
2378            return;
2379        };
2380        cx.spawn({
2381            async move |this, cx| {
2382                let result = this
2383                    .update(cx, |_this, cx| {
2384                        active_repository.update(cx, |repo, cx| {
2385                            if stage {
2386                                repo.stage_all(cx)
2387                            } else {
2388                                repo.unstage_all(cx)
2389                            }
2390                        })
2391                    })?
2392                    .await;
2393
2394                this.update(cx, |this, cx| {
2395                    if let Err(err) = result {
2396                        this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
2397                    }
2398                    this.update_counts(active_repository.read(cx));
2399                    cx.notify()
2400                })
2401            }
2402        })
2403        .detach();
2404    }
2405
2406    fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus {
2407        // Checking for current staged/unstaged file status is a chained operation:
2408        // 1. first, we check for any pending operation recorded in repository
2409        // 2. if there are no pending ops either running or finished, we then ask the repository
2410        //    for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry`
2411        //    is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on
2412        //    the checkbox's state (or flickering) which is undesirable.
2413        // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded
2414        //    in `entry` arg.
2415        repo.pending_ops_for_path(&entry.repo_path)
2416            .and_then(|ops| {
2417                // In case the last operation in the list of pending operations
2418                // failed, we can't assume the stage status for this entry and
2419                // need to fallback to the actual state in the repo.
2420                if ops.last_op_errored() {
2421                    return None;
2422                }
2423
2424                if ops.staging() || ops.staged() {
2425                    Some(StageStatus::Staged)
2426                } else {
2427                    Some(StageStatus::Unstaged)
2428                }
2429            })
2430            .or_else(|| {
2431                repo.status_for_path(&entry.repo_path)
2432                    .map(|status| status.status.staging())
2433            })
2434            .unwrap_or(entry.staging)
2435    }
2436
2437    fn stage_status_for_directory(
2438        &self,
2439        entry: &GitTreeDirEntry,
2440        repo: &Repository,
2441    ) -> StageStatus {
2442        let GitPanelViewMode::Tree(tree_state) = &self.view_mode else {
2443            util::debug_panic!("We should never render a directory entry while in flat view mode");
2444            return StageStatus::Unstaged;
2445        };
2446
2447        let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else {
2448            return StageStatus::Unstaged;
2449        };
2450
2451        let show_placeholders = self.show_placeholders && !self.has_staged_changes();
2452        let mut fully_staged_count = 0usize;
2453        let mut any_staged_or_partially_staged = false;
2454
2455        for descendant in descendants {
2456            if show_placeholders && !descendant.status.is_created() {
2457                fully_staged_count += 1;
2458                any_staged_or_partially_staged = true;
2459            } else {
2460                match GitPanel::stage_status_for_entry(descendant, repo) {
2461                    StageStatus::Staged => {
2462                        fully_staged_count += 1;
2463                        any_staged_or_partially_staged = true;
2464                    }
2465                    StageStatus::PartiallyStaged => {
2466                        any_staged_or_partially_staged = true;
2467                    }
2468                    StageStatus::Unstaged => {}
2469                }
2470            }
2471        }
2472
2473        if descendants.is_empty() {
2474            StageStatus::Unstaged
2475        } else if fully_staged_count == descendants.len() {
2476            StageStatus::Staged
2477        } else if any_staged_or_partially_staged {
2478            StageStatus::PartiallyStaged
2479        } else {
2480            StageStatus::Unstaged
2481        }
2482    }
2483
2484    pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
2485        self.change_all_files_stage(true, cx);
2486    }
2487
2488    pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
2489        self.change_all_files_stage(false, cx);
2490    }
2491
2492    fn toggle_staged_for_entry(
2493        &mut self,
2494        entry: &GitListEntry,
2495        intent: StageIntent,
2496        _window: &mut Window,
2497        cx: &mut Context<Self>,
2498    ) {
2499        let Some(active_repository) = self.active_repository.clone() else {
2500            return;
2501        };
2502        let mut set_anchor: Option<RepoPath> = None;
2503        let mut clear_anchor = None;
2504
2505        let (stage, repo_paths) = {
2506            let repo = active_repository.read(cx);
2507            match entry {
2508                GitListEntry::Status(status_entry)
2509                | GitListEntry::TreeStatus(GitTreeStatusEntry {
2510                    entry: status_entry,
2511                    ..
2512                }) => {
2513                    let repo_paths = vec![status_entry.clone()];
2514                    let stage = intent
2515                        .resolve_with(|| GitPanel::stage_status_for_entry(status_entry, &repo));
2516
2517                    if stage {
2518                        set_anchor = Some(status_entry.repo_path.clone());
2519                    } else if let Some(op) = self.bulk_staging.clone()
2520                        && op.anchor == status_entry.repo_path
2521                    {
2522                        clear_anchor = Some(op.anchor);
2523                    }
2524                    (stage, repo_paths)
2525                }
2526                GitListEntry::Header(section) => {
2527                    let goal_staged_state = match intent {
2528                        StageIntent::Stage => true,
2529                        StageIntent::Unstage => false,
2530                        StageIntent::Toggle => !self.header_state(section.header).selected(),
2531                    };
2532                    let entries = self
2533                        .change_entries_by_path()
2534                        .filter(|status_entry| {
2535                            section.contains(status_entry, &repo)
2536                                && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool()
2537                                    != Some(goal_staged_state)
2538                        })
2539                        .cloned()
2540                        .collect::<Vec<_>>();
2541
2542                    (goal_staged_state, entries)
2543                }
2544                GitListEntry::Directory(entry) => {
2545                    let goal_stage =
2546                        intent.resolve_with(|| self.stage_status_for_directory(entry, repo));
2547                    let goal_staged_state = if goal_stage {
2548                        StageStatus::Staged
2549                    } else {
2550                        StageStatus::Unstaged
2551                    };
2552
2553                    let entries = self
2554                        .view_mode
2555                        .tree_state()
2556                        .and_then(|state| state.directory_descendants.get(&entry.key))
2557                        .cloned()
2558                        .unwrap_or_default()
2559                        .into_iter()
2560                        .filter(|status_entry| {
2561                            GitPanel::stage_status_for_entry(status_entry, &repo)
2562                                != goal_staged_state
2563                        })
2564                        .collect::<Vec<_>>();
2565                    (goal_stage, entries)
2566                }
2567                GitListEntry::EmptySection(_) => return,
2568            }
2569        };
2570        if let Some(anchor) = clear_anchor {
2571            if let Some(op) = self.bulk_staging.clone()
2572                && op.anchor == anchor
2573            {
2574                self.bulk_staging = None;
2575            }
2576        }
2577        if let Some(anchor) = set_anchor {
2578            self.set_bulk_staging_anchor(anchor, cx);
2579        }
2580
2581        self.change_file_stage(stage, repo_paths, cx);
2582    }
2583
2584    fn change_file_stage(
2585        &mut self,
2586        stage: bool,
2587        entries: Vec<GitStatusEntry>,
2588        cx: &mut Context<Self>,
2589    ) {
2590        let Some(active_repository) = self.active_repository.clone() else {
2591            return;
2592        };
2593        cx.spawn({
2594            async move |this, cx| {
2595                let result = this
2596                    .update(cx, |this, cx| {
2597                        let task = active_repository.update(cx, |repo, cx| {
2598                            let repo_paths = entries
2599                                .iter()
2600                                .map(|entry| entry.repo_path.clone())
2601                                .unique()
2602                                .collect();
2603                            if stage {
2604                                repo.stage_entries(repo_paths, cx)
2605                            } else {
2606                                repo.unstage_entries(repo_paths, cx)
2607                            }
2608                        });
2609                        this.update_counts(active_repository.read(cx));
2610                        cx.notify();
2611                        task
2612                    })?
2613                    .await;
2614
2615                this.update(cx, |this, cx| {
2616                    if let Err(err) = result {
2617                        this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
2618                        this.update_counts(active_repository.read(cx));
2619                    }
2620                    cx.notify();
2621                })
2622            }
2623        })
2624        .detach();
2625    }
2626
2627    pub fn total_staged_count(&self) -> usize {
2628        self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
2629    }
2630
2631    pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
2632        let Some(active_repository) = self.active_repository.clone() else {
2633            return;
2634        };
2635
2636        cx.spawn({
2637            async move |this, cx| {
2638                let stash_task = active_repository
2639                    .update(cx, |repo, cx| repo.stash_pop(None, cx))
2640                    .await;
2641                this.update(cx, |this, cx| {
2642                    stash_task
2643                        .map_err(|e| {
2644                            this.show_error_toast("stash pop", e, cx);
2645                        })
2646                        .ok();
2647                    cx.notify();
2648                })
2649            }
2650        })
2651        .detach();
2652    }
2653
2654    pub fn stash_apply(&mut self, _: &StashApply, _window: &mut Window, cx: &mut Context<Self>) {
2655        let Some(active_repository) = self.active_repository.clone() else {
2656            return;
2657        };
2658
2659        cx.spawn({
2660            async move |this, cx| {
2661                let stash_task = active_repository
2662                    .update(cx, |repo, cx| repo.stash_apply(None, cx))
2663                    .await;
2664                this.update(cx, |this, cx| {
2665                    stash_task
2666                        .map_err(|e| {
2667                            this.show_error_toast("stash apply", e, cx);
2668                        })
2669                        .ok();
2670                    cx.notify();
2671                })
2672            }
2673        })
2674        .detach();
2675    }
2676
2677    pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
2678        let Some(active_repository) = self.active_repository.clone() else {
2679            return;
2680        };
2681
2682        cx.spawn({
2683            async move |this, cx| {
2684                let stash_task = active_repository
2685                    .update(cx, |repo, cx| repo.stash_all(cx))
2686                    .await;
2687                this.update(cx, |this, cx| {
2688                    stash_task
2689                        .map_err(|e| {
2690                            this.show_error_toast("stash", e, cx);
2691                        })
2692                        .ok();
2693                    cx.notify();
2694                })
2695            }
2696        })
2697        .detach();
2698    }
2699
2700    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
2701        self.commit_editor
2702            .read(cx)
2703            .buffer()
2704            .read(cx)
2705            .as_singleton()
2706            .unwrap()
2707    }
2708
2709    fn toggle_staged_for_selected(
2710        &mut self,
2711        _: &git::ToggleStaged,
2712        window: &mut Window,
2713        cx: &mut Context<Self>,
2714    ) {
2715        let Some(selected_index) = self.selected_entry else {
2716            return;
2717        };
2718        let Some(selected_entry) = self.entries.get(selected_index).cloned() else {
2719            return;
2720        };
2721
2722        if self.is_resolved_conflict(selected_index, cx) {
2723            return;
2724        }
2725
2726        let intent = self.stage_intent_for_entry_index(selected_index);
2727        self.toggle_staged_for_entry(&selected_entry, intent, window, cx);
2728    }
2729
2730    fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
2731        let Some(index) = self.selected_entry else {
2732            return;
2733        };
2734        let stage = self.stage_intent_for_entry_index(index) != StageIntent::Unstage;
2735        self.stage_bulk(index, stage, cx);
2736    }
2737
2738    fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
2739        let Some(selected_entry) = self.get_selected_entry() else {
2740            return;
2741        };
2742        let Some(status_entry) = selected_entry.status_entry() else {
2743            return;
2744        };
2745        if status_entry.staging != StageStatus::Staged {
2746            self.change_file_stage(true, vec![status_entry.clone()], cx);
2747        }
2748    }
2749
2750    fn unstage_selected(
2751        &mut self,
2752        _: &git::UnstageFile,
2753        _window: &mut Window,
2754        cx: &mut Context<Self>,
2755    ) {
2756        let Some(selected_entry) = self.get_selected_entry() else {
2757            return;
2758        };
2759        let Some(status_entry) = selected_entry.status_entry() else {
2760            return;
2761        };
2762        if status_entry.staging != StageStatus::Unstaged {
2763            self.change_file_stage(false, vec![status_entry.clone()], cx);
2764        }
2765    }
2766
2767    fn on_commit(&mut self, _: &Commit, window: &mut Window, cx: &mut Context<Self>) {
2768        let is_amend = self.amend_pending;
2769        if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
2770            if is_amend {
2771                telemetry::event!("Git Amended", source = "Git Panel");
2772            } else {
2773                telemetry::event!("Git Committed", source = "Git Panel");
2774            }
2775        }
2776    }
2777
2778    /// Commits staged changes with the current commit message.
2779    /// When `amend_pending` is true, performs an amend commit instead.
2780    ///
2781    /// Returns `true` if the commit was executed, `false` otherwise.
2782    pub(crate) fn commit(
2783        &mut self,
2784        commit_editor_focus_handle: &FocusHandle,
2785        window: &mut Window,
2786        cx: &mut Context<Self>,
2787    ) -> bool {
2788        if commit_editor_focus_handle.contains_focused(window, cx) {
2789            let options = self.commit_options();
2790            self.commit_changes(options, window, cx);
2791            true
2792        } else {
2793            cx.propagate();
2794            false
2795        }
2796    }
2797
2798    fn on_amend(&mut self, _: &Amend, window: &mut Window, cx: &mut Context<Self>) {
2799        if self.amend(&self.commit_editor.focus_handle(cx), window, cx) {
2800            telemetry::event!("Git Amended", source = "Git Panel");
2801        }
2802    }
2803
2804    /// Enters the amend state on first invocation, loading the last commit
2805    /// message for editing. On second invocation, performs the amend commit
2806    /// by delegating to [`Self::commit`]. Returns `true` if a commit was
2807    /// executed.
2808    pub(crate) fn amend(
2809        &mut self,
2810        commit_editor_focus_handle: &FocusHandle,
2811        window: &mut Window,
2812        cx: &mut Context<Self>,
2813    ) -> bool {
2814        if commit_editor_focus_handle.contains_focused(window, cx) {
2815            if self.head_commit(cx).is_some() {
2816                if !self.amend_pending {
2817                    self.toggle_amend_pending(cx);
2818                } else {
2819                    return self.commit(commit_editor_focus_handle, window, cx);
2820                }
2821            }
2822            false
2823        } else {
2824            cx.propagate();
2825            false
2826        }
2827    }
2828    pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
2829        self.active_repository
2830            .as_ref()
2831            .and_then(|repo| repo.read(cx).head_commit.as_ref())
2832            .cloned()
2833    }
2834
2835    pub fn load_last_commit_message(&mut self, cx: &mut Context<Self>) {
2836        let Some(head_commit) = self.head_commit(cx) else {
2837            return;
2838        };
2839
2840        let recent_sha = head_commit.sha.to_string();
2841        let detail_task = self.load_commit_details(recent_sha, cx);
2842        cx.spawn(async move |this, cx| {
2843            if let Ok(message) = detail_task.await.map(|detail| detail.message) {
2844                this.update(cx, |this, cx| {
2845                    this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2846                        let start = buffer.anchor_before(0);
2847                        let end = buffer.anchor_after(buffer.len());
2848                        buffer.edit([(start..end, message)], None, cx);
2849                    });
2850                })
2851                .log_err();
2852            }
2853        })
2854        .detach();
2855    }
2856
2857    fn custom_or_suggested_commit_message(
2858        &self,
2859        window: &mut Window,
2860        cx: &mut Context<Self>,
2861    ) -> Option<String> {
2862        let git_commit_language = self
2863            .commit_editor
2864            .read(cx)
2865            .language_at(MultiBufferOffset(0), cx);
2866        let message = self.commit_editor.read(cx).text(cx);
2867        if message.is_empty() {
2868            return self
2869                .suggest_commit_message(cx)
2870                .filter(|message| !message.trim().is_empty());
2871        } else if message.trim().is_empty() {
2872            return None;
2873        }
2874        let buffer = cx.new(|cx| {
2875            let mut buffer = Buffer::local(message, cx);
2876            buffer.set_language(git_commit_language, cx);
2877            buffer
2878        });
2879        let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
2880        let wrapped_message = editor.update(cx, |editor, cx| {
2881            editor.select_all(&Default::default(), window, cx);
2882            editor.rewrap(
2883                RewrapOptions {
2884                    override_language_settings: false,
2885                    preserve_existing_whitespace: true,
2886                    line_length: None,
2887                },
2888                cx,
2889            );
2890            editor.text(cx)
2891        });
2892        if wrapped_message.trim().is_empty() {
2893            return None;
2894        }
2895        Some(wrapped_message)
2896    }
2897
2898    fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
2899        let text = self.commit_editor.read(cx).text(cx);
2900        if !text.trim().is_empty() {
2901            true
2902        } else if text.is_empty() {
2903            self.suggest_commit_message(cx)
2904                .is_some_and(|text| !text.trim().is_empty())
2905        } else {
2906            false
2907        }
2908    }
2909
2910    pub(crate) fn commit_options(&self) -> CommitOptions {
2911        CommitOptions {
2912            amend: self.amend_pending,
2913            signoff: self.signoff_enabled,
2914            allow_empty: false,
2915            no_verify: self.skip_hooks_enabled,
2916        }
2917    }
2918
2919    pub(crate) fn commit_changes(
2920        &mut self,
2921        options: CommitOptions,
2922        window: &mut Window,
2923        cx: &mut Context<Self>,
2924    ) {
2925        let Some(active_repository) = self.active_repository.clone() else {
2926            return;
2927        };
2928        let error_spawn = |message, window: &mut Window, cx: &mut App| {
2929            let prompt = window.prompt(PromptLevel::Warning, message, None, &["OK"], cx);
2930            cx.spawn(async move |_| {
2931                prompt.await.ok();
2932            })
2933            .detach();
2934        };
2935
2936        if self.has_unstaged_conflicts() {
2937            error_spawn(
2938                "There are still conflicts. You must stage these before committing",
2939                window,
2940                cx,
2941            );
2942            return;
2943        }
2944
2945        let askpass = self.askpass_delegate("git commit", window, cx);
2946        let commit_message = self.custom_or_suggested_commit_message(window, cx);
2947
2948        let Some(mut message) = commit_message else {
2949            self.commit_editor
2950                .read(cx)
2951                .focus_handle(cx)
2952                .focus(window, cx);
2953            return;
2954        };
2955
2956        if self.add_coauthors {
2957            self.fill_co_authors(&mut message, cx);
2958        }
2959
2960        let task = if self.has_staged_changes() {
2961            // Repository serializes all git operations, so we can just send a commit immediately
2962            let commit_task = active_repository.update(cx, |repo, cx| {
2963                repo.commit(message.into(), None, options, askpass, cx)
2964            });
2965            cx.background_spawn(async move { commit_task.await? })
2966        } else {
2967            let changed_files = self
2968                .change_entries_by_path()
2969                .filter(|status_entry| !status_entry.status.is_created())
2970                .map(|status_entry| status_entry.repo_path.clone())
2971                .collect::<Vec<_>>();
2972
2973            if changed_files.is_empty() && !options.amend {
2974                error_spawn("No changes to commit", window, cx);
2975                return;
2976            }
2977
2978            let stage_task =
2979                active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
2980            cx.spawn(async move |_, cx| {
2981                stage_task.await?;
2982                let commit_task = active_repository.update(cx, |repo, cx| {
2983                    repo.commit(message.into(), None, options, askpass, cx)
2984                });
2985                commit_task.await?
2986            })
2987        };
2988        let task = cx.spawn_in(window, async move |this, cx| {
2989            let result = task.await;
2990            this.update_in(cx, |this, window, cx| {
2991                this.pending_commit.take();
2992
2993                match result {
2994                    Ok(()) => {
2995                        this.set_skip_hooks_enabled(false, cx);
2996                        if options.amend {
2997                            this.set_amend_pending(false, cx);
2998                        } else {
2999                            this.commit_editor
3000                                .update(cx, |editor, cx| editor.clear(window, cx));
3001                            this.original_commit_message = None;
3002                            this.serialize(cx);
3003                        }
3004                    }
3005                    Err(e) => this.show_error_toast("commit", e, cx),
3006                }
3007            })
3008            .ok();
3009        });
3010
3011        self.pending_commit = Some(task);
3012    }
3013
3014    pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3015        let Some(repo) = self.active_repository.clone() else {
3016            return;
3017        };
3018        telemetry::event!("Git Uncommitted");
3019
3020        let confirmation = self.check_for_pushed_commits(window, cx);
3021        let prior_head = self.load_commit_details("HEAD".to_string(), cx);
3022
3023        let task = cx.spawn_in(window, async move |this, cx| {
3024            let result = maybe!(async {
3025                if let Ok(true) = confirmation.await {
3026                    let prior_head = prior_head.await?;
3027
3028                    repo.update(cx, |repo, cx| {
3029                        repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
3030                    })
3031                    .await??;
3032
3033                    Ok(Some(prior_head))
3034                } else {
3035                    Ok(None)
3036                }
3037            })
3038            .await;
3039
3040            this.update_in(cx, |this, window, cx| {
3041                this.pending_commit.take();
3042                match result {
3043                    Ok(None) => {}
3044                    Ok(Some(prior_commit)) => {
3045                        this.commit_editor.update(cx, |editor, cx| {
3046                            editor.set_text(prior_commit.message, window, cx)
3047                        });
3048                    }
3049                    Err(e) => this.show_error_toast("reset", e, cx),
3050                }
3051            })
3052            .ok();
3053        });
3054
3055        self.pending_commit = Some(task);
3056    }
3057
3058    fn check_for_pushed_commits(
3059        &mut self,
3060        window: &mut Window,
3061        cx: &mut Context<Self>,
3062    ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
3063        let repo = self.active_repository.clone();
3064        let mut cx = window.to_async(cx);
3065
3066        async move {
3067            let repo = repo.context("No active repository")?;
3068
3069            let pushed_to: Vec<SharedString> = repo
3070                .update(&mut cx, |repo, _| repo.check_for_pushed_commits())
3071                .await??;
3072
3073            if pushed_to.is_empty() {
3074                Ok(true)
3075            } else {
3076                #[derive(strum::EnumIter, strum::VariantNames)]
3077                #[strum(serialize_all = "title_case")]
3078                enum CancelUncommit {
3079                    Uncommit,
3080                    Cancel,
3081                }
3082                let detail = format!(
3083                    "This commit was already pushed to {}.",
3084                    pushed_to.into_iter().join(", ")
3085                );
3086                let result = cx
3087                    .update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
3088                    .await?;
3089
3090                match result {
3091                    CancelUncommit::Cancel => Ok(false),
3092                    CancelUncommit::Uncommit => Ok(true),
3093                }
3094            }
3095        }
3096    }
3097
3098    /// Suggests a commit message based on the changed files and their statuses
3099    pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
3100        if let Some(merge_message) = self
3101            .active_repository
3102            .as_ref()
3103            .and_then(|repo| repo.read(cx).merge.message.as_ref())
3104        {
3105            return Some(merge_message.to_string());
3106        }
3107
3108        let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
3109            Some(staged_entry)
3110        } else if self.total_staged_count() == 0
3111            && let Some(single_tracked_entry) = &self.single_tracked_entry
3112        {
3113            Some(single_tracked_entry)
3114        } else {
3115            None
3116        }?;
3117
3118        let action_text = if git_status_entry.status.is_deleted() {
3119            Some("Delete")
3120        } else if git_status_entry.status.is_created() {
3121            Some("Create")
3122        } else if git_status_entry.status.is_modified() {
3123            Some("Update")
3124        } else {
3125            None
3126        }?;
3127
3128        let file_name = git_status_entry
3129            .repo_path
3130            .file_name()
3131            .unwrap_or_default()
3132            .to_string();
3133
3134        Some(format!("{} {}", action_text, file_name))
3135    }
3136
3137    fn generate_commit_message_action(
3138        &mut self,
3139        _: &git::GenerateCommitMessage,
3140        _window: &mut Window,
3141        cx: &mut Context<Self>,
3142    ) {
3143        self.generate_commit_message(cx);
3144    }
3145
3146    fn split_patch(patch: &str) -> Vec<String> {
3147        let mut result = Vec::new();
3148        let mut current_patch = String::new();
3149
3150        for line in patch.lines() {
3151            if line.starts_with("---") && !current_patch.is_empty() {
3152                result.push(current_patch.trim_end_matches('\n').into());
3153                current_patch = String::new();
3154            }
3155            current_patch.push_str(line);
3156            current_patch.push('\n');
3157        }
3158
3159        if !current_patch.is_empty() {
3160            result.push(current_patch.trim_end_matches('\n').into());
3161        }
3162
3163        result
3164    }
3165    fn truncate_iteratively(patch: &str, max_bytes: usize) -> String {
3166        let mut current_size = patch.len();
3167        if current_size <= max_bytes {
3168            return patch.to_string();
3169        }
3170        let file_patches = Self::split_patch(patch);
3171        let mut file_infos: Vec<TruncatedPatch> = file_patches
3172            .iter()
3173            .filter_map(|patch| TruncatedPatch::from_unified_diff(patch))
3174            .collect();
3175
3176        if file_infos.is_empty() {
3177            return patch.to_string();
3178        }
3179
3180        current_size = file_infos.iter().map(|f| f.calculate_size()).sum::<usize>();
3181        while current_size > max_bytes {
3182            let file_idx = file_infos
3183                .iter()
3184                .enumerate()
3185                .filter(|(_, f)| f.hunks_to_keep > 1)
3186                .max_by_key(|(_, f)| f.hunks_to_keep)
3187                .map(|(idx, _)| idx);
3188            match file_idx {
3189                Some(idx) => {
3190                    let file = &mut file_infos[idx];
3191                    let size_before = file.calculate_size();
3192                    file.hunks_to_keep -= 1;
3193                    let size_after = file.calculate_size();
3194                    let saved = size_before.saturating_sub(size_after);
3195                    current_size = current_size.saturating_sub(saved);
3196                }
3197                None => {
3198                    break;
3199                }
3200            }
3201        }
3202
3203        file_infos
3204            .iter()
3205            .map(|info| info.to_string())
3206            .collect::<Vec<_>>()
3207            .join("\n")
3208    }
3209
3210    pub fn compress_commit_diff(diff_text: &str, max_bytes: usize) -> String {
3211        if diff_text.len() <= max_bytes {
3212            return diff_text.to_string();
3213        }
3214
3215        let mut compressed = diff_text
3216            .lines()
3217            .map(|line| {
3218                if line.len() > 256 {
3219                    format!("{}...[truncated]\n", &line[..line.floor_char_boundary(256)])
3220                } else {
3221                    format!("{}\n", line)
3222                }
3223            })
3224            .collect::<Vec<_>>()
3225            .concat();
3226
3227        if compressed.len() <= max_bytes {
3228            return compressed;
3229        }
3230
3231        compressed = Self::truncate_iteratively(&compressed, max_bytes);
3232
3233        compressed
3234    }
3235
3236    async fn load_project_rules(
3237        project: &Entity<Project>,
3238        repo_work_dir: &Arc<Path>,
3239        cx: &mut AsyncApp,
3240    ) -> Option<String> {
3241        let rules_path = cx.update(|cx| {
3242            for worktree in project.read(cx).worktrees(cx) {
3243                let worktree_abs_path = worktree.read(cx).abs_path();
3244                if !worktree_abs_path.starts_with(&repo_work_dir) {
3245                    continue;
3246                }
3247
3248                let worktree_snapshot = worktree.read(cx).snapshot();
3249                for rules_name in RULES_FILE_NAMES {
3250                    if let Ok(rel_path) = RelPath::from_unix_str(rules_name) {
3251                        if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) {
3252                            if entry.is_file() {
3253                                return Some(ProjectPath {
3254                                    worktree_id: worktree.read(cx).id(),
3255                                    path: entry.path.clone(),
3256                                });
3257                            }
3258                        }
3259                    }
3260                }
3261            }
3262            None
3263        })?;
3264
3265        let buffer = project
3266            .update(cx, |project, cx| project.open_buffer(rules_path, cx))
3267            .await
3268            .ok()?;
3269
3270        let content = buffer
3271            .read_with(cx, |buffer, _| buffer.text())
3272            .trim()
3273            .to_string();
3274
3275        if content.is_empty() {
3276            None
3277        } else {
3278            Some(content)
3279        }
3280    }
3281
3282    fn build_commit_message_prompt(
3283        prompt: &str,
3284        user_agents_md: Option<&str>,
3285        rules_content: Option<&str>,
3286        instructions: Option<&str>,
3287        subject: &str,
3288        diff_text: &str,
3289    ) -> String {
3290        let user_agents_md_section = match user_agents_md {
3291            Some(user_agents_md) => format!(
3292                "\n\nThe user has provided the following rules that you should follow when writing the commit message. Project-specific rules may override these instructions when they conflict:\n\
3293                <rules>\n{user_agents_md}\n</rules>\n"
3294            ),
3295            None => String::new(),
3296        };
3297
3298        let rules_section = match rules_content {
3299            Some(rules) => format!(
3300                "\n\nThe user has provided the following rules specific to this project that you should follow when writing the commit message:\n\
3301                <project_rules>\n{rules}\n</project_rules>\n"
3302            ),
3303            None => String::new(),
3304        };
3305
3306        let instructions_section = match instructions {
3307            Some(instructions) if !instructions.trim().is_empty() => format!(
3308                "\n\nThe user has provided the following instructions for writing commit messages that you should follow:\n\
3309                <commit_message_instructions>\n{instructions}\n</commit_message_instructions>\n"
3310            ),
3311            _ => String::new(),
3312        };
3313
3314        let subject_section = if subject.trim().is_empty() {
3315            String::new()
3316        } else {
3317            format!("\nHere is the user's subject line:\n{subject}")
3318        };
3319
3320        format!(
3321            "{prompt}{user_agents_md_section}{rules_section}{instructions_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
3322        )
3323    }
3324
3325    /// Generates a commit message using an LLM.
3326    pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
3327        if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
3328            return;
3329        }
3330
3331        let Some(ConfiguredModel { provider, model }) =
3332            LanguageModelRegistry::read_global(cx).commit_message_model(cx)
3333        else {
3334            return;
3335        };
3336
3337        let Some(repo) = self.active_repository.as_ref() else {
3338            return;
3339        };
3340
3341        telemetry::event!("Git Commit Message Generated");
3342
3343        let diff = repo.update(cx, |repo, cx| {
3344            if self.has_staged_changes() {
3345                repo.diff(DiffType::HeadToIndex, cx)
3346            } else {
3347                repo.diff(DiffType::HeadToWorktree, cx)
3348            }
3349        });
3350
3351        let temperature = AgentSettings::temperature_for_model(&model, cx);
3352
3353        let include_project_rules =
3354            AgentSettings::get_global(cx).commit_message_include_project_rules;
3355
3356        let instructions = AgentSettings::get_global(cx)
3357            .commit_message_instructions
3358            .clone();
3359        let project = self.project.clone();
3360        let repo_work_dir = repo.read(cx).work_directory_abs_path.clone();
3361
3362        self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| {
3363            async move {
3364                let _defer = cx.on_drop(&this, |this, _cx| {
3365                    this.generate_commit_message_task.take();
3366                });
3367
3368                if let Some(task) = cx.update(|cx| {
3369                    if !provider.is_authenticated(cx) {
3370                        Some(provider.authenticate(cx))
3371                    } else {
3372                        None
3373                    }
3374                }) {
3375                    task.await.log_err();
3376                }
3377
3378                let mut diff_text = match diff.await {
3379                    Ok(result) => match result {
3380                        Ok(text) => text,
3381                        Err(e) => {
3382                            Self::show_commit_message_error(&this, &e, cx);
3383                            return anyhow::Ok(());
3384                        }
3385                    },
3386                    Err(e) => {
3387                        Self::show_commit_message_error(&this, &e, cx);
3388                        return anyhow::Ok(());
3389                    }
3390                };
3391
3392                const MAX_DIFF_BYTES: usize = 20_000;
3393                diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES);
3394
3395                let rules_content = if include_project_rules {
3396                    Self::load_project_rules(&project, &repo_work_dir, &mut cx).await
3397                } else {
3398                    None
3399                };
3400                let user_agents_md = if include_project_rules {
3401                    cx.update(|cx| {
3402                        UserAgentsMd::global(cx)
3403                            .and_then(|user_agents_md| user_agents_md.content().cloned())
3404                    })
3405                } else {
3406                    None
3407                };
3408
3409                let prompt = include_str!("../src/commit_message_prompt.txt");
3410
3411                let subject = this.update(cx, |this, cx| {
3412                    this.commit_editor
3413                        .read(cx)
3414                        .text(cx)
3415                        .lines()
3416                        .next()
3417                        .map(ToOwned::to_owned)
3418                        .unwrap_or_default()
3419                })?;
3420
3421                let text_empty = subject.trim().is_empty();
3422
3423                let content = Self::build_commit_message_prompt(
3424                    &prompt,
3425                    user_agents_md.as_deref(),
3426                    rules_content.as_deref(),
3427                    instructions.as_deref(),
3428                    &subject,
3429                    &diff_text,
3430                );
3431
3432                let request = LanguageModelRequest {
3433                    thread_id: None,
3434                    prompt_id: None,
3435                    intent: Some(CompletionIntent::GenerateGitCommitMessage),
3436                    messages: vec![LanguageModelRequestMessage {
3437                        role: Role::User,
3438                        content: vec![content.into()],
3439                        cache: false,
3440                        reasoning_details: None,
3441                    }],
3442                    tools: Vec::new(),
3443                    tool_choice: None,
3444                    stop: Vec::new(),
3445                    temperature,
3446                    thinking_allowed: false,
3447                    thinking_effort: None,
3448                    speed: None,
3449                    compact_at_tokens: None,
3450                };
3451
3452                let stream = model.stream_completion_text(request, cx);
3453                match stream.await {
3454                    Ok(mut messages) => {
3455                        if !text_empty {
3456                            this.update(cx, |this, cx| {
3457                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
3458                                    let insert_position = buffer.anchor_before(buffer.len());
3459                                    buffer.edit(
3460                                        [(insert_position..insert_position, "\n")],
3461                                        None,
3462                                        cx,
3463                                    )
3464                                });
3465                            })?;
3466                        }
3467
3468                        while let Some(message) = messages.stream.next().await {
3469                            match message {
3470                                Ok(text) => {
3471                                    this.update(cx, |this, cx| {
3472                                        this.commit_message_buffer(cx).update(cx, |buffer, cx| {
3473                                            let insert_position =
3474                                                buffer.anchor_before(buffer.len());
3475                                            buffer.edit(
3476                                                [(insert_position..insert_position, text)],
3477                                                None,
3478                                                cx,
3479                                            );
3480                                        });
3481                                    })?;
3482                                }
3483                                Err(e) => {
3484                                    Self::show_commit_message_error(&this, &e, cx);
3485                                    break;
3486                                }
3487                            }
3488                        }
3489                    }
3490                    Err(e) => {
3491                        Self::show_commit_message_error(&this, &e, cx);
3492                    }
3493                }
3494
3495                anyhow::Ok(())
3496            }
3497            .log_err()
3498            .await
3499        }));
3500    }
3501
3502    fn get_fetch_options(
3503        &self,
3504        window: &mut Window,
3505        cx: &mut Context<Self>,
3506    ) -> Task<Option<FetchOptions>> {
3507        let repo = self.active_repository.clone();
3508        let workspace = self.workspace.clone();
3509
3510        cx.spawn_in(window, async move |_, cx| {
3511            let repo = repo?;
3512            let remotes = repo
3513                .update(cx, |repo, _| repo.get_remotes(None, false))
3514                .await
3515                .ok()?
3516                .log_err()?;
3517
3518            let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
3519            if remotes.len() > 1 {
3520                remotes.push(FetchOptions::All);
3521            }
3522            let selection = cx
3523                .update(|window, cx| {
3524                    picker_prompt::prompt(
3525                        "Pick which remote to fetch",
3526                        remotes.iter().map(|r| r.name()).collect(),
3527                        workspace,
3528                        window,
3529                        cx,
3530                    )
3531                })
3532                .ok()?
3533                .await?;
3534            remotes.get(selection).cloned()
3535        })
3536    }
3537
3538    pub(crate) fn fetch(
3539        &mut self,
3540        is_fetch_all: bool,
3541        window: &mut Window,
3542        cx: &mut Context<Self>,
3543    ) {
3544        if !self.can_push_and_pull(cx) {
3545            return;
3546        }
3547
3548        let Some(repo) = self.active_repository.clone() else {
3549            return;
3550        };
3551        if !self.start_remote_operation(RemoteOperationKind::Fetch, cx) {
3552            return;
3553        }
3554
3555        telemetry::event!("Git Fetched");
3556        let askpass = self.askpass_delegate("git fetch", window, cx);
3557        let this = cx.weak_entity();
3558
3559        let fetch_options = if is_fetch_all {
3560            Task::ready(Some(FetchOptions::All))
3561        } else {
3562            self.get_fetch_options(window, cx)
3563        };
3564
3565        window
3566            .spawn(cx, async move |cx| {
3567                let _clear_pending_remote_operation = cx.on_drop(&this, |this, cx| {
3568                    this.clear_remote_operation(cx);
3569                });
3570
3571                let Some(fetch_options) = fetch_options.await else {
3572                    return Ok(());
3573                };
3574                let fetch = repo.update(cx, |repo, cx| {
3575                    repo.fetch(fetch_options.clone(), askpass, cx)
3576                });
3577
3578                let remote_message = fetch.await?;
3579                this.update(cx, |this, cx| {
3580                    let action = match fetch_options {
3581                        FetchOptions::All => RemoteAction::Fetch(None),
3582                        FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
3583                    };
3584                    match remote_message {
3585                        Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3586                        Err(e) => {
3587                            log::error!("Error while fetching {:?}", e);
3588                            this.show_error_toast(action.name(), e, cx)
3589                        }
3590                    }
3591
3592                    anyhow::Ok(())
3593                })
3594                .ok();
3595                anyhow::Ok(())
3596            })
3597            .detach_and_log_err(cx);
3598    }
3599
3600    pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
3601        let workspace = self.workspace.clone();
3602
3603        crate::clone::clone_and_open(
3604            repo.into(),
3605            workspace,
3606            window,
3607            cx,
3608            Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
3609        );
3610    }
3611
3612    pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3613        let worktrees = self
3614            .project
3615            .read(cx)
3616            .visible_worktrees(cx)
3617            .collect::<Vec<_>>();
3618
3619        let worktree = if worktrees.len() == 1 {
3620            Task::ready(Some(worktrees.first().unwrap().clone()))
3621        } else if worktrees.is_empty() {
3622            let result = window.prompt(
3623                PromptLevel::Warning,
3624                "Unable to initialize a git repository",
3625                Some("Open a directory first"),
3626                &["OK"],
3627                cx,
3628            );
3629            cx.background_executor()
3630                .spawn(async move {
3631                    result.await.ok();
3632                })
3633                .detach();
3634            return;
3635        } else {
3636            let worktree_directories = worktrees
3637                .iter()
3638                .map(|worktree| worktree.read(cx).abs_path())
3639                .map(|worktree_abs_path| {
3640                    if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
3641                        Path::new("~")
3642                            .join(path)
3643                            .to_string_lossy()
3644                            .to_string()
3645                            .into()
3646                    } else {
3647                        worktree_abs_path.to_string_lossy().into_owned().into()
3648                    }
3649                })
3650                .collect_vec();
3651            let prompt = picker_prompt::prompt(
3652                "Where would you like to initialize this git repository?",
3653                worktree_directories,
3654                self.workspace.clone(),
3655                window,
3656                cx,
3657            );
3658
3659            cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
3660        };
3661
3662        cx.spawn_in(window, async move |this, cx| {
3663            let worktree = match worktree.await {
3664                Some(worktree) => worktree,
3665                None => {
3666                    return;
3667                }
3668            };
3669
3670            let Ok(result) = this.update(cx, |this, cx| {
3671                let fallback_branch_name = GitPanelSettings::get_global(cx)
3672                    .fallback_branch_name
3673                    .clone();
3674                this.project.read(cx).git_init(
3675                    worktree.read(cx).abs_path(),
3676                    fallback_branch_name,
3677                    cx,
3678                )
3679            }) else {
3680                return;
3681            };
3682
3683            let result = result.await;
3684
3685            this.update_in(cx, |this, _, cx| match result {
3686                Ok(()) => {}
3687                Err(e) => this.show_error_toast("init", e, cx),
3688            })
3689            .ok();
3690        })
3691        .detach();
3692    }
3693
3694    pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context<Self>) {
3695        if !self.can_push_and_pull(cx) {
3696            return;
3697        }
3698        let Some(repo) = self.active_repository.clone() else {
3699            return;
3700        };
3701        let Some(branch) = repo.read(cx).branch.clone() else {
3702            return;
3703        };
3704        if !self.start_remote_operation(RemoteOperationKind::Pull, cx) {
3705            return;
3706        }
3707
3708        telemetry::event!("Git Pulled");
3709        let remote = self.get_remote(false, false, window, cx);
3710        cx.spawn_in(window, async move |this, cx| {
3711            let _clear_pending_remote_operation = cx.on_drop(&this, |this, cx| {
3712                this.clear_remote_operation(cx);
3713            });
3714
3715            let remote = match remote.await {
3716                Ok(Some(remote)) => remote,
3717                Ok(None) => {
3718                    return Ok(());
3719                }
3720                Err(e) => {
3721                    log::error!("Failed to get current remote: {}", e);
3722                    this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
3723                        .ok();
3724                    return Ok(());
3725                }
3726            };
3727
3728            let askpass = this.update_in(cx, |this, window, cx| {
3729                this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
3730            })?;
3731
3732            let branch_name = branch
3733                .upstream
3734                .is_none()
3735                .then(|| branch.name().to_owned().into());
3736
3737            let pull = repo.update(cx, |repo, cx| {
3738                repo.pull(branch_name, remote.name.clone(), rebase, askpass, cx)
3739            });
3740
3741            let remote_message = pull.await?;
3742
3743            let action = RemoteAction::Pull(remote);
3744            this.update(cx, |this, cx| match remote_message {
3745                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3746                Err(e) => {
3747                    log::error!("Error while pulling {:?}", e);
3748                    this.show_error_toast(action.name(), e, cx)
3749                }
3750            })
3751            .ok();
3752
3753            anyhow::Ok(())
3754        })
3755        .detach_and_log_err(cx);
3756    }
3757
3758    pub(crate) fn push(
3759        &mut self,
3760        force_push: bool,
3761        select_remote: bool,
3762        window: &mut Window,
3763        cx: &mut Context<Self>,
3764    ) {
3765        if !self.can_push_and_pull(cx) {
3766            return;
3767        }
3768        let Some(repo) = self.active_repository.clone() else {
3769            return;
3770        };
3771        let Some(branch) = repo.read(cx).branch.clone() else {
3772            return;
3773        };
3774        if !self.start_remote_operation(RemoteOperationKind::Push, cx) {
3775            return;
3776        }
3777
3778        telemetry::event!("Git Pushed");
3779
3780        let options = if force_push {
3781            Some(PushOptions::Force)
3782        } else {
3783            match branch.upstream {
3784                Some(Upstream {
3785                    tracking: UpstreamTracking::Gone,
3786                    ..
3787                })
3788                | None => Some(PushOptions::SetUpstream),
3789                _ => None,
3790            }
3791        };
3792        let remote = self.get_remote(select_remote, true, window, cx);
3793
3794        cx.spawn_in(window, async move |this, cx| {
3795            let _clear_pending_remote_operation = cx.on_drop(&this, |this, cx| {
3796                this.clear_remote_operation(cx);
3797            });
3798
3799            let remote = match remote.await {
3800                Ok(Some(remote)) => remote,
3801                Ok(None) => {
3802                    this.update(cx, |this, cx| {
3803                        this.show_error_toast(
3804                            "push",
3805                            anyhow::anyhow!("No remote available to push to. Add a remote to be able to publish changes."),
3806                            cx,
3807                        )
3808                    })
3809                    .ok();
3810                    return Ok(());
3811                }
3812                Err(e) => {
3813                    log::error!("Failed to get current remote: {}", e);
3814                    this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
3815                        .ok();
3816                    return Ok(());
3817                }
3818            };
3819
3820            let askpass_delegate = this.update_in(cx, |this, window, cx| {
3821                this.askpass_delegate(format!("git push {}", remote.name), window, cx)
3822            })?;
3823
3824            let push = repo.update(cx, |repo, cx| {
3825                repo.push(
3826                    branch.name().to_owned().into(),
3827                    branch
3828                        .upstream
3829                        .as_ref()
3830                        .filter(|u| matches!(u.tracking, UpstreamTracking::Tracked(_)))
3831                        .and_then(|u| u.branch_name())
3832                        .unwrap_or_else(|| branch.name())
3833                        .to_owned()
3834                        .into(),
3835                    remote.name.clone(),
3836                    options,
3837                    askpass_delegate,
3838                    cx,
3839                )
3840            });
3841
3842            let remote_output = push.await?;
3843
3844            let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
3845            this.update(cx, |this, cx| match remote_output {
3846                Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3847                Err(e) => {
3848                    log::error!("Error while pushing {:?}", e);
3849                    this.show_error_toast(action.name(), e, cx)
3850                }
3851            })?;
3852
3853            anyhow::Ok(())
3854        })
3855        .detach_and_log_err(cx);
3856    }
3857
3858    /// Updates git's configuration, adding the directory of the current
3859    /// worktree to the `safe.directory` config, ensuring that, even if the user
3860    /// that's running the application is not the owner of `.git/`, it can still
3861    /// read the repository's contents.
3862    fn add_safe_directory(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
3863        let Some(active_repository) = &self.active_repository else {
3864            return;
3865        };
3866
3867        let path = active_repository.update(cx, |repository, _cx| {
3868            repository.snapshot().work_directory_abs_path
3869        });
3870
3871        if let Some(path_str) = path.to_str() {
3872            let path_arg = String::from(path_str);
3873            let args = vec![
3874                String::from("--global"),
3875                String::from("--add"),
3876                String::from("safe.directory"),
3877                path_arg,
3878            ];
3879
3880            self.project
3881                .read(cx)
3882                .git_config(path, args, cx)
3883                .detach_and_log_err(cx);
3884        }
3885    }
3886
3887    pub fn create_pull_request(&self, window: &mut Window, cx: &mut Context<Self>) {
3888        let result = (|| -> anyhow::Result<()> {
3889            let repo = self
3890                .active_repository
3891                .clone()
3892                .ok_or_else(|| anyhow::anyhow!("No active repository"))?;
3893
3894            let (branch, remote_origin, remote_upstream) = {
3895                let repository = repo.read(cx);
3896                (
3897                    repository.branch.clone(),
3898                    repository.remote_origin_url.clone(),
3899                    repository.remote_upstream_url.clone(),
3900                )
3901            };
3902
3903            let branch = branch.ok_or_else(|| anyhow::anyhow!("No active branch"))?;
3904            let source_branch = branch
3905                .upstream
3906                .as_ref()
3907                .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_)))
3908                .and_then(|upstream| upstream.branch_name())
3909                .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3910            let source_branch = source_branch.to_string();
3911
3912            let remote_url = branch
3913                .upstream
3914                .as_ref()
3915                .and_then(|upstream| match upstream.remote_name() {
3916                    Some("upstream") => remote_upstream.as_deref(),
3917                    Some(_) => remote_origin.as_deref(),
3918                    None => None,
3919                })
3920                .or(remote_origin.as_deref())
3921                .or(remote_upstream.as_deref())
3922                .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3923            let remote_url = remote_url.to_string();
3924
3925            let provider_registry = GitHostingProviderRegistry::global(cx);
3926            let Some((provider, parsed_remote)) =
3927                git::parse_git_remote_url(provider_registry, &remote_url)
3928            else {
3929                return Err(anyhow::anyhow!("Unsupported remote URL: {}", remote_url));
3930            };
3931
3932            let Some(url) = provider.build_create_pull_request_url(&parsed_remote, &source_branch)
3933            else {
3934                return Err(anyhow::anyhow!("Unable to construct pull request URL"));
3935            };
3936
3937            cx.open_url(url.as_str());
3938            Ok(())
3939        })();
3940
3941        if let Err(err) = result {
3942            log::error!("Error while creating pull request {:?}", err);
3943            cx.defer_in(window, |panel, _window, cx| {
3944                panel.show_error_toast("create pull request", err, cx);
3945            });
3946        }
3947    }
3948
3949    fn askpass_delegate(
3950        &self,
3951        operation: impl Into<SharedString>,
3952        window: &mut Window,
3953        cx: &mut Context<Self>,
3954    ) -> AskPassDelegate {
3955        let workspace = self.workspace.clone();
3956        let operation = operation.into();
3957        let window = window.window_handle();
3958        AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
3959            window
3960                .update(cx, |_, window, cx| {
3961                    workspace.update(cx, |workspace, cx| {
3962                        workspace.toggle_modal(window, cx, |window, cx| {
3963                            AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
3964                        });
3965                    })
3966                })
3967                .ok();
3968        })
3969    }
3970
3971    fn can_push_and_pull(&self, cx: &App) -> bool {
3972        !self.project.read(cx).is_via_collab()
3973    }
3974
3975    fn start_remote_operation(
3976        &mut self,
3977        kind: RemoteOperationKind,
3978        cx: &mut Context<Self>,
3979    ) -> bool {
3980        if self.pending_remote_operation.is_some() {
3981            return false;
3982        }
3983
3984        self.pending_remote_operation = Some(kind);
3985        cx.notify();
3986        true
3987    }
3988
3989    fn clear_remote_operation(&mut self, cx: &mut Context<Self>) {
3990        self.pending_remote_operation.take();
3991        cx.notify();
3992    }
3993
3994    fn get_remote(
3995        &mut self,
3996        always_select: bool,
3997        is_push: bool,
3998        window: &mut Window,
3999        cx: &mut Context<Self>,
4000    ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
4001        let repo = self.active_repository.clone();
4002        let workspace = self.workspace.clone();
4003        let mut cx = window.to_async(cx);
4004
4005        async move {
4006            let repo = repo.context("No active repository")?;
4007            let current_remotes: Vec<Remote> = repo
4008                .update(&mut cx, |repo, _| {
4009                    let current_branch = if always_select {
4010                        None
4011                    } else {
4012                        let current_branch = repo.branch.as_ref().context("No active branch")?;
4013                        Some(current_branch.name().to_string())
4014                    };
4015                    anyhow::Ok(repo.get_remotes(current_branch, is_push))
4016                })?
4017                .await??;
4018
4019            let current_remotes: Vec<_> = current_remotes
4020                .into_iter()
4021                .map(|remotes| remotes.name)
4022                .collect();
4023            let selection = cx
4024                .update(|window, cx| {
4025                    picker_prompt::prompt(
4026                        "Pick which remote to push to",
4027                        current_remotes.clone(),
4028                        workspace,
4029                        window,
4030                        cx,
4031                    )
4032                })?
4033                .await;
4034
4035            Ok(selection.map(|selection| Remote {
4036                name: current_remotes[selection].clone(),
4037            }))
4038        }
4039    }
4040
4041    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
4042        if self.local_committer_task.is_none() {
4043            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
4044                let committer = get_git_committer(cx).await;
4045                this.update(cx, |this, cx| {
4046                    this.local_committer = Some(committer);
4047                    cx.notify()
4048                })
4049                .ok();
4050            }));
4051        }
4052    }
4053
4054    #[cfg(not(feature = "call"))]
4055    fn potential_co_authors(&self, _cx: &App) -> Vec<(String, String)> {
4056        Vec::new()
4057    }
4058
4059    #[cfg(feature = "call")]
4060    fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
4061        let mut new_co_authors = Vec::new();
4062        let project = self.project.read(cx);
4063
4064        let Some(room) =
4065            call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
4066        else {
4067            return Vec::default();
4068        };
4069
4070        let room = room.read(cx);
4071
4072        for (peer_id, collaborator) in project.collaborators() {
4073            if collaborator.is_host {
4074                continue;
4075            }
4076
4077            let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
4078                continue;
4079            };
4080            if !participant.can_write() {
4081                continue;
4082            }
4083            if let Some(email) = &collaborator.committer_email {
4084                let name = collaborator
4085                    .committer_name
4086                    .clone()
4087                    .or_else(|| participant.user.name.clone())
4088                    .unwrap_or_else(|| participant.user.username.clone().to_string());
4089                new_co_authors.push((name.clone(), email.clone()))
4090            }
4091        }
4092        if !project.is_local()
4093            && !project.is_read_only(cx)
4094            && let Some(local_committer) = self.local_committer(room, cx)
4095        {
4096            new_co_authors.push(local_committer);
4097        }
4098        new_co_authors
4099    }
4100
4101    #[cfg(feature = "call")]
4102    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
4103        let user = room.local_participant_user(cx)?;
4104        let committer = self.local_committer.as_ref()?;
4105        let email = committer.email.clone()?;
4106        let name = committer
4107            .name
4108            .clone()
4109            .or_else(|| user.name.clone())
4110            .unwrap_or_else(|| user.username.clone().to_string());
4111        Some((name, email))
4112    }
4113
4114    fn toggle_fill_co_authors(
4115        &mut self,
4116        _: &ToggleFillCoAuthors,
4117        _: &mut Window,
4118        cx: &mut Context<Self>,
4119    ) {
4120        self.add_coauthors = !self.add_coauthors;
4121        cx.notify();
4122    }
4123
4124    fn set_sort_by_path(&mut self, _: &SetSortByPath, _: &mut Window, cx: &mut Context<Self>) {
4125        if let Some(workspace) = self.workspace.upgrade() {
4126            let workspace = workspace.read(cx);
4127            let fs = workspace.app_state().fs.clone();
4128            cx.update_global::<SettingsStore, _>(|store, _cx| {
4129                store.update_settings_file(fs, move |settings, _cx| {
4130                    settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Path);
4131                });
4132            });
4133        }
4134    }
4135
4136    fn set_sort_by_name(&mut self, _: &SetSortByName, _: &mut Window, cx: &mut Context<Self>) {
4137        if let Some(workspace) = self.workspace.upgrade() {
4138            let workspace = workspace.read(cx);
4139            let fs = workspace.app_state().fs.clone();
4140            cx.update_global::<SettingsStore, _>(|store, _cx| {
4141                store.update_settings_file(fs, move |settings, _cx| {
4142                    settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Name);
4143                });
4144            });
4145        }
4146    }
4147
4148    fn set_group_by_none(&mut self, _: &SetGroupByNone, _: &mut Window, cx: &mut Context<Self>) {
4149        if let Some(workspace) = self.workspace.upgrade() {
4150            let workspace = workspace.read(cx);
4151            let fs = workspace.app_state().fs.clone();
4152            cx.update_global::<SettingsStore, _>(|store, _cx| {
4153                store.update_settings_file(fs, move |settings, _cx| {
4154                    settings.git_panel.get_or_insert_default().group_by =
4155                        Some(GitPanelGroupBy::None);
4156                });
4157            });
4158        }
4159    }
4160
4161    fn set_group_by_status(
4162        &mut self,
4163        _: &SetGroupByStatus,
4164        _: &mut Window,
4165        cx: &mut Context<Self>,
4166    ) {
4167        if let Some(workspace) = self.workspace.upgrade() {
4168            let workspace = workspace.read(cx);
4169            let fs = workspace.app_state().fs.clone();
4170            cx.update_global::<SettingsStore, _>(|store, _cx| {
4171                store.update_settings_file(fs, move |settings, _cx| {
4172                    settings.git_panel.get_or_insert_default().group_by =
4173                        Some(GitPanelGroupBy::Status);
4174                });
4175            });
4176        }
4177    }
4178
4179    fn view_staged_changes(
4180        &mut self,
4181        _: &ViewStagedChanges,
4182        window: &mut Window,
4183        cx: &mut Context<Self>,
4184    ) {
4185        let entry = self
4186            .get_selected_entry()
4187            .and_then(|entry| entry.status_entry())
4188            .cloned();
4189        if let Some(workspace) = self.workspace.upgrade() {
4190            workspace.update(cx, |workspace, cx| {
4191                StagedDiff::deploy_at(workspace, entry, window, cx);
4192            });
4193        }
4194    }
4195
4196    fn view_unstaged_changes(
4197        &mut self,
4198        _: &ViewUnstagedChanges,
4199        window: &mut Window,
4200        cx: &mut Context<Self>,
4201    ) {
4202        let entry = self
4203            .get_selected_entry()
4204            .and_then(|entry| entry.status_entry())
4205            .cloned();
4206        if let Some(workspace) = self.workspace.upgrade() {
4207            workspace.update(cx, |workspace, cx| {
4208                UnstagedDiff::deploy_at(workspace, entry, window, cx);
4209            });
4210        }
4211    }
4212
4213    fn set_group_by_staging(
4214        &mut self,
4215        _: &SetGroupByStaging,
4216        _: &mut Window,
4217        cx: &mut Context<Self>,
4218    ) {
4219        if let Some(workspace) = self.workspace.upgrade() {
4220            let workspace = workspace.read(cx);
4221            let fs = workspace.app_state().fs.clone();
4222            cx.update_global::<SettingsStore, _>(|store, _cx| {
4223                store.update_settings_file(fs, move |settings, _cx| {
4224                    settings.git_panel.get_or_insert_default().group_by =
4225                        Some(GitPanelGroupBy::Staging);
4226                });
4227            });
4228        }
4229    }
4230
4231    fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context<Self>) {
4232        let current_setting = GitPanelSettings::get_global(cx).tree_view;
4233        if let Some(workspace) = self.workspace.upgrade() {
4234            let workspace = workspace.read(cx);
4235            let fs = workspace.app_state().fs.clone();
4236            cx.update_global::<SettingsStore, _>(|store, _cx| {
4237                store.update_settings_file(fs, move |settings, _cx| {
4238                    settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting);
4239                });
4240            })
4241        }
4242    }
4243
4244    pub(crate) fn increase_font_size(
4245        &mut self,
4246        action: &IncreaseBufferFontSize,
4247        _: &mut Window,
4248        cx: &mut Context<Self>,
4249    ) {
4250        self.handle_font_size_action(action.persist, px(1.0), cx);
4251    }
4252
4253    pub(crate) fn decrease_font_size(
4254        &mut self,
4255        action: &DecreaseBufferFontSize,
4256        _: &mut Window,
4257        cx: &mut Context<Self>,
4258    ) {
4259        self.handle_font_size_action(action.persist, px(-1.0), cx);
4260    }
4261
4262    fn handle_font_size_action(&mut self, persist: bool, delta: Pixels, cx: &mut Context<Self>) {
4263        if persist {
4264            update_settings_file(self.fs.clone(), cx, move |settings, cx| {
4265                let git_commit_buffer_font_size =
4266                    ThemeSettings::get_global(cx).git_commit_buffer_font_size(cx) + delta;
4267
4268                let _ = settings.theme.git_commit_buffer_font_size.insert(
4269                    f32::from(theme_settings::clamp_font_size(git_commit_buffer_font_size)).into(),
4270                );
4271            });
4272        } else {
4273            theme_settings::adjust_git_commit_buffer_font_size(cx, |size| size + delta);
4274        }
4275    }
4276
4277    pub(crate) fn reset_font_size(
4278        &mut self,
4279        action: &ResetBufferFontSize,
4280        _: &mut Window,
4281        cx: &mut Context<Self>,
4282    ) {
4283        if action.persist {
4284            update_settings_file(self.fs.clone(), cx, move |settings, _| {
4285                settings.theme.git_commit_buffer_font_size = None;
4286            });
4287        } else {
4288            theme_settings::reset_git_commit_buffer_font_size(cx);
4289        }
4290    }
4291
4292    fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context<Self>) {
4293        if let Some(state) = self.view_mode.tree_state_mut() {
4294            let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
4295            *expanded = !*expanded;
4296            self.tree_expanded_dirs = state.expanded_dirs.clone();
4297            self.update_visible_entries(window, cx);
4298        } else {
4299            util::debug_panic!("Attempted to toggle directory in flat Git Panel state");
4300        }
4301    }
4302
4303    fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
4304        const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
4305
4306        let existing_text = message.to_ascii_lowercase();
4307        let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
4308        let mut ends_with_co_authors = false;
4309        let existing_co_authors = existing_text
4310            .lines()
4311            .filter_map(|line| {
4312                let line = line.trim();
4313                if line.starts_with(&lowercase_co_author_prefix) {
4314                    ends_with_co_authors = true;
4315                    Some(line)
4316                } else {
4317                    ends_with_co_authors = false;
4318                    None
4319                }
4320            })
4321            .collect::<HashSet<_>>();
4322
4323        let new_co_authors = self
4324            .potential_co_authors(cx)
4325            .into_iter()
4326            .filter(|(_, email)| {
4327                !existing_co_authors
4328                    .iter()
4329                    .any(|existing| existing.contains(email.as_str()))
4330            })
4331            .collect::<Vec<_>>();
4332
4333        if new_co_authors.is_empty() {
4334            return;
4335        }
4336
4337        if !ends_with_co_authors {
4338            message.push('\n');
4339        }
4340        for (name, email) in new_co_authors {
4341            message.push('\n');
4342            message.push_str(CO_AUTHOR_PREFIX);
4343            message.push_str(&name);
4344            message.push_str(" <");
4345            message.push_str(&email);
4346            message.push('>');
4347        }
4348        message.push('\n');
4349    }
4350
4351    fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4352        let handle = cx.entity().downgrade();
4353        let new_active_repository = self.project.read(cx).active_repository(cx);
4354        let active_repository_changed = self.active_repository.as_ref().map(Entity::entity_id)
4355            != new_active_repository.as_ref().map(Entity::entity_id);
4356        if active_repository_changed {
4357            self.set_skip_hooks_enabled(false, cx);
4358            if self.amend_pending {
4359                // Leaving a repository with a pending amend: undo it so the amend
4360                // state doesn't carry over to the newly active repository. The
4361                // commit editor still holds the previous repository's buffer here
4362                // (`reopen_commit_buffer` swaps it asynchronously below), so this
4363                // restores the pre-amend draft into that repository's buffer.
4364                self.set_amend_pending(false, cx);
4365            }
4366            self.git_access = None;
4367            self._repo_subscriptions.clear();
4368            if self.active_tab == GitPanelTab::History {
4369                self.set_commit_history(CommitHistory::Loading, cx);
4370            }
4371        }
4372        self.active_repository = new_active_repository;
4373        self.reopen_commit_buffer(window, cx);
4374        self.preload_commit_history(cx);
4375        if self.active_tab == GitPanelTab::History {
4376            self.load_commit_history(cx);
4377        }
4378        self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
4379            cx.background_executor().timer(UPDATE_DEBOUNCE).await;
4380            if let Some(git_panel) = handle.upgrade() {
4381                git_panel
4382                    .update_in(cx, |git_panel, window, cx| {
4383                        git_panel.update_visible_entries(window, cx);
4384                    })
4385                    .ok();
4386            }
4387        });
4388    }
4389
4390    fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4391        let Some(active_repo) = self.active_repository.as_ref() else {
4392            self.reopen_commit_buffer_task = Task::ready(());
4393            return;
4394        };
4395        let active_repository_abs_path = active_repo
4396            .read(cx)
4397            .work_directory_abs_path
4398            .to_string_lossy()
4399            .into_owned();
4400        let load_buffer = active_repo.update(cx, |active_repo, cx| {
4401            let project = self.project.read(cx);
4402            active_repo.open_commit_buffer(
4403                Some(project.languages().clone()),
4404                project.buffer_store().clone(),
4405                cx,
4406            )
4407        });
4408        let load_template = self.load_commit_template(cx);
4409
4410        self.reopen_commit_buffer_task = cx.spawn_in(window, async move |git_panel, cx| {
4411            let result = async {
4412                // Set up the buffer before awaiting the commit template as the
4413                // request may never resolve (for example, a collab host that
4414                // doesn't know about `LoadCommitTemplate`) and must not block the
4415                // commit editor from attaching to the shared buffer.
4416                let buffer = load_buffer.await?;
4417                git_panel.update_in(cx, |git_panel, window, cx| {
4418                    if git_panel
4419                        .commit_editor
4420                        .read(cx)
4421                        .buffer()
4422                        .read(cx)
4423                        .as_singleton()
4424                        .as_ref()
4425                        != Some(&buffer)
4426                    {
4427                        git_panel.commit_editor = cx.new(|cx| {
4428                            commit_message_editor(
4429                                buffer.clone(),
4430                                git_panel.suggest_commit_message(cx).map(SharedString::from),
4431                                git_panel.project.clone(),
4432                                true,
4433                                window,
4434                                cx,
4435                            )
4436                        });
4437                    }
4438
4439                    // Create subscription such that, any edit on the commit
4440                    // editor's buffer will be serialized and saved to the database
4441                    // in order to be able to restore it in case there's a
4442                    // disconnect.
4443                    git_panel._commit_message_buffer_subscription =
4444                        Some(cx.subscribe(&buffer, |git_panel, _, event, cx| {
4445                            if matches!(event, BufferEvent::Edited { .. }) {
4446                                git_panel.serialize(cx);
4447                            }
4448                        }));
4449                })?;
4450
4451                // Check whether there's a pending commit message for this
4452                // repository and, if that's the case, update the buffer's
4453                // text.
4454                git_panel.update(cx, |git_panel, cx| {
4455                    if let Some(restored_commit_message) = git_panel
4456                        .pending_commit_message_restores
4457                        .remove(&active_repository_abs_path)
4458                    {
4459                        git_panel.amend_pending = restored_commit_message.amend_pending;
4460                        git_panel.original_commit_message =
4461                            restored_commit_message.original_message;
4462                        cx.notify();
4463
4464                        if let Some(message) = restored_commit_message.message
4465                            && buffer.read(cx).text().trim().is_empty()
4466                        {
4467                            buffer.update(cx, |buffer, cx| {
4468                                buffer.set_text(message, cx);
4469                            });
4470                        }
4471                    }
4472                })?;
4473
4474                // Only apply the template if it's non-empty and the buffer has no
4475                // content, so we never override a commit message that was already
4476                // in progress.
4477                let commit_template = load_template.await?;
4478                git_panel.update(cx, |git_panel, cx| {
4479                    git_panel.commit_template = commit_template;
4480
4481                    if let Some(commit_template) = git_panel.commit_template.as_ref()
4482                        && !commit_template.template.is_empty()
4483                        && buffer.read(cx).text().trim().is_empty()
4484                    {
4485                        buffer.update(cx, |buffer, cx| {
4486                            buffer.set_text(commit_template.template.clone(), cx);
4487                        });
4488                    }
4489                })?;
4490
4491                anyhow::Ok(())
4492            }
4493            .await;
4494
4495            result.log_err();
4496        });
4497    }
4498
4499    fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
4500        let path_style = self.project.read(cx).path_style(cx);
4501        let selected_change = self.selected_entry.and_then(|index| {
4502            let entry = self.entries.get(index)?.status_entry()?;
4503            Some((entry.repo_path.clone(), self.section_for_entry_index(index)))
4504        });
4505        let bulk_staging = self.bulk_staging.take();
4506        let last_staged_path_prev_index = bulk_staging
4507            .as_ref()
4508            .and_then(|op| self.entry_by_path(&op.anchor));
4509
4510        self.entries.clear();
4511        self.projected_entries_by_path.clear();
4512        self.single_staged_entry.take();
4513        self.single_tracked_entry.take();
4514        self.conflicted_count = 0;
4515        self.conflicted_staged_count = 0;
4516        self.changes_count = 0;
4517        self.diff_stat_total = DiffStat::default();
4518        self.new_count = 0;
4519        self.tracked_count = 0;
4520        self.new_staged_count = 0;
4521        self.tracked_staged_count = 0;
4522        self.entry_count = 0;
4523        self.max_width_item_index = None;
4524
4525        let settings = GitPanelSettings::get_global(cx);
4526        let sort_by = settings.sort_by;
4527        let group_by = settings.group_by;
4528        let group_by_file_status = group_by == GitPanelGroupBy::Status;
4529        let group_by_staging_state = group_by == GitPanelGroupBy::Staging;
4530        let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
4531
4532        if let Some(active_repo) = self.active_repository.as_ref() {
4533            if self.git_access.is_none() {
4534                let access = active_repo.update(cx, |active_repo, cx| active_repo.access(cx));
4535
4536                cx.spawn_in(window, async move |git_panel, cx| {
4537                    // When the user does not own the `.git` folder, the
4538                    // `GitStore.spawn_local_git_worker` will fail to create the
4539                    // receiver for Git jobs, so this access check will be
4540                    // cancelled.
4541                    //
4542                    // We assume `GitAccess::No` on cancellation. I believe this is
4543                    // imprecise, other failures could also cause cancellation, but
4544                    // the consequence is just showing the "unsafe repo" UI, which
4545                    // seems acceptable for this edge case.
4546                    let access = match access.await {
4547                        Ok(access) => access,
4548                        Err(Canceled) => GitAccess::No,
4549                    };
4550
4551                    git_panel.update(cx, |this, _cx| {
4552                        this.git_access = Some(access);
4553                    })
4554                })
4555                .detach_and_log_err(cx);
4556            }
4557        }
4558
4559        let mut changed_entries = Vec::new();
4560        let mut new_entries = Vec::new();
4561        let mut conflict_entries = Vec::new();
4562        let mut staged_entries = Vec::new();
4563        let mut unstaged_entries = Vec::new();
4564        let mut tracked_entries = Vec::new();
4565        let mut single_staged_entry = None;
4566        let mut staged_count = 0;
4567        let mut seen_directories = HashSet::default();
4568        let mut max_width_estimate = 0usize;
4569        let mut max_width_item_index = None;
4570
4571        let Some(repo) = self.active_repository.as_ref() else {
4572            // Just clear entries if no repository is active.
4573            cx.notify();
4574            return;
4575        };
4576
4577        let repo = repo.read(cx);
4578
4579        self.stash_entries = repo.cached_stash();
4580
4581        for status_entry in repo.cached_status() {
4582            self.changes_count += 1;
4583            let is_conflict = repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path);
4584            let is_new = status_entry.status.is_created();
4585            let staging = status_entry.status.staging();
4586
4587            if let Some(pending) = repo.pending_ops_for_path(&status_entry.repo_path)
4588                && pending
4589                    .ops
4590                    .iter()
4591                    .any(|op| op.git_status == pending_op::GitStatus::Reverted && op.finished())
4592            {
4593                continue;
4594            }
4595
4596            let entry = GitStatusEntry {
4597                repo_path: status_entry.repo_path.clone(),
4598                status: status_entry.status,
4599                staging,
4600                diff_stat: status_entry.diff_stat,
4601            };
4602
4603            if !is_conflict && !is_new {
4604                tracked_entries.push(entry.clone());
4605            }
4606
4607            if staging.has_staged() {
4608                staged_count += 1;
4609                single_staged_entry = Some(entry.clone());
4610            }
4611
4612            if group_by_staging_state && is_conflict {
4613                conflict_entries.push(entry);
4614            } else if group_by_staging_state {
4615                if staging.has_staged() {
4616                    staged_entries.push(GitStatusEntry {
4617                        diff_stat: status_entry.staged_diff_stat,
4618                        ..entry.clone()
4619                    });
4620                }
4621                if staging.has_unstaged() {
4622                    unstaged_entries.push(GitStatusEntry {
4623                        diff_stat: status_entry.unstaged_diff_stat,
4624                        ..entry
4625                    });
4626                }
4627            } else if group_by_file_status && is_conflict {
4628                conflict_entries.push(entry);
4629            } else if group_by_file_status && is_new {
4630                new_entries.push(entry);
4631            } else {
4632                changed_entries.push(entry);
4633            }
4634        }
4635
4636        if conflict_entries.is_empty() {
4637            if staged_count == 1
4638                && let Some(entry) = single_staged_entry.as_ref()
4639            {
4640                if let Some(ops) = repo.pending_ops_for_path(&entry.repo_path) {
4641                    if ops.staged() {
4642                        self.single_staged_entry = single_staged_entry;
4643                    }
4644                } else {
4645                    self.single_staged_entry = single_staged_entry;
4646                }
4647            } else if repo.pending_ops_summary().item_summary.staging_count == 1
4648                && let Some(ops) = repo.pending_ops().find(|ops| ops.staging())
4649            {
4650                self.single_staged_entry =
4651                    repo.status_for_path(&ops.repo_path)
4652                        .map(|status| GitStatusEntry {
4653                            repo_path: ops.repo_path.clone(),
4654                            status: status.status,
4655                            staging: StageStatus::Staged,
4656                            diff_stat: status.diff_stat,
4657                        });
4658            }
4659        }
4660
4661        if tracked_entries.len() == 1 {
4662            self.single_tracked_entry = tracked_entries.pop();
4663        }
4664
4665        if !is_tree_view {
4666            let sort_entries = |entries: &mut Vec<GitStatusEntry>| match sort_by {
4667                GitPanelSortBy::Path => entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path)),
4668                GitPanelSortBy::Name => entries.sort_by(|a, b| {
4669                    a.repo_path
4670                        .file_name()
4671                        .cmp(&b.repo_path.file_name())
4672                        .then_with(|| a.repo_path.cmp(&b.repo_path))
4673                }),
4674            };
4675
4676            sort_entries(&mut conflict_entries);
4677            sort_entries(&mut changed_entries);
4678            sort_entries(&mut new_entries);
4679            sort_entries(&mut staged_entries);
4680            sort_entries(&mut unstaged_entries);
4681        }
4682
4683        let mut push_entry =
4684            |this: &mut Self,
4685             entry: GitListEntry,
4686             section: Section,
4687             is_visible: bool,
4688             logical_indices: Option<&mut Vec<usize>>| {
4689                if let Some(estimate) =
4690                    this.width_estimate_for_list_entry(is_tree_view, &entry, path_style)
4691                {
4692                    if estimate > max_width_estimate {
4693                        max_width_estimate = estimate;
4694                        max_width_item_index = Some(this.entries.len());
4695                    }
4696                }
4697
4698                if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
4699                {
4700                    this.projected_entries_by_path
4701                        .entry(repo_path)
4702                        .or_default()
4703                        .push(ProjectedChangeEntry {
4704                            section,
4705                            index: this.entries.len(),
4706                        });
4707                }
4708
4709                if let (Some(indices), true) = (logical_indices, is_visible) {
4710                    indices.push(this.entries.len());
4711                }
4712
4713                this.entries.push(entry);
4714            };
4715
4716        let section_entries = if group_by_staging_state {
4717            vec![
4718                (Section::Conflict, std::mem::take(&mut conflict_entries)),
4719                (Section::Staged, std::mem::take(&mut staged_entries)),
4720                (Section::Unstaged, std::mem::take(&mut unstaged_entries)),
4721            ]
4722        } else {
4723            vec![
4724                (Section::Conflict, std::mem::take(&mut conflict_entries)),
4725                (Section::Tracked, std::mem::take(&mut changed_entries)),
4726                (Section::New, std::mem::take(&mut new_entries)),
4727            ]
4728        };
4729
4730        // Keep Staged/Unstaged headers pinned even when empty (as long as there's
4731        // anything to show at all) so the layout stays stable while staging.
4732        let has_any_section_entries = section_entries
4733            .iter()
4734            .any(|(_, entries)| !entries.is_empty());
4735        let show_when_empty = |section: Section| {
4736            group_by_staging_state
4737                && has_any_section_entries
4738                && matches!(section, Section::Staged | Section::Unstaged)
4739        };
4740
4741        match &mut self.view_mode {
4742            GitPanelViewMode::Tree(tree_state) => {
4743                tree_state.logical_indices.clear();
4744                tree_state.directory_descendants.clear();
4745
4746                // This is just to get around the borrow checker
4747                // because push_entry mutably borrows self
4748                let mut tree_state = std::mem::take(tree_state);
4749
4750                for (section, entries) in section_entries {
4751                    if entries.is_empty() && !show_when_empty(section) {
4752                        continue;
4753                    }
4754
4755                    if section != Section::Tracked || group_by != GitPanelGroupBy::None {
4756                        push_entry(
4757                            self,
4758                            GitListEntry::Header(GitHeaderEntry { header: section }),
4759                            section,
4760                            true,
4761                            Some(&mut tree_state.logical_indices),
4762                        );
4763                    }
4764
4765                    if entries.is_empty() {
4766                        push_entry(
4767                            self,
4768                            GitListEntry::EmptySection(section),
4769                            section,
4770                            true,
4771                            Some(&mut tree_state.logical_indices),
4772                        );
4773                    }
4774
4775                    for (entry, is_visible) in
4776                        tree_state.build_tree_entries(section, entries, &mut seen_directories)
4777                    {
4778                        push_entry(
4779                            self,
4780                            entry,
4781                            section,
4782                            is_visible,
4783                            Some(&mut tree_state.logical_indices),
4784                        );
4785                    }
4786                }
4787
4788                tree_state
4789                    .expanded_dirs
4790                    .retain(|key, _| seen_directories.contains(key));
4791                self.tree_expanded_dirs = tree_state.expanded_dirs.clone();
4792                self.view_mode = GitPanelViewMode::Tree(tree_state);
4793            }
4794            GitPanelViewMode::Flat => {
4795                for (section, entries) in section_entries {
4796                    if entries.is_empty() && !show_when_empty(section) {
4797                        continue;
4798                    }
4799
4800                    if section != Section::Tracked || group_by != GitPanelGroupBy::None {
4801                        push_entry(
4802                            self,
4803                            GitListEntry::Header(GitHeaderEntry { header: section }),
4804                            section,
4805                            true,
4806                            None,
4807                        );
4808                    }
4809
4810                    if entries.is_empty() {
4811                        push_entry(
4812                            self,
4813                            GitListEntry::EmptySection(section),
4814                            section,
4815                            true,
4816                            None,
4817                        );
4818                    }
4819
4820                    for entry in entries {
4821                        push_entry(self, GitListEntry::Status(entry), section, true, None);
4822                    }
4823                }
4824            }
4825        }
4826
4827        self.max_width_item_index = max_width_item_index;
4828
4829        self.update_counts(repo);
4830
4831        let bulk_staging_anchor_new_index = bulk_staging
4832            .as_ref()
4833            .filter(|op| op.repo_id == repo.id)
4834            .and_then(|op| self.entry_by_path(&op.anchor));
4835        if bulk_staging_anchor_new_index == last_staged_path_prev_index
4836            && let Some(index) = bulk_staging_anchor_new_index
4837            && let Some(entry) = self.entries.get(index)
4838            && let Some(entry) = entry.status_entry()
4839            && GitPanel::stage_status_for_entry(entry, &repo)
4840                .as_bool()
4841                .unwrap_or(false)
4842        {
4843            self.bulk_staging = bulk_staging;
4844        }
4845
4846        if let Some((path, section)) = selected_change {
4847            self.selected_entry = section
4848                .and_then(|section| self.entry_by_path_in_section(&path, section))
4849                .or_else(|| self.entry_by_path(&path));
4850        }
4851        self.select_first_entry_if_none(window, cx);
4852        self.select_last_entry_if_out_of_bounds(window, cx);
4853
4854        let suggested_commit_message = self.suggest_commit_message(cx);
4855        let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
4856
4857        self.commit_editor.update(cx, |editor, cx| {
4858            editor.set_placeholder_text(&placeholder_text, window, cx)
4859        });
4860
4861        cx.notify();
4862    }
4863
4864    fn header_state(&self, header_type: Section) -> ToggleState {
4865        let (staged_count, count) = match header_type {
4866            Section::New => (self.new_staged_count, self.new_count),
4867            Section::Tracked => (self.tracked_staged_count, self.tracked_count),
4868            Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
4869            Section::Staged => (self.entry_count, self.entry_count),
4870            Section::Unstaged => (0, self.entry_count),
4871        };
4872        if staged_count == 0 {
4873            ToggleState::Unselected
4874        } else if count == staged_count {
4875            ToggleState::Selected
4876        } else {
4877            ToggleState::Indeterminate
4878        }
4879    }
4880
4881    fn section_for_entry_index(&self, ix: usize) -> Option<Section> {
4882        self.entries.get(..=ix)?.iter().rev().find_map(|entry| {
4883            if let GitListEntry::Header(header) = entry {
4884                Some(header.header)
4885            } else {
4886                None
4887            }
4888        })
4889    }
4890
4891    fn stage_intent_for_entry_index(&self, ix: usize) -> StageIntent {
4892        self.section_for_entry_index(ix)
4893            .map_or(StageIntent::Toggle, StageIntent::for_section)
4894    }
4895
4896    // A conflict that has been marked resolved (fully staged) is locked
4897    // against toggling: unstaging would rebuild the index entry from HEAD,
4898    // silently discarding the unmerged (base/ours/theirs) stages — a
4899    // round-trip git can't actually perform. The explicit git::UnstageFile
4900    // action remains as an escape hatch.
4901    fn is_resolved_conflict(&self, ix: usize, cx: &App) -> bool {
4902        if self.section_for_entry_index(ix) != Some(Section::Conflict) {
4903            return false;
4904        }
4905        let Some(entry) = self.entries.get(ix) else {
4906            return false;
4907        };
4908        let Some(repo) = self.active_repository.as_ref() else {
4909            return false;
4910        };
4911        let repo = repo.read(cx);
4912        match entry {
4913            GitListEntry::Directory(directory) => {
4914                self.stage_status_for_directory(directory, repo) == StageStatus::Staged
4915            }
4916            entry => entry.status_entry().is_some_and(|status_entry| {
4917                GitPanel::stage_status_for_entry(status_entry, repo) == StageStatus::Staged
4918            }),
4919        }
4920    }
4921
4922    fn diff_target_for_section(section: Option<Section>) -> DiffTarget {
4923        match section {
4924            Some(Section::Staged) => DiffTarget::Staged,
4925            Some(Section::Unstaged) => DiffTarget::Unstaged,
4926            _ => DiffTarget::Uncommitted,
4927        }
4928    }
4929
4930    fn update_counts(&mut self, repo: &Repository) {
4931        self.show_placeholders = false;
4932        self.conflicted_count = 0;
4933        self.conflicted_staged_count = 0;
4934        self.new_count = 0;
4935        self.tracked_count = 0;
4936        self.new_staged_count = 0;
4937        self.tracked_staged_count = 0;
4938        self.entry_count = 0;
4939        self.diff_stat_total = DiffStat::default();
4940
4941        let change_entries = self.change_entries_by_path().cloned().collect::<Vec<_>>();
4942        for status_entry in change_entries {
4943            self.entry_count += 1;
4944            if let Some(diff_stat) = status_entry.diff_stat {
4945                self.diff_stat_total.added =
4946                    self.diff_stat_total.added.saturating_add(diff_stat.added);
4947                self.diff_stat_total.deleted = self
4948                    .diff_stat_total
4949                    .deleted
4950                    .saturating_add(diff_stat.deleted);
4951            }
4952
4953            let stage_status = GitPanel::stage_status_for_entry(&status_entry, repo);
4954
4955            if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
4956                self.conflicted_count += 1;
4957                if stage_status.has_staged() {
4958                    self.conflicted_staged_count += 1;
4959                }
4960            } else if status_entry.status.is_created() {
4961                self.new_count += 1;
4962                if stage_status.has_staged() {
4963                    self.new_staged_count += 1;
4964                }
4965            } else {
4966                self.tracked_count += 1;
4967                if stage_status.has_staged() {
4968                    self.tracked_staged_count += 1;
4969                }
4970            }
4971        }
4972    }
4973
4974    pub(crate) fn has_staged_changes(&self) -> bool {
4975        self.tracked_staged_count > 0
4976            || self.new_staged_count > 0
4977            || self.conflicted_staged_count > 0
4978    }
4979
4980    pub(crate) fn has_unstaged_changes(&self) -> bool {
4981        self.change_entries_by_path()
4982            .any(|entry| entry.staging.has_unstaged())
4983    }
4984
4985    fn primary_changes_action_stages(&self) -> bool {
4986        self.entry_count == 0 || self.has_unstaged_changes()
4987    }
4988
4989    fn has_tracked_changes(&self) -> bool {
4990        self.tracked_count > 0
4991    }
4992
4993    pub fn has_unstaged_conflicts(&self) -> bool {
4994        self.change_entries_by_path()
4995            .any(|entry| entry.status.is_conflicted() && entry.staging.has_unstaged())
4996    }
4997
4998    fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
4999        let Some(workspace) = self.workspace.upgrade() else {
5000            return;
5001        };
5002        show_error_toast(workspace, action, e, cx)
5003    }
5004
5005    fn show_git_job_queue(&mut self, window: &mut Window, cx: &mut Context<Self>) {
5006        let Some(repo) = self.active_repository.as_ref() else {
5007            let workspace = self.workspace.clone();
5008            cx.defer(move |cx| {
5009                if let Some(workspace) = workspace.upgrade() {
5010                    workspace.update(cx, |workspace, cx| {
5011                        struct GitJobQueueToast;
5012                        workspace.show_toast(
5013                            workspace::Toast::new(
5014                                NotificationId::unique::<GitJobQueueToast>(),
5015                                "No active repository",
5016                            )
5017                            .autohide(),
5018                            cx,
5019                        );
5020                    });
5021                }
5022            });
5023            return;
5024        };
5025
5026        let repo_path = repo.read(cx).work_directory_abs_path.display().to_string();
5027        let queue_value = repo.read(cx).job_debug_queue().to_debug_value();
5028        let title = format!("Git Job Queue: {repo_path}");
5029
5030        let json_language = self.project.read(cx).languages().language_for_name("JSON");
5031        let project = self.project.clone();
5032        let workspace = self.workspace.clone();
5033
5034        window
5035            .spawn(cx, async move |cx| {
5036                let json_language = json_language.await.ok();
5037
5038                // Best-effort: gather runtime diagnostics off the main thread.
5039                // Any failure inside `gather` is logged and produces an empty
5040                // section; this `.await` itself cannot meaningfully fail and
5041                // must never prevent us from showing the queue dump.
5042                let diagnostics = cx
5043                    .background_spawn(crate::git_runtime_diagnostics::gather())
5044                    .await;
5045
5046                let mut combined = queue_value;
5047                if let serde_json::Value::Object(ref mut map) = combined
5048                    && let serde_json::Value::Object(diag_map) = diagnostics
5049                    && !diag_map.is_empty()
5050                {
5051                    map.insert(
5052                        "runtime_diagnostics".into(),
5053                        serde_json::Value::Object(diag_map),
5054                    );
5055                }
5056
5057                let text = serde_json::to_string_pretty(&combined).unwrap_or_default();
5058
5059                let buffer = project
5060                    .update(cx, |project, cx| {
5061                        project.create_buffer(json_language, false, cx)
5062                    })
5063                    .await?;
5064
5065                buffer.update(cx, |buffer, cx| {
5066                    buffer.set_text(text, cx);
5067                    buffer.set_capability(language::Capability::ReadWrite, cx);
5068                });
5069
5070                workspace.update_in(cx, |workspace, window, cx| {
5071                    let buffer =
5072                        cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.clone()));
5073
5074                    workspace.add_item_to_active_pane(
5075                        Box::new(cx.new(|cx| {
5076                            let mut editor =
5077                                Editor::for_multibuffer(buffer, Some(project.clone()), window, cx);
5078                            editor.set_breadcrumb_header(title);
5079                            editor.disable_mouse_wheel_zoom();
5080                            editor
5081                        })),
5082                        None,
5083                        true,
5084                        window,
5085                        cx,
5086                    );
5087                })?;
5088
5089                anyhow::Ok(())
5090            })
5091            .detach_and_log_err(cx);
5092    }
5093
5094    fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
5095    where
5096        E: std::fmt::Debug + std::fmt::Display,
5097    {
5098        if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
5099            let _ = workspace.update(cx, |workspace, cx| {
5100                workspace.show_error(format!("Failed to generate commit message: {err}"), cx);
5101            });
5102        }
5103    }
5104
5105    fn show_remote_output(
5106        &mut self,
5107        action: RemoteAction,
5108        info: RemoteCommandOutput,
5109        cx: &mut Context<Self>,
5110    ) {
5111        let Some(workspace) = self.workspace.upgrade() else {
5112            return;
5113        };
5114
5115        let is_push = matches!(action, RemoteAction::Push(_, _));
5116
5117        workspace.update(cx, |workspace, cx| {
5118            let SuccessMessage { message, style } = remote_output::format_output(&action, info);
5119            let workspace_weak = cx.weak_entity();
5120            let operation = action.name();
5121
5122            let status_toast = StatusToast::new(message, cx, move |this, _cx| {
5123                use remote_output::SuccessStyle::*;
5124                let this = this.icon(
5125                    Icon::new(IconName::GitBranch)
5126                        .size(IconSize::Small)
5127                        .color(Color::Muted),
5128                );
5129                match (style, is_push) {
5130                    (PushPrLink { label, url }, _) => {
5131                        this.action(label, move |_window, cx| cx.open_url(&url))
5132                    }
5133                    (Toast | ToastWithLog { .. }, true) => {
5134                        // If we were not able to parse a valid URL from the
5135                        // output of a push command, we'll simply dispatch the
5136                        // generic `CreatePullRequest` action when the toast
5137                        // button is pressed.
5138                        this.action("Create Pull Request", move |window, cx| {
5139                            window
5140                                .dispatch_action(Box::new(zed_actions::git::CreatePullRequest), cx);
5141                        })
5142                    }
5143                    (Toast, false) => this,
5144                    (ToastWithLog { output }, false) => {
5145                        this.action("View Log", move |window, cx| {
5146                            let output = output.clone();
5147                            let output =
5148                                format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
5149                            workspace_weak
5150                                .update(cx, move |workspace, cx| {
5151                                    open_output(operation, workspace, &output, window, cx)
5152                                })
5153                                .ok();
5154                        })
5155                    }
5156                }
5157                .dismiss_button(true)
5158            });
5159            workspace.toggle_status_toast(status_toast, cx)
5160        });
5161    }
5162
5163    pub fn can_commit(&self) -> bool {
5164        (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
5165    }
5166
5167    pub fn can_stage_all(&self) -> bool {
5168        self.has_unstaged_changes()
5169    }
5170
5171    pub fn can_unstage_all(&self) -> bool {
5172        self.has_staged_changes()
5173    }
5174
5175    /// Computes tree indentation depths for visible entries in the given range.
5176    /// Used by indent guides to render vertical connector lines in tree view.
5177    fn compute_visible_depths(&self, range: Range<usize>) -> SmallVec<[usize; 64]> {
5178        let GitPanelViewMode::Tree(state) = &self.view_mode else {
5179            return SmallVec::new();
5180        };
5181
5182        range
5183            .map(|ix| {
5184                state
5185                    .logical_indices
5186                    .get(ix)
5187                    .and_then(|&entry_ix| self.entries.get(entry_ix))
5188                    .map_or(0, |entry| entry.depth())
5189            })
5190            .collect()
5191    }
5192
5193    fn status_width_estimate(
5194        tree_view: bool,
5195        entry: &GitStatusEntry,
5196        path_style: PathStyle,
5197        depth: usize,
5198    ) -> usize {
5199        if tree_view {
5200            Self::item_width_estimate(0, entry.display_name(path_style).len(), depth)
5201        } else {
5202            Self::item_width_estimate(
5203                entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
5204                entry.display_name(path_style).len(),
5205                0,
5206            )
5207        }
5208    }
5209
5210    fn width_estimate_for_list_entry(
5211        &self,
5212        tree_view: bool,
5213        entry: &GitListEntry,
5214        path_style: PathStyle,
5215    ) -> Option<usize> {
5216        match entry {
5217            GitListEntry::Status(status) => Some(Self::status_width_estimate(
5218                tree_view, status, path_style, 0,
5219            )),
5220            GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate(
5221                tree_view,
5222                &status.entry,
5223                path_style,
5224                status.depth,
5225            )),
5226            GitListEntry::Directory(dir) => {
5227                Some(Self::item_width_estimate(0, dir.name.len(), dir.depth))
5228            }
5229            GitListEntry::Header(_) | GitListEntry::EmptySection(_) => None,
5230        }
5231    }
5232
5233    fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize {
5234        path + file_name + depth * 2
5235    }
5236
5237    fn render_view_options_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
5238        let focus_handle = self.focus_handle.clone();
5239
5240        PopoverMenu::new(id.into())
5241            .trigger_with_tooltip(
5242                IconButton::new("view-options-menu-trigger", IconName::Filter)
5243                    .icon_size(IconSize::Small),
5244                Tooltip::text("View Options"),
5245            )
5246            .menu(move |window, cx| {
5247                Some(git_panel_view_options_menu(
5248                    focus_handle.clone(),
5249                    window,
5250                    cx,
5251                ))
5252            })
5253            .anchor(Anchor::TopRight)
5254    }
5255
5256    pub(crate) fn render_generate_commit_message_button(
5257        &self,
5258        cx: &Context<Self>,
5259    ) -> Option<AnyElement> {
5260        if !agent_settings::AgentSettings::get_global(cx).enabled(cx) {
5261            return None;
5262        }
5263
5264        if self.generate_commit_message_task.is_some() {
5265            return Some(
5266                h_flex()
5267                    .gap_1()
5268                    .child(
5269                        IconButton::new("cancel-generate-commit-message", IconName::Stop)
5270                            .icon_color(Color::Error)
5271                            .icon_size(IconSize::Small)
5272                            .style(ButtonStyle::Tinted(TintColor::Error))
5273                            .tooltip(Tooltip::text("Cancel Commit Message Generation"))
5274                            .on_click(cx.listener(|this, _event, _window, cx| {
5275                                this.generate_commit_message_task.take();
5276                                cx.notify();
5277                            })),
5278                    )
5279                    .child(
5280                        Label::new("Generating Commit…")
5281                            .size(LabelSize::Small)
5282                            .color(Color::Muted),
5283                    )
5284                    .into_any_element(),
5285            );
5286        }
5287
5288        let model_registry = LanguageModelRegistry::read_global(cx);
5289        let has_commit_model_configuration_error = model_registry
5290            .configuration_error(model_registry.commit_message_model(cx), cx)
5291            .is_some();
5292        let can_commit = self.can_commit();
5293
5294        let editor_focus_handle = self.commit_editor.focus_handle(cx);
5295
5296        let button = IconButton::new("generate-commit-message", IconName::AiEdit)
5297            .shape(ui::IconButtonShape::Square)
5298            .icon_color(if has_commit_model_configuration_error {
5299                Color::Disabled
5300            } else {
5301                Color::Muted
5302            })
5303            .disabled(!can_commit || has_commit_model_configuration_error)
5304            .on_click(cx.listener(move |this, _event, _window, cx| {
5305                this.generate_commit_message(cx);
5306            }));
5307
5308        let button = if can_commit && has_commit_model_configuration_error {
5309            button.hoverable_tooltip(move |_window, cx| {
5310                cx.new(|_| GenerateCommitMessageConfigurationTooltip).into()
5311            })
5312        } else {
5313            button.tooltip(move |_window, cx| {
5314                if !can_commit {
5315                    Tooltip::simple("No Changes to Commit", cx)
5316                } else {
5317                    Tooltip::for_action_in(
5318                        "Generate Commit Message",
5319                        &git::GenerateCommitMessage,
5320                        &editor_focus_handle,
5321                        cx,
5322                    )
5323                }
5324            })
5325        };
5326
5327        Some(button.into_any_element())
5328    }
5329
5330    pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
5331        let potential_co_authors = self.potential_co_authors(cx);
5332
5333        let (tooltip_label, icon) = if self.add_coauthors {
5334            ("Remove co-authored-by", IconName::Person)
5335        } else {
5336            ("Add co-authored-by", IconName::UserCheck)
5337        };
5338
5339        if potential_co_authors.is_empty() {
5340            None
5341        } else {
5342            Some(
5343                IconButton::new("co-authors", icon)
5344                    .shape(ui::IconButtonShape::Square)
5345                    .icon_color(Color::Disabled)
5346                    .selected_icon_color(Color::Selected)
5347                    .toggle_state(self.add_coauthors)
5348                    .tooltip(move |_, cx| {
5349                        let title = format!(
5350                            "{}:{}{}",
5351                            tooltip_label,
5352                            if potential_co_authors.len() == 1 {
5353                                ""
5354                            } else {
5355                                "\n"
5356                            },
5357                            potential_co_authors
5358                                .iter()
5359                                .map(|(name, email)| format!(" {} <{}>", name, email))
5360                                .join("\n")
5361                        );
5362                        Tooltip::simple(title, cx)
5363                    })
5364                    .on_click(cx.listener(|this, _, _, cx| {
5365                        this.add_coauthors = !this.add_coauthors;
5366                        cx.notify();
5367                    }))
5368                    .into_any_element(),
5369            )
5370        }
5371    }
5372
5373    fn render_git_commit_menu(
5374        &self,
5375        id: impl Into<ElementId>,
5376        keybinding_target: Option<FocusHandle>,
5377        disabled: bool,
5378        cx: &mut Context<Self>,
5379    ) -> impl IntoElement {
5380        let menu_open = self.commit_menu_handle.is_deployed();
5381
5382        PopoverMenu::new(id.into())
5383            .trigger(
5384                crate::render_split_button_chevron_trigger("commit-split-button-right", menu_open)
5385                    .disabled(disabled),
5386            )
5387            .with_handle(self.commit_menu_handle.clone())
5388            .menu({
5389                let git_panel = cx.entity();
5390                let has_previous_commit = self.head_commit(cx).is_some();
5391                let amend = self.amend_pending();
5392                let signoff = self.signoff_enabled;
5393                let skip_hooks = self.skip_hooks_enabled;
5394
5395                move |window, cx| {
5396                    Some(ContextMenu::build(window, cx, |context_menu, _, _| {
5397                        context_menu
5398                            .when_some(keybinding_target.clone(), |el, keybinding_target| {
5399                                el.context(keybinding_target)
5400                            })
5401                            .when(has_previous_commit, |this| {
5402                                this.toggleable_entry(
5403                                    "Amend",
5404                                    amend,
5405                                    IconPosition::Start,
5406                                    Some(Box::new(Amend)),
5407                                    {
5408                                        let git_panel = git_panel.downgrade();
5409                                        move |_, cx| {
5410                                            git_panel
5411                                                .update(cx, |git_panel, cx| {
5412                                                    git_panel.toggle_amend_pending(cx);
5413                                                })
5414                                                .ok();
5415                                        }
5416                                    },
5417                                )
5418                            })
5419                            .toggleable_entry(
5420                                "Signoff",
5421                                signoff,
5422                                IconPosition::Start,
5423                                Some(Box::new(Signoff)),
5424                                move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
5425                            )
5426                            .item(
5427                                ContextMenuEntry::new("Skip Hooks")
5428                                    .toggleable(IconPosition::Start, skip_hooks)
5429                                    .action(Box::new(SkipHooks))
5430                                    .handler(move |window, cx| {
5431                                        window.dispatch_action(Box::new(SkipHooks), cx)
5432                                    })
5433                                    .documentation_aside(DocumentationSide::Left, |_| {
5434                                        Label::new("git commit --no-verify").into_any_element()
5435                                    }),
5436                            )
5437                    }))
5438                }
5439            })
5440            .anchor(Anchor::TopRight)
5441            .offset(gpui::Point {
5442                x: px(0.),
5443                y: px(2.),
5444            })
5445    }
5446
5447    pub fn is_generating_commit_message(&self) -> bool {
5448        self.generate_commit_message_task.is_some()
5449    }
5450
5451    pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
5452        if self.generate_commit_message_task.is_some() {
5453            (false, "Generating commit message...")
5454        } else if self.has_unstaged_conflicts() {
5455            (false, "You must resolve conflicts before committing")
5456        } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending {
5457            (false, "No changes to commit")
5458        } else if self.pending_commit.is_some() {
5459            (false, "Commit in progress")
5460        } else if !self.has_commit_message(cx) {
5461            (false, "No commit message")
5462        } else if !self.has_write_access(cx) {
5463            (false, "You do not have write access to this project")
5464        } else {
5465            (true, self.commit_button_title())
5466        }
5467    }
5468
5469    pub fn commit_button_title(&self) -> &'static str {
5470        if self.amend_pending {
5471            if self.has_staged_changes() {
5472                "Amend"
5473            } else if self.has_tracked_changes() {
5474                "Amend Tracked"
5475            } else {
5476                "Amend"
5477            }
5478        } else if self.has_staged_changes() {
5479            "Commit"
5480        } else {
5481            "Commit Tracked"
5482        }
5483    }
5484
5485    fn toggle_fill_commit_editor(
5486        &mut self,
5487        _: &ToggleFillCommitEditor,
5488        _window: &mut Window,
5489        cx: &mut Context<Self>,
5490    ) {
5491        self.commit_editor_expanded = !self.commit_editor_expanded;
5492        self.commit_editor.update(cx, |editor, _cx| {
5493            if self.commit_editor_expanded {
5494                editor.set_mode(EditorMode::Full {
5495                    scale_ui_elements_with_buffer_font_size: false,
5496                    show_active_line_background: false,
5497                    sizing_behavior: SizingBehavior::ExcludeOverscrollMargin,
5498                })
5499            } else {
5500                editor.set_mode(EditorMode::AutoHeight {
5501                    min_lines: MAX_PANEL_EDITOR_LINES,
5502                    max_lines: Some(MAX_PANEL_EDITOR_LINES),
5503                })
5504            }
5505        });
5506
5507        cx.notify();
5508    }
5509
5510    fn expand_commit_editor(
5511        &mut self,
5512        _: &ExpandCommitEditor,
5513        window: &mut Window,
5514        cx: &mut Context<Self>,
5515    ) {
5516        let workspace = self.workspace.clone();
5517        window.defer(cx, move |window, cx| {
5518            workspace
5519                .update(cx, |workspace, cx| {
5520                    CommitModal::toggle(workspace, None, window, cx)
5521                })
5522                .ok();
5523        })
5524    }
5525
5526    fn render_git_changes_actions_menu(
5527        &self,
5528        id: impl Into<ElementId>,
5529        _cx: &mut Context<Self>,
5530    ) -> impl IntoElement {
5531        let has_tracked_changes = self.has_tracked_changes();
5532        let has_staged_changes = self.has_staged_changes();
5533        let has_unstaged_changes = self.has_unstaged_changes();
5534        let has_new_changes = self.new_count > 0;
5535        let has_stash_items = self.stash_entries.entries.len() > 0;
5536
5537        let focus_handle = self.focus_handle.clone();
5538        let menu_open = self.changes_actions_menu_handle.is_deployed();
5539
5540        PopoverMenu::new(id.into())
5541            .trigger(crate::render_split_button_chevron_trigger(
5542                "changes-actions-split-button-right",
5543                menu_open,
5544            ))
5545            .with_handle(self.changes_actions_menu_handle.clone())
5546            .menu(move |window, cx| {
5547                Some(git_panel_context_menu(
5548                    has_tracked_changes,
5549                    has_staged_changes,
5550                    has_unstaged_changes,
5551                    has_new_changes,
5552                    has_stash_items,
5553                    focus_handle.clone(),
5554                    window,
5555                    cx,
5556                ))
5557            })
5558            .anchor(Anchor::TopRight)
5559            .offset(gpui::Point {
5560                x: px(0.),
5561                y: px(2.),
5562            })
5563    }
5564
5565    fn render_git_changes_actions_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5566        let (text, action, stage, tooltip) = if self.primary_changes_action_stages() {
5567            ("Stage All", StageAll.boxed_clone(), true, "git add --all")
5568        } else {
5569            ("Unstage All", UnstageAll.boxed_clone(), false, "git reset")
5570        };
5571
5572        SplitButton::new(
5573            ButtonLike::new_rounded_left("git-changes-actions-split-button-left")
5574                .layer(ElevationIndex::ModalSurface)
5575                .size(ButtonSize::Compact)
5576                .child(Label::new(text).size(LabelSize::Small).mr_0p5())
5577                .tooltip(Tooltip::for_action_title_in(
5578                    tooltip,
5579                    action.as_ref(),
5580                    &self.focus_handle,
5581                ))
5582                .disabled(self.entry_count == 0)
5583                .on_click({
5584                    let git_panel = cx.weak_entity();
5585                    move |_, _, cx| {
5586                        git_panel
5587                            .update(cx, |git_panel, cx| {
5588                                git_panel.change_all_files_stage(stage, cx);
5589                            })
5590                            .ok();
5591                    }
5592                }),
5593            self.render_git_changes_actions_menu("git-changes-actions-split-button-menu", cx)
5594                .into_any_element(),
5595        )
5596    }
5597
5598    fn render_changes_header(
5599        &self,
5600        _window: &mut Window,
5601        cx: &mut Context<Self>,
5602    ) -> Option<impl IntoElement> {
5603        if matches!(self.git_access, Some(GitAccess::No)) {
5604            return None;
5605        }
5606
5607        self.active_repository.as_ref()?;
5608
5609        let diff_stat_total = self.diff_stat_total;
5610
5611        Some(
5612            h_flex()
5613                .min_h(Tab::container_height(cx))
5614                .w_full()
5615                .pl_1()
5616                .pr_2()
5617                .flex_none()
5618                .flex_wrap()
5619                .gap_1()
5620                .justify_between()
5621                .child(
5622                    ButtonLike::new("diff-button")
5623                        .child(
5624                            h_flex()
5625                                .gap_1()
5626                                .child(
5627                                    Icon::new(IconName::Diff)
5628                                        .size(IconSize::Small)
5629                                        .color(Color::Muted),
5630                                )
5631                                .child(
5632                                    Label::new("View Diff")
5633                                        .size(LabelSize::Small)
5634                                        .color(Color::Muted),
5635                                )
5636                                .when(
5637                                    GitPanelSettings::get_global(cx).diff_stats
5638                                        && diff_stat_total != DiffStat::default(),
5639                                    |this| {
5640                                        this.child(ui::DiffStat::new(
5641                                            "changes-diff-stat-total",
5642                                            diff_stat_total.added as usize,
5643                                            diff_stat_total.deleted as usize,
5644                                        ))
5645                                    },
5646                                ),
5647                        )
5648                        .tooltip(Tooltip::for_action_title_in(
5649                            "View Diff",
5650                            &Diff,
5651                            &self.focus_handle,
5652                        ))
5653                        .on_click(|_, _, cx| {
5654                            cx.defer(|cx| {
5655                                cx.dispatch_action(&Diff);
5656                            })
5657                        }),
5658                )
5659                .child(
5660                    h_flex()
5661                        .gap_1()
5662                        .child(self.render_view_options_menu("view_options_menu"))
5663                        .child(self.render_git_changes_actions_button(cx)),
5664                ),
5665        )
5666    }
5667
5668    pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
5669        let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
5670        if !self.can_push_and_pull(cx) {
5671            return None;
5672        }
5673        Some(
5674            h_flex()
5675                .gap_1()
5676                .flex_shrink_0()
5677                .when_some(branch, |this, branch| {
5678                    let focus_handle = Some(self.focus_handle(cx));
5679
5680                    this.children(render_remote_button(
5681                        "remote-button",
5682                        &branch,
5683                        focus_handle,
5684                        true,
5685                        self.pending_remote_operation,
5686                        self.remote_action_menu_handle.clone(),
5687                    ))
5688                })
5689                .into_any_element(),
5690        )
5691    }
5692
5693    pub fn render_footer(
5694        &self,
5695        window: &mut Window,
5696        cx: &mut Context<Self>,
5697    ) -> Option<impl IntoElement> {
5698        let active_repository = self.active_repository.clone()?;
5699        let settings = ThemeSettings::get_global(cx);
5700        let panel_editor_style =
5701            git_commit_editor_style(settings.git_commit_buffer_font_size(cx), cx);
5702        let enable_coauthors = self.render_co_authors(cx);
5703        let editor_focus_handle = self.commit_editor.focus_handle(cx);
5704        let branch = active_repository.read(cx).branch.clone();
5705        let head_commit = active_repository.read(cx).head_commit.clone();
5706
5707        let git_panel = cx.entity();
5708        let display_name = SharedString::from(Arc::from(
5709            active_repository
5710                .read(cx)
5711                .display_name()
5712                .trim_end_matches("/"),
5713        ));
5714        let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
5715            editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
5716        });
5717
5718        let max_title_length = GitPanelSettings::get_global(cx).commit_title_max_length;
5719        let title_exceeds_limit = if max_title_length > 0 {
5720            self.commit_editor
5721                .read(cx)
5722                .text(cx)
5723                .lines()
5724                .next()
5725                .is_some_and(|title| commit_title_exceeds_limit(title, max_title_length))
5726        } else {
5727            false
5728        };
5729
5730        let vertical_buttons = v_flex()
5731            .h_full()
5732            .gap_px()
5733            .p_1p5()
5734            .opacity(0.6)
5735            .hover(|s| s.opacity(1.0))
5736            .child(
5737                IconButton::new("expand-commit-editor", IconName::MaximizeAlt)
5738                    .icon_size(IconSize::Small)
5739                    .tooltip({
5740                        move |_window, cx| {
5741                            Tooltip::for_action_in(
5742                                "Open Commit Modal",
5743                                &git::ExpandCommitEditor,
5744                                &editor_focus_handle,
5745                                cx,
5746                            )
5747                        }
5748                    })
5749                    .on_click(cx.listener({
5750                        move |_, _, window, cx| {
5751                            window.dispatch_action(git::ExpandCommitEditor.boxed_clone(), cx)
5752                        }
5753                    })),
5754            )
5755            .child({
5756                let (icon, label) = if self.commit_editor_expanded {
5757                    (IconName::Minimize, "Collapse Commit Editor")
5758                } else {
5759                    (IconName::Maximize, "Expand Commit Editor")
5760                };
5761                let focus_handle = self.focus_handle.clone();
5762
5763                IconButton::new("fill-commit-editor", icon)
5764                    .icon_size(IconSize::Small)
5765                    .tooltip({
5766                        move |_window, cx| {
5767                            Tooltip::for_action_in(
5768                                label,
5769                                &git::ToggleFillCommitEditor,
5770                                &focus_handle,
5771                                cx,
5772                            )
5773                        }
5774                    })
5775                    .on_click(cx.listener({
5776                        move |_, _, window, cx| {
5777                            window.dispatch_action(git::ToggleFillCommitEditor.boxed_clone(), cx)
5778                        }
5779                    }))
5780            });
5781
5782        let footer = v_flex()
5783            .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0())
5784            .child(PanelRepoFooter::new(
5785                display_name,
5786                branch,
5787                head_commit,
5788                Some(git_panel),
5789            ))
5790            .when(title_exceeds_limit, |this| {
5791                this.child(
5792                    h_flex()
5793                        .px_2()
5794                        .py_1()
5795                        .gap_1()
5796                        .border_t_1()
5797                        .border_color(cx.theme().status().warning_border)
5798                        .bg(cx.theme().status().warning_background.opacity(0.5))
5799                        .child(
5800                            Icon::new(IconName::Warning)
5801                                .size(IconSize::XSmall)
5802                                .color(Color::Warning),
5803                        )
5804                        .child(
5805                            Label::new(format!(
5806                                "Commit message title exceeds {max_title_length}-character limit."
5807                            ))
5808                            .size(LabelSize::Small),
5809                        ),
5810                )
5811            })
5812            .child(
5813                panel_editor_container(window, cx)
5814                    .id("commit-editor-container")
5815                    .w_full()
5816                    .when(self.commit_editor_expanded, |this| this.flex_1().min_h_0())
5817                    .border_t_1()
5818                    .border_color(if title_exceeds_limit {
5819                        cx.theme().status().warning_border
5820                    } else {
5821                        cx.theme().colors().border
5822                    })
5823                    .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
5824                        window.focus(&this.commit_editor.focus_handle(cx), cx);
5825                    }))
5826                    .child(
5827                        h_flex()
5828                            .size_full()
5829                            .child(
5830                                div()
5831                                    .pt_2()
5832                                    .px_2()
5833                                    .h_full()
5834                                    .flex_grow_1()
5835                                    .cursor_text()
5836                                    .on_action(|&zed_actions::editor::MoveUp, _, cx| {
5837                                        cx.stop_propagation();
5838                                    })
5839                                    .on_action(|&zed_actions::editor::MoveDown, _, cx| {
5840                                        cx.stop_propagation();
5841                                    })
5842                                    .child(EditorElement::new(
5843                                        &self.commit_editor,
5844                                        panel_editor_style,
5845                                    )),
5846                            )
5847                            .child(vertical_buttons),
5848                    )
5849                    .child(
5850                        h_flex()
5851                            .id("commit-footer")
5852                            .w_full()
5853                            .p_1p5()
5854                            .border_t_1()
5855                            .when(editor_is_long, |el| {
5856                                el.border_color(cx.theme().colors().border_variant)
5857                            })
5858                            .justify_between()
5859                            .child(
5860                                self.render_generate_commit_message_button(cx)
5861                                    .unwrap_or_else(|| div().into_any_element()),
5862                            )
5863                            .child(
5864                                h_flex()
5865                                    .gap_0p5()
5866                                    .children(enable_coauthors)
5867                                    .child(self.render_commit_button(cx)),
5868                            ),
5869                    ),
5870            );
5871
5872        Some(footer)
5873    }
5874
5875    fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
5876        let (can_commit, tooltip) = self.configure_commit_button(cx);
5877        let title = self.commit_button_title();
5878        let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
5879        let options = self.commit_options();
5880        let amend = options.amend;
5881        let signoff = options.signoff;
5882        let no_verify = options.no_verify;
5883
5884        let label_color = if self.pending_commit.is_some() {
5885            Color::Disabled
5886        } else {
5887            Color::Default
5888        };
5889
5890        h_flex()
5891            .id("commit-wrapper")
5892            .on_hover(cx.listener(move |this, hovered, _, cx| {
5893                this.show_placeholders =
5894                    *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
5895                cx.notify()
5896            }))
5897            .child(SplitButton::new(
5898                ButtonLike::new_rounded_left(format!("split-button-left-{}", title))
5899                    .layer(ElevationIndex::ModalSurface)
5900                    .size(ButtonSize::Compact)
5901                    .disabled(!can_commit || self.modal_open)
5902                    .child(
5903                        Label::new(title)
5904                            .size(LabelSize::Small)
5905                            .color(label_color)
5906                            .mr_0p5(),
5907                    )
5908                    .on_click({
5909                        let git_panel = cx.weak_entity();
5910                        move |_, window, cx| {
5911                            telemetry::event!("Git Committed", source = "Git Panel");
5912                            git_panel
5913                                .update(cx, |git_panel, cx| {
5914                                    let options = git_panel.commit_options();
5915                                    git_panel.commit_changes(options, window, cx);
5916                                })
5917                                .ok();
5918                        }
5919                    })
5920                    .tooltip({
5921                        let handle = commit_tooltip_focus_handle.clone();
5922                        move |_window, cx| {
5923                            if can_commit {
5924                                Tooltip::with_meta_in(
5925                                    tooltip,
5926                                    Some(&git::Commit),
5927                                    format!(
5928                                        "git commit{}{}{}",
5929                                        if amend { " --amend" } else { "" },
5930                                        if signoff { " --signoff" } else { "" },
5931                                        if no_verify { " --no-verify" } else { "" }
5932                                    ),
5933                                    &handle.clone(),
5934                                    cx,
5935                                )
5936                            } else {
5937                                Tooltip::simple(tooltip, cx)
5938                            }
5939                        }
5940                    }),
5941                self.render_git_commit_menu(
5942                    ElementId::Name(format!("split-button-right-{}", title).into()),
5943                    Some(commit_tooltip_focus_handle),
5944                    self.generate_commit_message_task.is_some(),
5945                    cx,
5946                )
5947                .into_any_element(),
5948            ))
5949    }
5950
5951    fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
5952        h_flex()
5953            .py_1p5()
5954            .px_2()
5955            .gap_1p5()
5956            .justify_between()
5957            .border_t_1()
5958            .border_color(cx.theme().colors().border.opacity(0.8))
5959            .child(
5960                div()
5961                    .flex_grow_1()
5962                    .overflow_hidden()
5963                    .max_w(relative(0.85))
5964                    .child(
5965                        Label::new("This will update your most recent commit.")
5966                            .size(LabelSize::Small)
5967                            .truncate(),
5968                    ),
5969            )
5970            .child(
5971                Button::new("cancel", "Cancel")
5972                    .label_size(LabelSize::Small)
5973                    .layer(ElevationIndex::ModalSurface)
5974                    .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
5975            )
5976    }
5977
5978    fn render_previous_commit(
5979        &self,
5980        _window: &mut Window,
5981        cx: &mut Context<Self>,
5982    ) -> Option<impl IntoElement> {
5983        let active_repository = self.active_repository.as_ref()?;
5984        let branch = active_repository.read(cx).branch.as_ref()?;
5985        let commit = branch.most_recent_commit.as_ref()?.clone();
5986        let workspace = self.workspace.clone();
5987        let this = cx.entity();
5988
5989        Some(
5990            h_flex()
5991                .p_1p5()
5992                .gap_1p5()
5993                .justify_between()
5994                .border_t_1()
5995                .border_color(cx.theme().colors().border.opacity(0.8))
5996                .child(
5997                    div()
5998                        .id("commit-msg-hover")
5999                        .cursor_pointer()
6000                        .px_1()
6001                        .rounded_sm()
6002                        .line_clamp(1)
6003                        .hover(|s| s.bg(cx.theme().colors().element_hover))
6004                        .child(
6005                            Label::new(commit.subject.clone())
6006                                .size(LabelSize::Small)
6007                                .truncate(),
6008                        )
6009                        .on_click({
6010                            let commit = commit.clone();
6011                            let repo = active_repository.downgrade();
6012                            move |_, window, cx| {
6013                                CommitView::open(
6014                                    commit.sha.to_string(),
6015                                    repo.clone(),
6016                                    workspace.clone(),
6017                                    None,
6018                                    None,
6019                                    window,
6020                                    cx,
6021                                );
6022                            }
6023                        })
6024                        .hoverable_tooltip({
6025                            let repo = active_repository.clone();
6026                            move |window, cx| {
6027                                GitPanelMessageTooltip::new(
6028                                    this.clone(),
6029                                    commit.sha.clone(),
6030                                    repo.clone(),
6031                                    window,
6032                                    cx,
6033                                )
6034                                .into()
6035                            }
6036                        }),
6037                )
6038                .child(
6039                    h_flex()
6040                        .gap_0p5()
6041                        .when(commit.has_parent, |this| {
6042                            let has_unstaged = self.has_unstaged_changes();
6043                            this.child(
6044                                IconButton::new("undo", IconName::Undo)
6045                                    .icon_size(IconSize::Small)
6046                                    .tooltip(move |_window, cx| {
6047                                        Tooltip::with_meta(
6048                                            "Uncommit",
6049                                            Some(&git::Uncommit),
6050                                            if has_unstaged {
6051                                                "git reset HEAD^ --soft"
6052                                            } else {
6053                                                "git reset HEAD^"
6054                                            },
6055                                            cx,
6056                                        )
6057                                    })
6058                                    .on_click(
6059                                        cx.listener(|this, _, window, cx| {
6060                                            this.uncommit(window, cx)
6061                                        }),
6062                                    ),
6063                            )
6064                        })
6065                        .child(
6066                            IconButton::new("git-graph-button", IconName::GitGraph)
6067                                .icon_size(IconSize::Small)
6068                                .tooltip(|_window, cx| {
6069                                    Tooltip::for_action(
6070                                        "Open Git Graph",
6071                                        &crate::git_graph::Open,
6072                                        cx,
6073                                    )
6074                                })
6075                                .on_click(|_, window, cx| {
6076                                    window.dispatch_action(crate::git_graph::Open.boxed_clone(), cx)
6077                                }),
6078                        ),
6079                ),
6080        )
6081    }
6082
6083    fn render_tab_bar(&self, cx: &mut Context<Self>) -> impl IntoElement {
6084        let active_tab = self.active_tab;
6085
6086        let focus_handle = self.focus_handle.clone();
6087        let tab = |id: ElementId,
6088                   active: bool,
6089                   show_changes: bool,
6090                   label: SharedString,
6091                   set_active_tab: GitPanelTab,
6092                   tooltip_action: Box<dyn Action>| {
6093            let focus_handle = focus_handle.clone();
6094
6095            h_flex()
6096                .cursor_pointer()
6097                .id(id)
6098                .h_full()
6099                .py_1()
6100                .gap_1()
6101                .flex_1()
6102                .justify_center()
6103                .hover(|s| s.bg(cx.theme().colors().element_hover))
6104                .border_b_1()
6105                .when(!active, |s| {
6106                    s.bg(cx.theme().colors().editor_background.opacity(0.6))
6107                        .border_color(cx.theme().colors().border.opacity(0.6))
6108                })
6109                .child(Label::new(label.clone()).when(!active, |this| this.color(Color::Muted)))
6110                .when(show_changes && self.changes_count > 0, |this| {
6111                    this.child(
6112                        Label::new(format!("({})", self.changes_count))
6113                            .size(LabelSize::Small)
6114                            .color(Color::Muted),
6115                    )
6116                })
6117                .tooltip(Tooltip::for_action_title_in(
6118                    format!("Toggle {} Tab", label),
6119                    tooltip_action.as_ref(),
6120                    &focus_handle,
6121                ))
6122                .on_click(cx.listener(move |this, _, window, cx| {
6123                    this.set_active_tab(set_active_tab, window, cx)
6124                }))
6125        };
6126
6127        h_flex()
6128            .relative()
6129            .h(Tab::container_height(cx))
6130            .w_full()
6131            .child(tab(
6132                ElementId::Name("changes-tab".into()),
6133                active_tab == GitPanelTab::Changes,
6134                true,
6135                "Changes".into(),
6136                GitPanelTab::Changes,
6137                ActivateChangesTab.boxed_clone(),
6138            ))
6139            .child(
6140                Divider::vertical()
6141                    .color(ui::DividerColor::BorderFaded)
6142                    .h_full(),
6143            )
6144            .child(tab(
6145                ElementId::Name("history-tab".into()),
6146                active_tab != GitPanelTab::Changes,
6147                false,
6148                "History".into(),
6149                GitPanelTab::History,
6150                ActivateHistoryTab.boxed_clone(),
6151            ))
6152    }
6153
6154    fn render_history_tab(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
6155        v_flex().flex_1().size_full().overflow_hidden().map(|this| {
6156            let has_repo = self.active_repository.is_some();
6157            match &self.commit_history {
6158                _ if !has_repo => {
6159                    this.child(Self::render_history_placeholder("No repository found"))
6160                }
6161                CommitHistory::Error(_) => this.child(Self::render_history_placeholder(
6162                    "Failed to load commit history",
6163                )),
6164                CommitHistory::Loading => {
6165                    this.child(Self::render_history_placeholder("Loading Commit History…"))
6166                }
6167                CommitHistory::Loaded(entries) if entries.is_empty() => {
6168                    this.child(Self::render_history_placeholder("No commits yet"))
6169                }
6170                CommitHistory::Loaded(_) => match self.render_commit_history(window, cx) {
6171                    Some(history) => this.child(history),
6172                    None => this.child(Self::render_history_placeholder("Failed to load commits")),
6173                },
6174            }
6175        })
6176    }
6177
6178    fn render_history_placeholder(message: &'static str) -> impl IntoElement {
6179        h_flex()
6180            .flex_1()
6181            .justify_center()
6182            .child(Label::new(message).color(Color::Muted))
6183    }
6184
6185    fn commit_history_entries(&self) -> &[CommitHistoryEntry] {
6186        match &self.commit_history {
6187            CommitHistory::Loaded(entries) => entries,
6188            CommitHistory::Loading | CommitHistory::Error(_) => &[],
6189        }
6190    }
6191
6192    fn select_next_history_entry(&mut self, cx: &mut Context<Self>) {
6193        let count = self.commit_history_entries().len();
6194        if count == 0 {
6195            return;
6196        }
6197        let new_index = match self.focused_history_entry {
6198            None => 0,
6199            Some(i) => (i + 1).min(count - 1),
6200        };
6201        self.focused_history_entry = Some(new_index);
6202        self.history_keyboard_nav = true;
6203        self.commit_history_scroll_handle
6204            .scroll_to_item(new_index, ScrollStrategy::Top);
6205        cx.notify();
6206    }
6207
6208    fn select_previous_history_entry(&mut self, cx: &mut Context<Self>) {
6209        let count = self.commit_history_entries().len();
6210        if count == 0 {
6211            return;
6212        }
6213        let new_index = match self.focused_history_entry {
6214            None => 0,
6215            Some(i) => i.saturating_sub(1),
6216        };
6217        self.focused_history_entry = Some(new_index);
6218        self.history_keyboard_nav = true;
6219        self.commit_history_scroll_handle
6220            .scroll_to_item(new_index, ScrollStrategy::Top);
6221        cx.notify();
6222    }
6223
6224    fn open_selected_history_commit(&self, window: &mut Window, cx: &mut App) {
6225        let Some(index) = self.focused_history_entry else {
6226            return;
6227        };
6228        let Some(entry) = self.commit_history_entries().get(index) else {
6229            return;
6230        };
6231        let Some(active_repository) = self.active_repository.as_ref() else {
6232            return;
6233        };
6234        CommitView::open(
6235            entry.sha.to_string(),
6236            active_repository.downgrade(),
6237            self.workspace.clone(),
6238            None,
6239            None,
6240            window,
6241            cx,
6242        );
6243    }
6244
6245    fn deploy_history_context_menu(
6246        &mut self,
6247        position: Point<Pixels>,
6248        index: usize,
6249        window: &mut Window,
6250        cx: &mut Context<Self>,
6251    ) {
6252        let Some(commit) = self.commit_history_entries().get(index).cloned() else {
6253            return;
6254        };
6255        let Some(repository) = self.active_repository.as_ref() else {
6256            return;
6257        };
6258        let context_menu = commit_context_menu(
6259            CommitContextMenuData {
6260                sha: commit.sha,
6261                tag_names: commit.tag_names,
6262            },
6263            CommitContextMenuSource::GitPanel,
6264            None,
6265            self.focus_handle.clone(),
6266            Some(repository.downgrade()),
6267            self.workspace.clone(),
6268            window,
6269            cx,
6270        );
6271        self.focused_history_entry = Some(index);
6272        self.history_keyboard_nav = false;
6273        self.set_context_menu(context_menu, position, Some(index), window, cx);
6274    }
6275
6276    fn activate_changes_tab(
6277        &mut self,
6278        _: &ActivateChangesTab,
6279        window: &mut Window,
6280        cx: &mut Context<Self>,
6281    ) {
6282        self.set_active_tab(GitPanelTab::Changes, window, cx);
6283    }
6284
6285    fn activate_history_tab(
6286        &mut self,
6287        _: &ActivateHistoryTab,
6288        window: &mut Window,
6289        cx: &mut Context<Self>,
6290    ) {
6291        self.set_active_tab(GitPanelTab::History, window, cx);
6292    }
6293
6294    fn set_active_tab(&mut self, tab: GitPanelTab, window: &mut Window, cx: &mut Context<Self>) {
6295        if self.active_tab == tab {
6296            return;
6297        }
6298        self.active_tab = tab;
6299        match tab {
6300            GitPanelTab::History => {
6301                self.focus_handle.focus(window, cx);
6302                self.load_commit_history(cx);
6303            }
6304            GitPanelTab::Changes => {
6305                self.focus_handle.focus(window, cx);
6306                self.set_commit_history(CommitHistory::Loading, cx);
6307                self._repo_subscriptions.clear();
6308            }
6309        }
6310        cx.notify();
6311    }
6312
6313    fn preload_commit_history(&mut self, cx: &mut Context<Self>) {
6314        let Some(active_repository) = self.active_repository.as_ref() else {
6315            return;
6316        };
6317
6318        let Some(log_source) = Self::commit_history_log_source(active_repository, cx) else {
6319            return;
6320        };
6321        let log_order = LogOrder::DateOrder;
6322
6323        // Kick off the git log fetch so data is ready when the user switches to History.
6324        // graph_data() is idempotent — if already loading/loaded, this is a no-op.
6325        active_repository.update(cx, |repository, cx| {
6326            repository.graph_data(log_source, log_order, 0..0, cx);
6327        });
6328    }
6329
6330    fn load_commit_history(&mut self, cx: &mut Context<Self>) {
6331        let Some(active_repository) = self.active_repository.clone() else {
6332            return;
6333        };
6334
6335        if self._repo_subscriptions.is_empty() {
6336            self._repo_subscriptions.push(cx.subscribe(
6337                &active_repository,
6338                |this, _repo, event, cx| {
6339                    if let RepositoryEvent::GraphEvent(_, _) = event {
6340                        if this.active_tab == GitPanelTab::History {
6341                            this.fetch_commit_history_entries(cx);
6342                        }
6343                    }
6344                },
6345            ));
6346            self._repo_subscriptions
6347                .push(cx.observe(&active_repository, |_this, _repo, cx| {
6348                    cx.notify();
6349                }));
6350        }
6351
6352        self.fetch_commit_history_entries(cx);
6353    }
6354
6355    fn fetch_commit_history_entries(&mut self, cx: &mut Context<Self>) {
6356        let Some(active_repository) = self.active_repository.clone() else {
6357            return;
6358        };
6359
6360        let Some(log_source) = Self::commit_history_log_source(&active_repository, cx) else {
6361            // No HEAD commit at all (unborn/empty repository).
6362            self.set_commit_history(CommitHistory::Loaded(Rc::from([])), cx);
6363            return;
6364        };
6365        let log_order = LogOrder::DateOrder;
6366
6367        let (entries, is_loading, error) = active_repository.update(cx, |repository, cx| {
6368            let response = repository.graph_data(log_source, log_order, 0..usize::MAX, cx);
6369            let entries: Rc<[CommitHistoryEntry]> = response
6370                .commits
6371                .iter()
6372                .map(CommitHistoryEntry::from)
6373                .collect();
6374            (entries, response.is_loading, response.error)
6375        });
6376
6377        self.set_commit_history(commit_history_from_response(entries, is_loading, error), cx);
6378    }
6379
6380    fn set_commit_history(&mut self, commit_history: CommitHistory, cx: &mut Context<Self>) {
6381        let changed = self.commit_history != commit_history;
6382        self.commit_history = commit_history;
6383        // Keep the focused entry within range as the history grows or clears.
6384        let count = self.commit_history_entries().len();
6385        let focused = self.focused_history_entry.unwrap_or(0);
6386        self.focused_history_entry = (count > 0).then(|| focused.min(count - 1));
6387        if changed {
6388            cx.notify();
6389        }
6390    }
6391
6392    fn commit_history_log_source(
6393        active_repository: &Entity<Repository>,
6394        cx: &App,
6395    ) -> Option<LogSource> {
6396        let repository = active_repository.read(cx);
6397        let head_commit = repository.head_commit.as_ref()?;
6398        if let Some(branch) = repository.branch.as_ref() {
6399            Some(LogSource::Branch(branch.name().to_string().into()))
6400        } else {
6401            Some(LogSource::Sha(head_commit.sha.as_ref().parse().ok()?))
6402        }
6403    }
6404
6405    fn git_remote(&self, cx: &mut App) -> Option<GitRemote> {
6406        let repo = self.active_repository.as_ref()?;
6407        let remote_url = repo.read(cx).default_remote_url()?;
6408        let provider_registry = GitHostingProviderRegistry::default_global(cx);
6409        let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?;
6410        Some(GitRemote {
6411            host: provider,
6412            owner: parsed.owner.into(),
6413            repo: parsed.repo.into(),
6414        })
6415    }
6416
6417    fn render_commit_history(
6418        &self,
6419        window: &mut Window,
6420        cx: &mut Context<Self>,
6421    ) -> Option<impl IntoElement> {
6422        let CommitHistory::Loaded(entries) = &self.commit_history else {
6423            return None;
6424        };
6425        let entries = entries.clone();
6426        let active_repository = self.active_repository.as_ref()?;
6427        let workspace = self.workspace.clone();
6428        let repo_weak = active_repository.downgrade();
6429        let item_count = entries.len();
6430        let commit_history_scroll_handle = self.commit_history_scroll_handle.clone();
6431        let remote = self.git_remote(cx);
6432
6433        let focused_history_entry = self.focused_history_entry;
6434        let is_panel_focused = self.focus_handle.is_focused(window);
6435        let show_focus_border = self.history_keyboard_nav;
6436        let has_context_menu = self.context_menu.is_some();
6437        let context_menu_target_index = self
6438            .context_menu
6439            .as_ref()
6440            .and_then(|context_menu| context_menu.target_entry_index);
6441
6442        let ahead_count = active_repository
6443            .read(cx)
6444            .branch
6445            .as_ref()
6446            .and_then(|b| b.upstream.as_ref())
6447            .and_then(|u| u.tracking.status())
6448            .map(|s| s.ahead as usize)
6449            .unwrap_or(0);
6450
6451        Some(
6452            v_flex()
6453                .flex_1()
6454                .size_full()
6455                .overflow_hidden()
6456                .child(
6457                    uniform_list("commit_history_list", item_count, {
6458                        let workspace = workspace;
6459                        let repo_weak = repo_weak;
6460                        let git_panel = cx.weak_entity();
6461                        move |range, window, cx| {
6462                            let local_offset = time::UtcOffset::current_local_offset()
6463                                .unwrap_or(time::UtcOffset::UTC);
6464                            let now = time::OffsetDateTime::now_utc();
6465
6466                            let visible_data: Vec<Option<Arc<CommitData>>> = repo_weak
6467                                .update(cx, |repository, cx| {
6468                                    entries[range.clone()]
6469                                        .iter()
6470                                        .map(|entry| {
6471                                            match repository.fetch_commit_data(entry.sha, false, cx)
6472                                            {
6473                                                CommitDataState::Loaded(data) => Some(data.clone()),
6474                                                CommitDataState::Loading(_) => None,
6475                                            }
6476                                        })
6477                                        .collect()
6478                                })
6479                                .unwrap_or_default();
6480
6481                            entries[range.clone()]
6482                                .iter()
6483                                .zip(visible_data)
6484                                .enumerate()
6485                                .map(|(ix, (entry, data))| {
6486                                    let index = range.start + ix;
6487                                    let sha_string = entry.sha.to_string();
6488                                    let sha_shared: SharedString = sha_string.clone().into();
6489                                    let short_sha: SharedString =
6490                                        sha_string[..7.min(sha_string.len())].to_string().into();
6491                                    let tag_names = entry.tag_names.clone();
6492
6493                                    let (subject, author_name, author_email, timestamp): (
6494                                        SharedString,
6495                                        SharedString,
6496                                        Option<SharedString>,
6497                                        Option<i64>,
6498                                    ) = match &data {
6499                                        Some(data) => (
6500                                            data.subject.clone(),
6501                                            data.author_name.clone(),
6502                                            Some(data.author_email.clone()),
6503                                            Some(data.commit_timestamp),
6504                                        ),
6505                                        None => ("Loading…".into(), "".into(), None, None),
6506                                    };
6507
6508                                    let relative_time: SharedString = timestamp
6509                                        .and_then(|ts| {
6510                                            time::OffsetDateTime::from_unix_timestamp(ts).ok()
6511                                        })
6512                                        .map(|dt| {
6513                                            time_format::format_localized_timestamp(
6514                                                dt,
6515                                                now,
6516                                                local_offset,
6517                                                time_format::TimestampFormat::Relative,
6518                                            )
6519                                            .into()
6520                                        })
6521                                        .unwrap_or_else(|| "".into());
6522
6523                                    let avatar = CommitAvatar::new(
6524                                        &sha_shared,
6525                                        author_email,
6526                                        remote.as_ref(),
6527                                    )
6528                                    .size(px(14.))
6529                                    .render(window, cx);
6530
6531                                    let is_unpushed = index < ahead_count;
6532                                    let is_focused = focused_history_entry == Some(index);
6533                                    let is_context_menu_target =
6534                                        context_menu_target_index == Some(index);
6535                                    let workspace = workspace.clone();
6536                                    let repo = repo_weak.clone();
6537                                    let sha_for_click = sha_string;
6538
6539                                    let dot_separator = || {
6540                                        Label::new("•")
6541                                            .size(LabelSize::Small)
6542                                            .color(Color::Muted)
6543                                            .alpha(0.5)
6544                                            .flex_none()
6545                                    };
6546
6547                                    v_flex()
6548                                        .id(("commit-history-item", index))
6549                                        .cursor_pointer()
6550                                        .w_full()
6551                                        .py_1()
6552                                        .px_2()
6553                                        .gap_0p5()
6554                                        .border_1()
6555                                        .border_color(gpui::transparent_black())
6556                                        .when(
6557                                            is_focused && is_panel_focused && show_focus_border,
6558                                            |this| {
6559                                                this.border_color(
6560                                                    cx.theme().colors().panel_focused_border,
6561                                                )
6562                                            },
6563                                        )
6564                                        .hover(|s| s.bg(cx.theme().colors().element_hover))
6565                                        .when(is_context_menu_target, |this| {
6566                                            this.bg(cx.theme().colors().element_hover)
6567                                        })
6568                                        .child(
6569                                            h_flex()
6570                                                .gap_1()
6571                                                .w_full()
6572                                                .min_w_0()
6573                                                .child(Label::new(subject).truncate())
6574                                                .children((!tag_names.is_empty()).then(|| {
6575                                                    let hidden_tag_count = tag_names
6576                                                        .len()
6577                                                        .saturating_sub(MAX_HISTORY_TAG_CHIPS);
6578                                                    h_flex()
6579                                                        .gap_1()
6580                                                        .min_w_0()
6581                                                        .children(
6582                                                            tag_names
6583                                                                .iter()
6584                                                                .take(MAX_HISTORY_TAG_CHIPS)
6585                                                                .map(|tag_name| {
6586                                                                    let tag_name = tag_name.clone();
6587                                                                    Chip::new(tag_name.clone())
6588                                                                        .truncate()
6589                                                                        .when(
6590                                                                            !has_context_menu,
6591                                                                            |chip| {
6592                                                                                chip.tooltip(
6593                                                                                    Tooltip::text(
6594                                                                                        tag_name,
6595                                                                                    ),
6596                                                                                )
6597                                                                            },
6598                                                                        )
6599                                                                }),
6600                                                        )
6601                                                        .when(hidden_tag_count > 0, |this| {
6602                                                            let hidden_tag_names = tag_names
6603                                                                [MAX_HISTORY_TAG_CHIPS..]
6604                                                                .join(", ");
6605                                                            this.child(
6606                                                                Chip::new(format!(
6607                                                                    "+{hidden_tag_count}"
6608                                                                ))
6609                                                                .bg_color(
6610                                                                    cx.theme()
6611                                                                        .colors()
6612                                                                        .element_active
6613                                                                        .opacity(0.8),
6614                                                                )
6615                                                                .when(!has_context_menu, |chip| {
6616                                                                    chip.tooltip(Tooltip::text(
6617                                                                        hidden_tag_names,
6618                                                                    ))
6619                                                                }),
6620                                                            )
6621                                                        })
6622                                                }))
6623                                                .when(is_unpushed, |this| {
6624                                                    this.child(
6625                                                        h_flex()
6626                                                            .size_4()
6627                                                            .flex_none()
6628                                                            .justify_center()
6629                                                            .rounded_sm()
6630                                                            .border_1()
6631                                                            .border_color(
6632                                                                cx.theme().colors().border,
6633                                                            )
6634                                                            .bg(cx
6635                                                                .theme()
6636                                                                .colors()
6637                                                                .element_background)
6638                                                            .child(
6639                                                                Icon::new(IconName::ArrowUp)
6640                                                                    .size(IconSize::XSmall),
6641                                                            ),
6642                                                    )
6643                                                }),
6644                                        )
6645                                        .child(
6646                                            h_flex()
6647                                                .w_full()
6648                                                .min_w_0()
6649                                                .gap_1p5()
6650                                                .child(div().flex_none().child(avatar))
6651                                                .when(!author_name.is_empty(), |this| {
6652                                                    this.child(
6653                                                        Label::new(author_name)
6654                                                            .size(LabelSize::Small)
6655                                                            .color(Color::Muted)
6656                                                            .truncate(),
6657                                                    )
6658                                                    .child(dot_separator())
6659                                                })
6660                                                .when(!relative_time.is_empty(), |this| {
6661                                                    this.child(
6662                                                        Label::new(relative_time)
6663                                                            .size(LabelSize::Small)
6664                                                            .color(Color::Muted)
6665                                                            .flex_none(),
6666                                                    )
6667                                                    .child(dot_separator())
6668                                                })
6669                                                .child(
6670                                                    Label::new(short_sha.clone())
6671                                                        .size(LabelSize::Small)
6672                                                        .color(Color::Muted)
6673                                                        .flex_none(),
6674                                                ),
6675                                        )
6676                                        .when(!has_context_menu, |this| {
6677                                            this.tooltip(move |_, cx| {
6678                                                let description = if is_unpushed {
6679                                                    SharedString::from(format!(
6680                                                        "Contains Unpushed Changes — {}",
6681                                                        short_sha.clone(),
6682                                                    ))
6683                                                } else {
6684                                                    short_sha.clone()
6685                                                };
6686
6687                                                Tooltip::with_meta(
6688                                                    "View Commit Diff",
6689                                                    None,
6690                                                    description,
6691                                                    cx,
6692                                                )
6693                                            })
6694                                        })
6695                                        .on_mouse_down(gpui::MouseButton::Left, {
6696                                            let git_panel = git_panel.clone();
6697                                            move |_, _, cx| {
6698                                                git_panel
6699                                                    .update(cx, |panel, cx| {
6700                                                        panel.focused_history_entry = Some(index);
6701                                                        panel.history_keyboard_nav = false;
6702                                                        cx.notify();
6703                                                    })
6704                                                    .ok();
6705                                            }
6706                                        })
6707                                        .on_mouse_down(MouseButton::Right, {
6708                                            let git_panel = git_panel.clone();
6709                                            move |event, window, cx| {
6710                                                git_panel
6711                                                    .update(cx, |panel, cx| {
6712                                                        panel.deploy_history_context_menu(
6713                                                            event.position,
6714                                                            index,
6715                                                            window,
6716                                                            cx,
6717                                                        );
6718                                                    })
6719                                                    .ok();
6720                                                cx.stop_propagation();
6721                                            }
6722                                        })
6723                                        .on_click(move |_, window, cx| {
6724                                            CommitView::open(
6725                                                sha_for_click.clone(),
6726                                                repo.clone(),
6727                                                workspace.clone(),
6728                                                None,
6729                                                None,
6730                                                window,
6731                                                cx,
6732                                            );
6733                                        })
6734                                        .into_any_element()
6735                                })
6736                                .collect()
6737                        }
6738                    })
6739                    .size_full()
6740                    .track_scroll(&commit_history_scroll_handle),
6741                )
6742                .vertical_scrollbar_for(&commit_history_scroll_handle, window, cx),
6743        )
6744    }
6745
6746    fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
6747        let content = match (self.git_access, &self.active_repository) {
6748            (Some(GitAccess::No), Some(repository)) => self.render_unsafe_repo_ui(repository, cx),
6749            (_, None) => self.render_uninitialized_ui(cx),
6750            (_, Some(_)) => self.render_no_changes_ui(cx),
6751        };
6752
6753        v_flex()
6754            .gap_1p5()
6755            .flex_1()
6756            .items_center()
6757            .justify_center()
6758            .child(content)
6759    }
6760
6761    fn render_no_changes_ui(&self, cx: &Context<Self>) -> AnyElement {
6762        let show_branch_diff = self.changes_count == 0 && !self.is_on_main_branch(cx);
6763
6764        v_flex()
6765            .gap_1()
6766            .items_center()
6767            .child(Label::new("No changes to commit").color(Color::Muted))
6768            .when(show_branch_diff, |this| {
6769                this.child(
6770                    Button::new("view_branch_diff", "View Branch Diff")
6771                        .label_size(LabelSize::Small)
6772                        .style(ButtonStyle::Outlined)
6773                        .on_click(move |_, _, cx| {
6774                            cx.defer(move |cx| {
6775                                cx.dispatch_action(&DeployBranchDiff);
6776                            })
6777                        }),
6778                )
6779            })
6780            .into_any_element()
6781    }
6782
6783    fn render_unsafe_repo_ui(
6784        &self,
6785        active_repository: &Entity<Repository>,
6786        cx: &mut Context<Self>,
6787    ) -> AnyElement {
6788        let directory = active_repository.update(cx, |repository, _cx| {
6789            repository.snapshot().work_directory_abs_path
6790        });
6791
6792        let message = format!(
6793            "Detected dubious ownership in repository at {}. \
6794            This happens when the .git/ directory is not owned by the current user. \
6795            If you want to learn more about safe directories, visit git's documentation.",
6796            directory.display()
6797        );
6798
6799        v_flex()
6800                .px_4()
6801                .gap_1()
6802                .child(Label::new(message).color(Color::Muted))
6803                .child(
6804                    h_flex()
6805                        .flex_wrap()
6806                        .gap_1()
6807                        .child(
6808                            Button::new("trust_directory", "Trust Directory")
6809                            .label_size(LabelSize::Small)
6810                            .layer(ElevationIndex::ModalSurface)
6811                            .style(ButtonStyle::Filled)
6812                            .tooltip(Tooltip::text(
6813                                format!("git config --global --add safe.directory {}", directory.display())
6814                            ))
6815                            .on_click(
6816                                cx.listener(|this, _, window, cx| {
6817                                    this.add_safe_directory(window, cx);
6818                                })
6819                            )
6820                    )
6821                    .child(
6822                        Button::new("learn_more", "Learn More")
6823                            .label_size(LabelSize::Small)
6824                            .style(ButtonStyle::Outlined)
6825                            .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small).color(Color::Muted))
6826                            .on_click(move |_, _, cx| cx.open_url("https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory"))
6827                    )
6828                )
6829                .into_any_element()
6830    }
6831
6832    fn render_uninitialized_ui(&self, cx: &mut Context<Self>) -> AnyElement {
6833        let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
6834        if worktree_count > 0 && self.active_repository.is_none() {
6835            v_flex()
6836                .gap_1()
6837                .items_center()
6838                .child(Label::new("No Git Repositories").color(Color::Muted))
6839                .child(
6840                    Button::new("initialize_repository", "Initialize Repository")
6841                        .label_size(LabelSize::Small)
6842                        .style(ButtonStyle::Outlined)
6843                        .tooltip(Tooltip::for_action_title_in(
6844                            "git init",
6845                            &git::Init,
6846                            &self.focus_handle,
6847                        ))
6848                        .on_click(move |_, _, cx| {
6849                            cx.defer(move |cx| {
6850                                cx.dispatch_action(&git::Init);
6851                            })
6852                        }),
6853                )
6854                .into_any_element()
6855        } else if worktree_count == 0 {
6856            let focus_handle = self.focus_handle.clone();
6857            ProjectEmptyState::new(
6858                "Git Panel",
6859                focus_handle.clone(),
6860                KeyBinding::for_action_in(&workspace::Open::default(), &focus_handle, cx),
6861            )
6862            .on_open_project(|_, window, cx| {
6863                telemetry::event!("Git Panel Add Project Clicked");
6864                window.dispatch_action(workspace::Open::default().boxed_clone(), cx);
6865            })
6866            .on_clone_repo(|_, window, cx| {
6867                telemetry::event!("Git Panel Clone Repo Clicked");
6868                window.dispatch_action(git::Clone.boxed_clone(), cx);
6869            })
6870            .into_any_element()
6871        } else {
6872            Empty.into_any_element()
6873        }
6874    }
6875
6876    fn is_on_main_branch(&self, cx: &Context<Self>) -> bool {
6877        let Some(repo) = self.active_repository.as_ref() else {
6878            return false;
6879        };
6880
6881        let Some(branch) = repo.read(cx).branch.as_ref() else {
6882            return false;
6883        };
6884
6885        let branch_name = branch.name();
6886        matches!(branch_name, "main" | "master")
6887    }
6888
6889    fn render_buffer_header_controls(
6890        &self,
6891        entity: &Entity<Self>,
6892        file: &Arc<dyn File>,
6893        _: &Window,
6894        cx: &App,
6895    ) -> Option<AnyElement> {
6896        let repo = self.active_repository.as_ref()?.read(cx);
6897        let project_path = (file.worktree_id(cx), file.path().clone()).into();
6898        let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
6899        let ix = self.entry_by_path(&repo_path)?;
6900        let entry = self.entries.get(ix)?;
6901
6902        let is_staging_or_staged = repo
6903            .pending_ops_for_path(&repo_path)
6904            .map(|ops| !ops.last_op_errored() && (ops.staging() || ops.staged()))
6905            .or_else(|| {
6906                repo.status_for_path(&repo_path)
6907                    .and_then(|status| status.status.staging().as_bool())
6908            })
6909            .or_else(|| {
6910                entry
6911                    .status_entry()
6912                    .and_then(|entry| entry.staging.as_bool())
6913            });
6914
6915        let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into())
6916            .disabled(!self.has_write_access(cx))
6917            .fill()
6918            .elevation(ElevationIndex::Surface)
6919            .on_click({
6920                let entry = entry.clone();
6921                let git_panel = entity.downgrade();
6922                move |_, window, cx| {
6923                    git_panel
6924                        .update(cx, |this, cx| {
6925                            this.toggle_staged_for_entry(&entry, StageIntent::Toggle, window, cx);
6926                            cx.stop_propagation();
6927                        })
6928                        .ok();
6929                }
6930            });
6931        Some(
6932            h_flex()
6933                .id("start-slot")
6934                .text_lg()
6935                .child(checkbox)
6936                .on_mouse_down(MouseButton::Left, |_, _, cx| {
6937                    // prevent the list item active state triggering when toggling checkbox
6938                    cx.stop_propagation();
6939                })
6940                .into_any_element(),
6941        )
6942    }
6943
6944    fn render_entries(
6945        &self,
6946        has_write_access: bool,
6947        repo: Entity<Repository>,
6948        window: &mut Window,
6949        cx: &mut Context<Self>,
6950    ) -> impl IntoElement {
6951        let (is_tree_view, entry_count) = match &self.view_mode {
6952            GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()),
6953            GitPanelViewMode::Flat => (false, self.entries.len()),
6954        };
6955        let repo = repo.downgrade();
6956
6957        v_flex()
6958            .flex_1()
6959            .size_full()
6960            .overflow_hidden()
6961            .relative()
6962            .child(
6963                h_flex()
6964                    .flex_1()
6965                    .size_full()
6966                    .relative()
6967                    .overflow_hidden()
6968                    .child(
6969                        uniform_list(
6970                            "entries",
6971                            entry_count,
6972                            cx.processor(move |this, range: Range<usize>, window, cx| {
6973                                let Some(repo) = repo.upgrade() else {
6974                                    return Vec::new();
6975                                };
6976                                let repo = repo.read(cx);
6977
6978                                let mut items = Vec::with_capacity(range.end - range.start);
6979
6980                                for ix in range.into_iter().map(|ix| match &this.view_mode {
6981                                    GitPanelViewMode::Tree(state) => state.logical_indices[ix],
6982                                    GitPanelViewMode::Flat => ix,
6983                                }) {
6984                                    match &this.entries.get(ix) {
6985                                        Some(GitListEntry::Status(entry)) => {
6986                                            items.push(this.render_status_entry(
6987                                                ix,
6988                                                entry,
6989                                                0,
6990                                                has_write_access,
6991                                                repo,
6992                                                window,
6993                                                cx,
6994                                            ));
6995                                        }
6996                                        Some(GitListEntry::TreeStatus(entry)) => {
6997                                            items.push(this.render_status_entry(
6998                                                ix,
6999                                                &entry.entry,
7000                                                entry.depth,
7001                                                has_write_access,
7002                                                repo,
7003                                                window,
7004                                                cx,
7005                                            ));
7006                                        }
7007                                        Some(GitListEntry::Directory(entry)) => {
7008                                            items.push(this.render_directory_entry(
7009                                                ix,
7010                                                entry,
7011                                                has_write_access,
7012                                                window,
7013                                                cx,
7014                                            ));
7015                                        }
7016                                        Some(GitListEntry::Header(header)) => {
7017                                            items.push(this.render_list_header(
7018                                                ix,
7019                                                header,
7020                                                has_write_access,
7021                                                window,
7022                                                cx,
7023                                            ));
7024                                        }
7025                                        Some(GitListEntry::EmptySection(section)) => {
7026                                            items.push(this.render_empty_section(*section));
7027                                        }
7028                                        None => {}
7029                                    }
7030                                }
7031
7032                                items
7033                            }),
7034                        )
7035                        .when(is_tree_view, |list| {
7036                            list.with_decoration(
7037                                ui::indent_guides(px(TREE_INDENT), IndentGuideColors::panel(cx))
7038                                    .with_left_offset(INDENT_GUIDE_LEFT_OFFSET)
7039                                    .with_compute_indents_fn(
7040                                        cx.entity(),
7041                                        |this, range, _window, _cx| {
7042                                            this.compute_visible_depths(range)
7043                                        },
7044                                    ),
7045                            )
7046                        })
7047                        .group("entries")
7048                        .size_full()
7049                        .flex_grow_1()
7050                        .with_width_from_item(self.max_width_item_index)
7051                        .track_scroll(&self.scroll_handle),
7052                    )
7053                    .on_mouse_down(
7054                        MouseButton::Right,
7055                        cx.listener(move |this, event: &MouseDownEvent, window, cx| {
7056                            this.deploy_panel_context_menu(event.position, window, cx)
7057                        }),
7058                    )
7059                    .custom_scrollbars(
7060                        Scrollbars::for_settings::<GitPanelScrollbarAccessor>()
7061                            .tracked_scroll_handle(&self.scroll_handle)
7062                            .with_track_along(
7063                                ScrollAxes::Horizontal,
7064                                cx.theme().colors().panel_background,
7065                            ),
7066                        window,
7067                        cx,
7068                    ),
7069            )
7070    }
7071
7072    fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
7073        Label::new(label.into()).single_line().color(color)
7074    }
7075
7076    fn list_item_height(&self) -> Rems {
7077        rems(1.75)
7078    }
7079
7080    fn render_list_header(
7081        &self,
7082        ix: usize,
7083        header: &GitHeaderEntry,
7084        has_write_access: bool,
7085        _window: &Window,
7086        cx: &Context<Self>,
7087    ) -> AnyElement {
7088        let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
7089        let checkbox_id: ElementId = ElementId::Name(format!("header_{}_checkbox", ix).into());
7090        let group_name: SharedString = format!("header_{}", ix).into();
7091        let section = header.header;
7092        let weak = cx.weak_entity();
7093        let stage_intent = StageIntent::for_section(section);
7094        let toggle_state = stage_intent.checkbox_state(|| self.header_state(header.header));
7095
7096        let all_conflicts_resolved = section == Section::Conflict
7097            && self.conflicted_count > 0
7098            && self.conflicted_staged_count == self.conflicted_count;
7099
7100        let section_is_empty = !self
7101            .entries
7102            .get(ix + 1)
7103            .is_some_and(GitListEntry::is_selectable);
7104
7105        h_flex()
7106            .id(id)
7107            .group(group_name)
7108            .h(self.list_item_height())
7109            .w_full()
7110            .pl_2p5()
7111            .pr_1()
7112            .gap_2()
7113            .justify_between()
7114            .when(!section_is_empty && !all_conflicts_resolved, |this| {
7115                this.cursor_pointer()
7116                    .hover(|s| s.bg(cx.theme().colors().ghost_element_hover))
7117            })
7118            .border_1()
7119            .border_r_2()
7120            .child(
7121                Label::new(header.title())
7122                    .color(Color::Muted)
7123                    .size(LabelSize::Small),
7124            )
7125            .child(if section_is_empty {
7126                gpui::Empty.into_any_element()
7127            } else {
7128                let checkbox = Checkbox::new(checkbox_id, toggle_state)
7129                    .disabled(!has_write_access || all_conflicts_resolved)
7130                    .fill()
7131                    .elevation(ElevationIndex::Surface);
7132                let tooltip_label = if all_conflicts_resolved {
7133                    Some("All conflicts marked as resolved")
7134                } else {
7135                    match stage_intent {
7136                        StageIntent::Stage => Some("Stage All"),
7137                        StageIntent::Unstage => Some("Unstage All"),
7138                        StageIntent::Toggle => None,
7139                    }
7140                };
7141                if let Some(label) = tooltip_label {
7142                    checkbox
7143                        .tooltip(move |_window, cx| Tooltip::simple(label, cx))
7144                        .into_any_element()
7145                } else {
7146                    checkbox.into_any_element()
7147                }
7148            })
7149            .on_click(move |_, window, cx| {
7150                if !has_write_access || section_is_empty || all_conflicts_resolved {
7151                    return;
7152                }
7153
7154                weak.update(cx, |this, cx| {
7155                    this.toggle_staged_for_entry(
7156                        &GitListEntry::Header(GitHeaderEntry { header: section }),
7157                        stage_intent,
7158                        window,
7159                        cx,
7160                    );
7161                    cx.stop_propagation();
7162                })
7163                .ok();
7164            })
7165            .into_any_element()
7166    }
7167
7168    fn render_empty_section(&self, section: Section) -> AnyElement {
7169        let message = match section {
7170            Section::Staged => "No staged changes yet",
7171            Section::Unstaged => "No unstaged changes",
7172            _ => "No changes",
7173        };
7174        h_flex()
7175            .h(self.list_item_height())
7176            .w_full()
7177            .pl_2p5()
7178            .pr_1()
7179            .opacity(0.8)
7180            .child(
7181                Label::new(message)
7182                    .color(Color::Placeholder)
7183                    .size(LabelSize::Small),
7184            )
7185            .into_any_element()
7186    }
7187
7188    pub fn load_commit_details(
7189        &self,
7190        sha: String,
7191        cx: &mut Context<Self>,
7192    ) -> Task<anyhow::Result<CommitDetails>> {
7193        let Some(repo) = self.active_repository.clone() else {
7194            return Task::ready(Err(anyhow::anyhow!("no active repo")));
7195        };
7196        repo.update(cx, |repo, cx| {
7197            let show = repo.show(sha);
7198            cx.spawn(async move |_, _| show.await?)
7199        })
7200    }
7201
7202    fn deploy_entry_context_menu(
7203        &mut self,
7204        position: Point<Pixels>,
7205        ix: usize,
7206        window: &mut Window,
7207        cx: &mut Context<Self>,
7208    ) {
7209        let stage_intent = self.stage_intent_for_entry_index(ix);
7210        let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
7211            return;
7212        };
7213        // Resolve against the pending-op-aware status (like the checkboxes do)
7214        // so the menu label can't lag behind a just-clicked checkbox.
7215        let repo = self.active_repository.as_ref().map(|repo| repo.read(cx));
7216        let stage_title = if stage_intent.resolve_with(|| match repo {
7217            Some(repo) => GitPanel::stage_status_for_entry(entry, repo),
7218            None => entry.status.staging(),
7219        }) {
7220            "Stage File"
7221        } else {
7222            "Unstage File"
7223        };
7224        let restore_title = if entry.status.is_created() {
7225            "Trash File"
7226        } else {
7227            "Discard Changes"
7228        };
7229        let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
7230            let is_created = entry.status.is_created();
7231            context_menu
7232                .context(self.focus_handle.clone())
7233                .action(stage_title, ToggleStaged.boxed_clone())
7234                .action(restore_title, git::RestoreFile::default().boxed_clone())
7235                .separator()
7236                .action("Unstaged Changes", ViewUnstagedChanges.boxed_clone())
7237                .action("Staged Changes", ViewStagedChanges.boxed_clone())
7238                .separator()
7239                .action_disabled_when(
7240                    !is_created,
7241                    "Add to .gitignore",
7242                    git::AddToGitignore.boxed_clone(),
7243                )
7244                .action_disabled_when(
7245                    !is_created,
7246                    "Add to .git/info/exclude",
7247                    git::AddToGitInfoExclude.boxed_clone(),
7248                )
7249                .separator()
7250                .action("Open Diff", menu::Confirm.boxed_clone())
7251                .action("Open File Diff", menu::SecondaryConfirm.boxed_clone())
7252                .action("View File", ViewFile.boxed_clone())
7253                .when(!is_created, |context_menu| {
7254                    context_menu
7255                        .separator()
7256                        .action("View File History", Box::new(git::FileHistory))
7257                })
7258        });
7259        self.selected_entry = Some(ix);
7260        self.set_context_menu(context_menu, position, None, window, cx);
7261    }
7262
7263    fn deploy_panel_context_menu(
7264        &mut self,
7265        position: Point<Pixels>,
7266        window: &mut Window,
7267        cx: &mut Context<Self>,
7268    ) {
7269        let has_tracked_changes = self.has_tracked_changes();
7270        let has_staged_changes = self.has_staged_changes();
7271        let has_unstaged_changes = self.has_unstaged_changes();
7272        let has_new_changes = self.new_count > 0;
7273        let has_stash_items = self.stash_entries.entries.len() > 0;
7274
7275        let context_menu = git_panel_context_menu(
7276            has_tracked_changes,
7277            has_staged_changes,
7278            has_unstaged_changes,
7279            has_new_changes,
7280            has_stash_items,
7281            self.focus_handle.clone(),
7282            window,
7283            cx,
7284        );
7285        self.set_context_menu(context_menu, position, None, window, cx);
7286    }
7287
7288    fn set_context_menu(
7289        &mut self,
7290        context_menu: Entity<ContextMenu>,
7291        position: Point<Pixels>,
7292        target_entry_index: Option<usize>,
7293        window: &mut Window,
7294        cx: &mut Context<Self>,
7295    ) {
7296        let focus_handle = context_menu.focus_handle(cx);
7297        window.defer(cx, move |window, cx| {
7298            window.focus(&focus_handle, cx);
7299        });
7300
7301        let subscription = cx.subscribe_in(
7302            &context_menu,
7303            window,
7304            |this, _, _: &DismissEvent, window, cx| {
7305                if this.context_menu.as_ref().is_some_and(|context_menu| {
7306                    context_menu
7307                        .menu
7308                        .focus_handle(cx)
7309                        .contains_focused(window, cx)
7310                }) {
7311                    cx.focus_self(window);
7312                }
7313                this.context_menu.take();
7314                cx.notify();
7315            },
7316        );
7317        self.context_menu = Some(GitPanelContextMenu {
7318            menu: context_menu,
7319            position,
7320            target_entry_index,
7321            _subscription: subscription,
7322        });
7323        cx.notify();
7324    }
7325
7326    fn render_status_entry(
7327        &self,
7328        ix: usize,
7329        entry: &GitStatusEntry,
7330        depth: usize,
7331        has_write_access: bool,
7332        repo: &Repository,
7333        window: &Window,
7334        cx: &Context<Self>,
7335    ) -> AnyElement {
7336        let settings = GitPanelSettings::get_global(cx);
7337        let tree_view = settings.tree_view;
7338        let path_style = self.project.read(cx).path_style(cx);
7339        let git_path_style = ProjectSettings::get_global(cx).git.path_style;
7340        let display_name = entry.display_name(path_style);
7341
7342        let selected = self.selected_entry == Some(ix);
7343        let marked = self.marked_entries.contains(&ix);
7344        let status_style = settings.status_style;
7345        let status = entry.status;
7346        let file_icon = if settings.file_icons {
7347            FileIcons::get_icon(entry.repo_path.as_std_path(), cx)
7348        } else {
7349            None
7350        };
7351
7352        let has_conflict = status.is_conflicted();
7353        let is_modified = status.is_modified();
7354        let is_deleted = status.is_deleted();
7355        let is_created = status.is_created();
7356
7357        let label_color = if status_style == StatusStyle::LabelColor {
7358            if has_conflict {
7359                Color::VersionControlConflict
7360            } else if is_created {
7361                Color::VersionControlAdded
7362            } else if is_modified {
7363                Color::VersionControlModified
7364            } else if is_deleted {
7365                // We don't want a bunch of red labels in the list
7366                Color::Disabled
7367            } else {
7368                Color::VersionControlAdded
7369            }
7370        } else {
7371            Color::Default
7372        };
7373
7374        let path_color = if status.is_deleted() {
7375            Color::Disabled
7376        } else {
7377            Color::Muted
7378        };
7379
7380        let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
7381        let checkbox_wrapper_id: ElementId =
7382            ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
7383        let checkbox_id: ElementId =
7384            ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
7385
7386        let stage_status = GitPanel::stage_status_for_entry(entry, &repo);
7387        let stage_intent = self.stage_intent_for_entry_index(ix);
7388        let resolved_conflict = self.is_resolved_conflict(ix, cx);
7389        let toggle_state = stage_intent.checkbox_state(|| {
7390            if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
7391                ToggleState::Selected
7392            } else {
7393                match stage_status {
7394                    StageStatus::Staged => ToggleState::Selected,
7395                    StageStatus::Unstaged => ToggleState::Unselected,
7396                    StageStatus::PartiallyStaged => ToggleState::Indeterminate,
7397                }
7398            }
7399        });
7400
7401        let handle = cx.weak_entity();
7402
7403        let selected_bg_alpha = 0.08;
7404        let marked_bg_alpha = 0.12;
7405        let state_opacity_step = 0.04;
7406
7407        let info_color = cx.theme().status().info;
7408
7409        let base_bg = match (selected, marked) {
7410            (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
7411            (true, false) => info_color.alpha(selected_bg_alpha),
7412            (false, true) => info_color.alpha(marked_bg_alpha),
7413            _ => cx.theme().colors().ghost_element_background,
7414        };
7415
7416        let (hover_bg, active_bg) = if selected {
7417            (
7418                info_color.alpha(selected_bg_alpha + state_opacity_step),
7419                info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
7420            )
7421        } else {
7422            (
7423                cx.theme().colors().ghost_element_hover,
7424                cx.theme().colors().ghost_element_active,
7425            )
7426        };
7427
7428        let name_row = h_flex()
7429            .min_w_0()
7430            .flex_1()
7431            .gap_1()
7432            .when(settings.file_icons, |this| {
7433                this.child(
7434                    file_icon
7435                        .map(|file_icon| {
7436                            Icon::from_path(file_icon)
7437                                .size(IconSize::Small)
7438                                .color(Color::Muted)
7439                        })
7440                        .unwrap_or_else(|| {
7441                            Icon::new(IconName::File)
7442                                .size(IconSize::Small)
7443                                .color(Color::Muted)
7444                        }),
7445                )
7446            })
7447            .when(status_style != StatusStyle::LabelColor, |el| {
7448                el.child(git_status_icon(status))
7449            })
7450            .map(|this| {
7451                if tree_view {
7452                    this.pl(px(depth as f32 * TREE_INDENT)).child(
7453                        self.entry_label(display_name, label_color)
7454                            .when(status.is_deleted(), Label::strikethrough)
7455                            .truncate(),
7456                    )
7457                } else {
7458                    this.child(self.path_formatted(
7459                        entry.parent_dir(path_style),
7460                        path_color,
7461                        display_name,
7462                        label_color,
7463                        path_style,
7464                        git_path_style,
7465                        status.is_deleted(),
7466                    ))
7467                }
7468            });
7469
7470        let id_for_diff_stat = id.clone();
7471
7472        h_flex()
7473            .id(id)
7474            .h(self.list_item_height())
7475            .w_full()
7476            .pl_2p5()
7477            .pr_1()
7478            .gap_1p5()
7479            .border_1()
7480            .border_r_2()
7481            .when(selected && self.focus_handle.is_focused(window), |el| {
7482                el.border_color(cx.theme().colors().panel_focused_border)
7483            })
7484            .bg(base_bg)
7485            .hover(|s| s.bg(hover_bg))
7486            .active(|s| s.bg(active_bg))
7487            .child(name_row)
7488            .when(GitPanelSettings::get_global(cx).diff_stats, |el| {
7489                el.when_some(entry.diff_stat, move |this, stat| {
7490                    let id = format!("diff-stat-{}", id_for_diff_stat);
7491                    this.child(ui::DiffStat::new(
7492                        id,
7493                        stat.added as usize,
7494                        stat.deleted as usize,
7495                    ))
7496                })
7497            })
7498            .child(
7499                div()
7500                    .id(checkbox_wrapper_id)
7501                    .flex_none()
7502                    .occlude()
7503                    .cursor_pointer()
7504                    .child(
7505                        Checkbox::new(checkbox_id, toggle_state)
7506                            .fill()
7507                            .elevation(ElevationIndex::Surface)
7508                            .disabled(!has_write_access || resolved_conflict)
7509                            .on_click_ext({
7510                                let entry = entry.clone();
7511                                let this = cx.weak_entity();
7512                                move |_, click, window, cx| {
7513                                    this.update(cx, |this, cx| {
7514                                        if !has_write_access || resolved_conflict {
7515                                            return;
7516                                        }
7517                                        if click.modifiers().shift {
7518                                            this.stage_bulk(
7519                                                ix,
7520                                                stage_intent != StageIntent::Unstage,
7521                                                cx,
7522                                            );
7523                                        } else {
7524                                            let list_entry =
7525                                                if GitPanelSettings::get_global(cx).tree_view {
7526                                                    GitListEntry::TreeStatus(GitTreeStatusEntry {
7527                                                        entry: entry.clone(),
7528                                                        depth,
7529                                                    })
7530                                                } else {
7531                                                    GitListEntry::Status(entry.clone())
7532                                                };
7533                                            this.toggle_staged_for_entry(
7534                                                &list_entry,
7535                                                stage_intent,
7536                                                window,
7537                                                cx,
7538                                            );
7539                                        }
7540                                        cx.stop_propagation();
7541                                    })
7542                                    .ok();
7543                                }
7544                            })
7545                            .tooltip(move |_window, cx| {
7546                                if resolved_conflict {
7547                                    Tooltip::simple("Conflict marked as resolved", cx)
7548                                } else {
7549                                    let action = stage_intent.label(|| stage_status);
7550                                    Tooltip::for_action(action, &ToggleStaged, cx)
7551                                }
7552                            }),
7553                    ),
7554            )
7555            .on_click({
7556                cx.listener(move |this, event: &ClickEvent, window, cx| {
7557                    this.selected_entry = Some(ix);
7558                    cx.notify();
7559                    this.open_selected_entry_on_click(event.modifiers().secondary(), window, cx);
7560                })
7561            })
7562            .on_mouse_down(
7563                MouseButton::Right,
7564                move |event: &MouseDownEvent, window, cx| {
7565                    // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
7566                    if event.button != MouseButton::Right {
7567                        return;
7568                    }
7569
7570                    let Some(this) = handle.upgrade() else {
7571                        return;
7572                    };
7573                    this.update(cx, |this, cx| {
7574                        this.deploy_entry_context_menu(event.position, ix, window, cx);
7575                    });
7576                    cx.stop_propagation();
7577                },
7578            )
7579            .into_any_element()
7580    }
7581
7582    fn render_directory_entry(
7583        &self,
7584        ix: usize,
7585        entry: &GitTreeDirEntry,
7586        has_write_access: bool,
7587        window: &Window,
7588        cx: &Context<Self>,
7589    ) -> AnyElement {
7590        // TODO: Have not yet plugged in self.marked_entries. Not sure when and why we need that
7591        let selected = self.selected_entry == Some(ix);
7592        let label_color = Color::Muted;
7593
7594        let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into());
7595        let checkbox_id: ElementId =
7596            ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into());
7597        let checkbox_wrapper_id: ElementId =
7598            ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into());
7599
7600        let selected_bg_alpha = 0.08;
7601        let state_opacity_step = 0.04;
7602
7603        let info_color = cx.theme().status().info;
7604        let colors = cx.theme().colors();
7605
7606        let (base_bg, hover_bg, active_bg) = if selected {
7607            (
7608                info_color.alpha(selected_bg_alpha),
7609                info_color.alpha(selected_bg_alpha + state_opacity_step),
7610                info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
7611            )
7612        } else {
7613            (
7614                colors.ghost_element_background,
7615                colors.ghost_element_hover,
7616                colors.ghost_element_active,
7617            )
7618        };
7619
7620        let settings = GitPanelSettings::get_global(cx);
7621        let folder_icon = if settings.folder_icons {
7622            FileIcons::get_folder_icon(entry.expanded, entry.key.path.as_std_path(), cx)
7623        } else {
7624            FileIcons::get_chevron_icon(entry.expanded, cx)
7625        };
7626        let fallback_folder_icon = if settings.folder_icons {
7627            if entry.expanded {
7628                IconName::FolderOpen
7629            } else {
7630                IconName::Folder
7631            }
7632        } else {
7633            if entry.expanded {
7634                IconName::ChevronDown
7635            } else {
7636                IconName::ChevronRight
7637            }
7638        };
7639
7640        let stage_status = if let Some(repo) = &self.active_repository {
7641            self.stage_status_for_directory(entry, repo.read(cx))
7642        } else {
7643            util::debug_panic!(
7644                "Won't have entries to render without an active repository in Git Panel"
7645            );
7646            StageStatus::PartiallyStaged
7647        };
7648
7649        let stage_intent = StageIntent::for_section(entry.key.section);
7650        let resolved_conflict = self.is_resolved_conflict(ix, cx);
7651        let toggle_state = stage_intent.checkbox_state(|| match stage_status {
7652            StageStatus::Staged => ToggleState::Selected,
7653            StageStatus::Unstaged => ToggleState::Unselected,
7654            StageStatus::PartiallyStaged => ToggleState::Indeterminate,
7655        });
7656
7657        let name_row = h_flex()
7658            .min_w_0()
7659            .gap_1()
7660            .pl(px(entry.depth as f32 * TREE_INDENT))
7661            .child(
7662                folder_icon
7663                    .map(|folder_icon| {
7664                        Icon::from_path(folder_icon)
7665                            .size(IconSize::Small)
7666                            .color(Color::Muted)
7667                    })
7668                    .unwrap_or_else(|| {
7669                        Icon::new(fallback_folder_icon)
7670                            .size(IconSize::Small)
7671                            .color(Color::Muted)
7672                    }),
7673            )
7674            .child(self.entry_label(entry.name.clone(), label_color).truncate());
7675
7676        h_flex()
7677            .id(id)
7678            .h(self.list_item_height())
7679            .min_w_0()
7680            .w_full()
7681            .pl_2p5()
7682            .pr_1()
7683            .gap_1p5()
7684            .justify_between()
7685            .border_1()
7686            .border_r_2()
7687            .when(selected && self.focus_handle.is_focused(window), |el| {
7688                el.border_color(cx.theme().colors().panel_focused_border)
7689            })
7690            .bg(base_bg)
7691            .hover(|s| s.bg(hover_bg))
7692            .active(|s| s.bg(active_bg))
7693            .child(name_row)
7694            .child(
7695                div()
7696                    .id(checkbox_wrapper_id)
7697                    .flex_none()
7698                    .occlude()
7699                    .cursor_pointer()
7700                    .child(
7701                        Checkbox::new(checkbox_id, toggle_state)
7702                            .disabled(!has_write_access || resolved_conflict)
7703                            .fill()
7704                            .elevation(ElevationIndex::Surface)
7705                            .on_click({
7706                                let entry = entry.clone();
7707                                let this = cx.weak_entity();
7708                                move |_, window, cx| {
7709                                    this.update(cx, |this, cx| {
7710                                        if !has_write_access || resolved_conflict {
7711                                            return;
7712                                        }
7713                                        this.toggle_staged_for_entry(
7714                                            &GitListEntry::Directory(entry.clone()),
7715                                            stage_intent,
7716                                            window,
7717                                            cx,
7718                                        );
7719                                        cx.stop_propagation();
7720                                    })
7721                                    .ok();
7722                                }
7723                            })
7724                            .tooltip(move |_window, cx| {
7725                                if resolved_conflict {
7726                                    Tooltip::simple("Conflicts marked as resolved", cx)
7727                                } else {
7728                                    let action = stage_intent.label(|| stage_status);
7729                                    Tooltip::simple(format!("{action} Folder"), cx)
7730                                }
7731                            }),
7732                    ),
7733            )
7734            .on_click({
7735                let key = entry.key.clone();
7736                cx.listener(move |this, _event: &ClickEvent, window, cx| {
7737                    this.selected_entry = Some(ix);
7738                    this.toggle_directory(&key, window, cx);
7739                })
7740            })
7741            .into_any_element()
7742    }
7743
7744    fn path_formatted(
7745        &self,
7746        directory: Option<String>,
7747        path_color: Color,
7748        file_name: String,
7749        label_color: Color,
7750        path_style: PathStyle,
7751        git_path_style: GitPathStyle,
7752        strikethrough: bool,
7753    ) -> Div {
7754        let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
7755        let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
7756
7757        let file_name = format!("{} ", file_name);
7758
7759        h_flex()
7760            .min_w_0()
7761            .overflow_hidden()
7762            .when(file_path_first, |this| this.flex_row_reverse())
7763            .child(
7764                div().flex_none().child(
7765                    self.entry_label(file_name, label_color)
7766                        .when(strikethrough, Label::strikethrough),
7767                ),
7768            )
7769            .when_some(directory, |this, dir| {
7770                let path_name = if file_name_first {
7771                    dir
7772                } else {
7773                    format!("{dir}{}", path_style.primary_separator())
7774                };
7775
7776                this.child(
7777                    self.entry_label(path_name, path_color)
7778                        .truncate_start()
7779                        .when(strikethrough, Label::strikethrough),
7780                )
7781            })
7782    }
7783
7784    fn has_write_access(&self, cx: &App) -> bool {
7785        !self.project.read(cx).is_read_only(cx)
7786    }
7787
7788    pub fn load_commit_template(
7789        &self,
7790        cx: &mut Context<Self>,
7791    ) -> Task<anyhow::Result<Option<GitCommitTemplate>>> {
7792        let Some(repo) = self.active_repository.clone() else {
7793            return Task::ready(Err(anyhow::anyhow!("no active repo")));
7794        };
7795        repo.update(cx, |repo, cx| {
7796            let rx = repo.load_commit_template_text();
7797            cx.spawn(async move |_, _| rx.await?)
7798        })
7799    }
7800
7801    pub fn amend_pending(&self) -> bool {
7802        self.amend_pending
7803    }
7804
7805    /// Sets the pending amend state, ensuring that the original commit message
7806    /// is either saved, when `value` is `true` and there's no pending amend, or
7807    /// restored, when `value` is `false` and there's a pending amend.
7808    pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
7809        if value && !self.amend_pending {
7810            let current_message = self.commit_message_buffer(cx).read(cx).text();
7811            self.original_commit_message = if current_message.trim().is_empty() {
7812                None
7813            } else {
7814                Some(current_message)
7815            };
7816        } else if !value && self.amend_pending {
7817            let message = self.original_commit_message.take().unwrap_or_default();
7818            self.commit_message_buffer(cx).update(cx, |buffer, cx| {
7819                let start = buffer.anchor_before(0);
7820                let end = buffer.anchor_after(buffer.len());
7821                buffer.edit([(start..end, message)], None, cx);
7822            });
7823        }
7824
7825        self.amend_pending = value;
7826        self.serialize(cx);
7827        cx.notify();
7828    }
7829
7830    pub fn signoff_enabled(&self) -> bool {
7831        self.signoff_enabled
7832    }
7833
7834    pub fn skip_hooks_enabled(&self) -> bool {
7835        self.skip_hooks_enabled
7836    }
7837
7838    fn set_skip_hooks_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
7839        if self.skip_hooks_enabled != value {
7840            self.skip_hooks_enabled = value;
7841            cx.notify();
7842        }
7843    }
7844
7845    pub fn toggle_skip_hooks(
7846        &mut self,
7847        _: &SkipHooks,
7848        _window: &mut Window,
7849        cx: &mut Context<Self>,
7850    ) {
7851        self.set_skip_hooks_enabled(!self.skip_hooks_enabled, cx);
7852    }
7853
7854    pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
7855        self.signoff_enabled = value;
7856        self.serialize(cx);
7857        cx.notify();
7858    }
7859
7860    pub fn toggle_signoff_enabled(
7861        &mut self,
7862        _: &Signoff,
7863        _window: &mut Window,
7864        cx: &mut Context<Self>,
7865    ) {
7866        self.set_signoff_enabled(!self.signoff_enabled, cx);
7867    }
7868
7869    pub async fn load(
7870        workspace: WeakEntity<Workspace>,
7871        mut cx: AsyncWindowContext,
7872    ) -> anyhow::Result<Entity<Self>> {
7873        let serialized_panel = match workspace
7874            .read_with(&cx, |workspace, cx| {
7875                Self::serialization_key(workspace).map(|key| (key, KeyValueStore::global(cx)))
7876            })
7877            .ok()
7878            .flatten()
7879        {
7880            Some((serialization_key, kvp)) => cx
7881                .background_spawn(async move { kvp.read_kvp(&serialization_key) })
7882                .await
7883                .context("loading git panel")
7884                .log_err()
7885                .flatten()
7886                .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
7887                .transpose()
7888                .log_err()
7889                .flatten(),
7890            None => None,
7891        };
7892
7893        workspace.update_in(&mut cx, |workspace, window, cx| {
7894            GitPanel::new_with_serialized_panel(workspace, serialized_panel, window, cx)
7895        })
7896    }
7897
7898    fn stage_bulk(&mut self, mut index: usize, stage: bool, cx: &mut Context<'_, Self>) {
7899        let Some(op) = self.bulk_staging.as_ref() else {
7900            return;
7901        };
7902        let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else {
7903            return;
7904        };
7905        // Only a staged anchor survives the next entries refresh, so there's no
7906        // point re-anchoring on the entry we're about to unstage.
7907        if stage
7908            && let Some(entry) = self.entries.get(index)
7909            && let Some(entry) = entry.status_entry()
7910        {
7911            self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
7912        }
7913        if index < anchor_index {
7914            std::mem::swap(&mut index, &mut anchor_index);
7915        }
7916        let Some(repo) = self.active_repository.clone() else {
7917            return;
7918        };
7919        let repo = repo.read(cx);
7920        // Conflicts only change staging via their own explicit controls; a
7921        // range sweep must neither mark them resolved nor un-resolve them.
7922        let entries = self
7923            .entries
7924            .get(anchor_index..=index)
7925            .unwrap_or_default()
7926            .iter()
7927            .filter_map(|entry| entry.status_entry().cloned())
7928            .filter(|entry| !repo.had_conflict_on_last_merge_head_change(&entry.repo_path))
7929            .collect::<Vec<_>>();
7930        self.change_file_stage(stage, entries, cx);
7931    }
7932
7933    fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
7934        let Some(repo) = self.active_repository.as_ref() else {
7935            return;
7936        };
7937        self.bulk_staging = Some(BulkStaging {
7938            repo_id: repo.read(cx).id,
7939            anchor: path,
7940        });
7941    }
7942
7943    pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
7944        self.set_amend_pending(!self.amend_pending, cx);
7945        if self.amend_pending {
7946            self.load_last_commit_message(cx);
7947        }
7948    }
7949}
7950
7951struct GenerateCommitMessageConfigurationTooltip;
7952
7953impl Render for GenerateCommitMessageConfigurationTooltip {
7954    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
7955        ui::tooltip_container(cx, |container, _cx| {
7956            container
7957                .gap_1p5()
7958                .child(Label::new(
7959                    "Configure an LLM provider to generate commit messages.",
7960                ))
7961                .child(
7962                    h_flex()
7963                        .gap_1()
7964                        .child(
7965                            Button::new("configure-commit-message-provider", "Configure Provider")
7966                                .style(ButtonStyle::Filled)
7967                                .layer(ElevationIndex::ModalSurface)
7968                                .label_size(LabelSize::Small)
7969                                .on_click(|_, window, cx| {
7970                                    window.dispatch_action(
7971                                        zed_actions::OpenSettingsAt {
7972                                            path: "llm_providers".to_string(),
7973                                            target: None,
7974                                        }
7975                                        .boxed_clone(),
7976                                        cx,
7977                                    );
7978                                }),
7979                        )
7980                        .child(
7981                            Button::new("llm-provider-docs", "See Docs")
7982                                .style(ButtonStyle::OutlinedGhost)
7983                                .end_icon(
7984                                    Icon::new(IconName::ArrowUpRight)
7985                                        .color(Color::Muted)
7986                                        .size(IconSize::Small),
7987                                )
7988                                .label_size(LabelSize::Small)
7989                                .on_click(move |_, _, cx| {
7990                                    cx.open_url(&zed_urls::llm_provider_docs(cx))
7991                                }),
7992                        ),
7993                )
7994        })
7995    }
7996}
7997
7998impl GitPanel {
7999    pub fn selected_file_history_target(&self) -> Option<(Entity<Repository>, RepoPath)> {
8000        let entry = self.get_selected_entry()?.status_entry()?;
8001        let repository = self.active_repository.clone()?;
8002        if entry.status.is_created() {
8003            return None;
8004        }
8005        Some((repository, entry.repo_path.clone()))
8006    }
8007}
8008
8009#[cfg(any(test, feature = "test-support"))]
8010impl GitPanel {
8011    pub fn new_test(
8012        workspace: &mut Workspace,
8013        window: &mut Window,
8014        cx: &mut Context<Workspace>,
8015    ) -> Entity<Self> {
8016        Self::new(workspace, window, cx)
8017    }
8018
8019    pub fn active_repository(&self) -> Option<&Entity<Repository>> {
8020        self.active_repository.as_ref()
8021    }
8022}
8023
8024impl Render for GitPanel {
8025    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
8026        let project = self.project.read(cx);
8027        let has_entries = !self.entries.is_empty();
8028        let has_write_access = self.has_write_access(cx);
8029
8030        #[cfg(feature = "call")]
8031        let has_co_authors = self
8032            .workspace
8033            .upgrade()
8034            .and_then(|_workspace| {
8035                call::ActiveCall::try_global(cx).and_then(|call| call.read(cx).room().cloned())
8036            })
8037            .is_some_and(|room| {
8038                self.load_local_committer(cx);
8039                let room = room.read(cx);
8040                room.remote_participants()
8041                    .values()
8042                    .any(|remote_participant| remote_participant.can_write())
8043            });
8044        #[cfg(not(feature = "call"))]
8045        let has_co_authors = false;
8046
8047        v_flex()
8048            .id("git_panel")
8049            .key_context(self.dispatch_context(window, cx))
8050            .track_focus(&self.focus_handle)
8051            .when(has_write_access && !project.is_read_only(cx), |this| {
8052                this.on_action(cx.listener(Self::toggle_staged_for_selected))
8053                    .on_action(cx.listener(Self::stage_range))
8054                    .on_action(cx.listener(GitPanel::on_commit))
8055                    .on_action(cx.listener(GitPanel::toggle_skip_hooks))
8056                    .on_action(cx.listener(GitPanel::on_amend))
8057                    .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
8058                    .on_action(cx.listener(Self::stage_all))
8059                    .on_action(cx.listener(Self::unstage_all))
8060                    .on_action(cx.listener(Self::stage_selected))
8061                    .on_action(cx.listener(Self::unstage_selected))
8062                    .on_action(cx.listener(Self::restore_tracked_files))
8063                    .on_action(cx.listener(Self::revert_selected))
8064                    .on_action(cx.listener(Self::add_to_gitignore))
8065                    .on_action(cx.listener(Self::add_to_git_info_exclude))
8066                    .on_action(cx.listener(Self::clean_all))
8067                    .on_action(cx.listener(Self::generate_commit_message_action))
8068                    .on_action(cx.listener(Self::stash_all))
8069                    .on_action(cx.listener(Self::stash_pop))
8070            })
8071            .on_action(cx.listener(Self::collapse_selected_entry))
8072            .on_action(cx.listener(Self::expand_selected_entry))
8073            .on_action(cx.listener(Self::select_first))
8074            .on_action(cx.listener(Self::select_next))
8075            .on_action(cx.listener(Self::select_previous))
8076            .on_action(cx.listener(Self::select_last))
8077            .on_action(cx.listener(Self::first_entry))
8078            .on_action(cx.listener(Self::next_entry))
8079            .on_action(cx.listener(Self::previous_entry))
8080            .on_action(cx.listener(Self::last_entry))
8081            .on_action(cx.listener(Self::close_panel))
8082            .on_action(cx.listener(Self::open_diff))
8083            .on_action(cx.listener(Self::open_solo_diff))
8084            .on_action(cx.listener(Self::view_file))
8085            .on_action(cx.listener(Self::view_unstaged_changes))
8086            .on_action(cx.listener(Self::view_staged_changes))
8087            .on_action(cx.listener(Self::focus_changes_list))
8088            .on_action(cx.listener(Self::focus_editor))
8089            .on_action(cx.listener(Self::expand_commit_editor))
8090            .when(has_write_access && has_co_authors, |git_panel| {
8091                git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
8092            })
8093            .on_action(cx.listener(Self::set_sort_by_path))
8094            .on_action(cx.listener(Self::set_sort_by_name))
8095            .on_action(cx.listener(Self::set_group_by_none))
8096            .on_action(cx.listener(Self::set_group_by_status))
8097            .on_action(cx.listener(Self::set_group_by_staging))
8098            .on_action(cx.listener(Self::toggle_tree_view))
8099            .on_action(cx.listener(Self::increase_font_size))
8100            .on_action(cx.listener(Self::decrease_font_size))
8101            .on_action(cx.listener(Self::reset_font_size))
8102            .on_action(cx.listener(Self::activate_changes_tab))
8103            .on_action(cx.listener(Self::activate_history_tab))
8104            .size_full()
8105            .overflow_hidden()
8106            .bg(cx.theme().colors().panel_background)
8107            .child(
8108                v_flex()
8109                    .size_full()
8110                    .when(!self.commit_editor_expanded, |this| {
8111                        this.child(self.render_tab_bar(cx))
8112                    })
8113                    .map(|this| match self.active_tab {
8114                        GitPanelTab::Changes => this
8115                            .children(self.render_changes_header(window, cx))
8116                            .when(!self.commit_editor_expanded, |this| {
8117                                this.map(|this| {
8118                                    if let Some(repo) = self.active_repository.clone()
8119                                        && has_entries
8120                                    {
8121                                        this.child(self.render_entries(
8122                                            has_write_access,
8123                                            repo,
8124                                            window,
8125                                            cx,
8126                                        ))
8127                                    } else {
8128                                        this.child(self.render_empty_state(cx).into_any_element())
8129                                    }
8130                                })
8131                            })
8132                            .children(self.render_footer(window, cx))
8133                            .when(self.amend_pending, |this| {
8134                                this.child(self.render_pending_amend(cx))
8135                            })
8136                            .when(!self.amend_pending, |this| {
8137                                this.children(self.render_previous_commit(window, cx))
8138                            }),
8139                        GitPanelTab::History => this.child(self.render_history_tab(window, cx)),
8140                    })
8141                    .into_any_element(),
8142            )
8143            .children(self.context_menu.as_ref().map(|context_menu| {
8144                deferred(
8145                    anchored()
8146                        .position(context_menu.position)
8147                        .anchor(Anchor::TopLeft)
8148                        .child(context_menu.menu.clone()),
8149                )
8150                .with_priority(1)
8151            }))
8152    }
8153}
8154
8155impl Focusable for GitPanel {
8156    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
8157        if self.entries.is_empty() || self.commit_editor_expanded {
8158            self.commit_editor.focus_handle(cx)
8159        } else {
8160            self.focus_handle.clone()
8161        }
8162    }
8163}
8164
8165impl EventEmitter<Event> for GitPanel {}
8166
8167impl EventEmitter<PanelEvent> for GitPanel {}
8168
8169pub(crate) struct GitPanelAddon {
8170    pub(crate) workspace: WeakEntity<Workspace>,
8171}
8172
8173impl editor::Addon for GitPanelAddon {
8174    fn to_any(&self) -> &dyn std::any::Any {
8175        self
8176    }
8177
8178    fn render_buffer_header_controls(
8179        &self,
8180        _excerpt_info: &ExcerptBoundaryInfo,
8181        buffer: &language::BufferSnapshot,
8182        window: &Window,
8183        cx: &App,
8184    ) -> Option<AnyElement> {
8185        let file = buffer.file()?;
8186        let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
8187
8188        git_panel
8189            .read(cx)
8190            .render_buffer_header_controls(&git_panel, file, window, cx)
8191    }
8192}
8193
8194impl Panel for GitPanel {
8195    fn persistent_name() -> &'static str {
8196        "GitPanel"
8197    }
8198
8199    fn panel_key() -> &'static str {
8200        GIT_PANEL_KEY
8201    }
8202
8203    fn position(&self, _: &Window, cx: &App) -> DockPosition {
8204        GitPanelSettings::get_global(cx).dock
8205    }
8206
8207    fn position_is_valid(&self, position: DockPosition) -> bool {
8208        matches!(position, DockPosition::Left | DockPosition::Right)
8209    }
8210
8211    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
8212        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
8213            settings.git_panel.get_or_insert_default().dock = Some(position.into())
8214        });
8215    }
8216
8217    fn default_size(&self, _: &Window, cx: &App) -> Pixels {
8218        GitPanelSettings::get_global(cx).default_width
8219    }
8220
8221    fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
8222        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
8223    }
8224
8225    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
8226        Some("Git Panel")
8227    }
8228
8229    fn icon_label(&self, _: &Window, cx: &App) -> Option<String> {
8230        if !GitPanelSettings::get_global(cx).show_count_badge {
8231            return None;
8232        }
8233        let total = self.changes_count;
8234        (total > 0).then(|| total.to_string())
8235    }
8236
8237    fn toggle_action(&self) -> Box<dyn Action> {
8238        Box::new(ToggleFocus)
8239    }
8240
8241    fn starts_open(&self, _: &Window, cx: &App) -> bool {
8242        GitPanelSettings::get_global(cx).starts_open
8243    }
8244
8245    fn activation_priority(&self) -> u32 {
8246        3
8247    }
8248
8249    fn hide_button_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
8250        Some(workspace::HideStatusItem::new(|settings| {
8251            settings.git_panel.get_or_insert_default().button = Some(false);
8252        }))
8253    }
8254}
8255
8256impl PanelHeader for GitPanel {}
8257
8258pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
8259    v_flex()
8260        .size_full()
8261        .bg(cx.theme().colors().editor_background)
8262}
8263
8264pub(crate) fn git_commit_editor_style(font_size: gpui::Pixels, cx: &App) -> EditorStyle {
8265    let settings = ThemeSettings::get_global(cx);
8266
8267    EditorStyle {
8268        background: cx.theme().colors().editor_background,
8269        local_player: cx.theme().players().local(),
8270        text: TextStyle {
8271            color: cx.theme().colors().text,
8272            font_family: settings.buffer_font.family.clone(),
8273            font_fallbacks: settings.buffer_font.fallbacks.clone(),
8274            font_features: settings.buffer_font.features.clone(),
8275            font_size: AbsoluteLength::from(font_size),
8276            font_weight: settings.buffer_font.weight,
8277            line_height: (font_size * settings.buffer_line_height.value()).into(),
8278            ..Default::default()
8279        },
8280        syntax: cx.theme().syntax().clone(),
8281        ..Default::default()
8282    }
8283}
8284
8285struct GitPanelMessageTooltip {
8286    commit_tooltip: Option<Entity<CommitTooltip>>,
8287}
8288
8289impl GitPanelMessageTooltip {
8290    fn new(
8291        git_panel: Entity<GitPanel>,
8292        sha: SharedString,
8293        repository: Entity<Repository>,
8294        window: &mut Window,
8295        cx: &mut App,
8296    ) -> Entity<Self> {
8297        let remote_url = repository.read(cx).default_remote_url();
8298        cx.new(|cx| {
8299            cx.spawn_in(window, async move |this, cx| {
8300                let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
8301                    (
8302                        git_panel.load_commit_details(sha.to_string(), cx),
8303                        git_panel.workspace.clone(),
8304                    )
8305                });
8306                let details = details.await?;
8307                let provider_registry = cx
8308                    .update(|_, app| GitHostingProviderRegistry::default_global(app))
8309                    .ok();
8310
8311                let commit_details = crate::commit_tooltip::CommitDetails {
8312                    sha: details.sha.clone(),
8313                    author_name: details.author_name.clone(),
8314                    author_email: details.author_email.clone(),
8315                    commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
8316                    message: Some(ParsedCommitMessage::parse(
8317                        details.sha.to_string(),
8318                        details.message.to_string(),
8319                        remote_url.as_deref(),
8320                        provider_registry,
8321                    )),
8322                    tag_names: Vec::new(),
8323                };
8324
8325                this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
8326                    this.commit_tooltip = Some(cx.new(move |cx| {
8327                        CommitTooltip::new(commit_details, repository, workspace, cx)
8328                    }));
8329                    cx.notify();
8330                })
8331            })
8332            .detach();
8333
8334            Self {
8335                commit_tooltip: None,
8336            }
8337        })
8338    }
8339}
8340
8341impl Render for GitPanelMessageTooltip {
8342    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
8343        if let Some(commit_tooltip) = &self.commit_tooltip {
8344            commit_tooltip.clone().into_any_element()
8345        } else {
8346            gpui::Empty.into_any_element()
8347        }
8348    }
8349}
8350
8351#[derive(IntoElement, RegisterComponent)]
8352pub struct PanelRepoFooter {
8353    active_repository: SharedString,
8354    branch: Option<Branch>,
8355    head_commit: Option<CommitDetails>,
8356
8357    // Getting a GitPanel in previews will be difficult.
8358    //
8359    // For now just take an option here, and we won't bind handlers to buttons in previews.
8360    git_panel: Option<Entity<GitPanel>>,
8361}
8362
8363impl PanelRepoFooter {
8364    pub fn new(
8365        active_repository: SharedString,
8366        branch: Option<Branch>,
8367        head_commit: Option<CommitDetails>,
8368        git_panel: Option<Entity<GitPanel>>,
8369    ) -> Self {
8370        Self {
8371            active_repository,
8372            branch,
8373            head_commit,
8374            git_panel,
8375        }
8376    }
8377
8378    pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
8379        Self {
8380            active_repository,
8381            branch,
8382            head_commit: None,
8383            git_panel: None,
8384        }
8385    }
8386}
8387
8388impl RenderOnce for PanelRepoFooter {
8389    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
8390        let project = self
8391            .git_panel
8392            .as_ref()
8393            .map(|panel| panel.read(cx).project.clone());
8394
8395        let (workspace, repo) = self
8396            .git_panel
8397            .as_ref()
8398            .map(|panel| {
8399                let panel = panel.read(cx);
8400                (panel.workspace.clone(), panel.active_repository.clone())
8401            })
8402            .unzip();
8403
8404        let single_repo = project
8405            .as_ref()
8406            .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
8407            .unwrap_or(true);
8408
8409        const MAX_SHORT_SHA_LEN: usize = 8;
8410        let branch_name = self
8411            .branch
8412            .as_ref()
8413            .map(|branch| branch.name().to_owned())
8414            .or_else(|| {
8415                self.head_commit.as_ref().map(|commit| {
8416                    commit
8417                        .sha
8418                        .chars()
8419                        .take(MAX_SHORT_SHA_LEN)
8420                        .collect::<String>()
8421                })
8422            })
8423            .unwrap_or_else(|| " (no branch)".to_owned());
8424        let show_separator = self.branch.is_some() || self.head_commit.is_some();
8425
8426        let active_repo_name = self.active_repository.clone();
8427
8428        let repo_selector = PopoverMenu::new("repository-switcher")
8429            .menu({
8430                let project = project;
8431                move |window, cx| {
8432                    let project = project.clone()?;
8433                    Some(cx.new(|cx| RepositorySelector::new(project, rems(20.), window, cx)))
8434                }
8435            })
8436            .trigger_with_tooltip(
8437                Button::new("repo-selector", active_repo_name)
8438                    .size(ButtonSize::None)
8439                    .label_size(LabelSize::Small)
8440                    .truncate(true),
8441                move |_, cx| {
8442                    if single_repo {
8443                        cx.new(|_| Empty).into()
8444                    } else {
8445                        Tooltip::simple("Switch Active Repository", cx)
8446                    }
8447                },
8448            )
8449            .anchor(Anchor::BottomLeft)
8450            .offset(gpui::Point {
8451                x: px(0.0),
8452                y: px(-2.0),
8453            })
8454            .into_any_element();
8455
8456        let branch_selector_button = Button::new("branch-selector", branch_name)
8457            .size(ButtonSize::None)
8458            .label_size(LabelSize::Small)
8459            .truncate(true)
8460            .on_click(|_, window, cx| {
8461                window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
8462            });
8463
8464        let branch_selector = PopoverMenu::new("popover-button")
8465            .menu(move |window, cx| {
8466                let workspace = workspace.clone()?;
8467                let repo = repo.clone().flatten();
8468                Some(branch_picker::popover(workspace, false, repo, window, cx))
8469            })
8470            .trigger_with_tooltip(
8471                branch_selector_button,
8472                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
8473            )
8474            .anchor(Anchor::BottomLeft)
8475            .offset(gpui::Point {
8476                x: px(0.0),
8477                y: px(-2.0),
8478            });
8479
8480        h_flex()
8481            .w_full()
8482            .px_2()
8483            .py_1p5()
8484            .justify_between()
8485            .gap_1()
8486            .child(
8487                h_flex()
8488                    .flex_1()
8489                    .overflow_hidden()
8490                    .gap_px()
8491                    .child(Icon::new(IconName::GitBranch).size(IconSize::Small).color(
8492                        if single_repo {
8493                            Color::Disabled
8494                        } else {
8495                            Color::Muted
8496                        },
8497                    ))
8498                    .when(!single_repo, |this| {
8499                        this.child(div().child(repo_selector).min_w_0()).when(
8500                            show_separator,
8501                            |this| {
8502                                this.child(Label::new("/").size(LabelSize::Small).color(
8503                                    Color::Custom(cx.theme().colors().text_muted.opacity(0.4)),
8504                                ))
8505                            },
8506                        )
8507                    })
8508                    .child(div().child(branch_selector).min_w_0()),
8509            )
8510            .children(if let Some(git_panel) = self.git_panel {
8511                git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
8512            } else {
8513                None
8514            })
8515    }
8516}
8517
8518impl Component for PanelRepoFooter {
8519    fn scope() -> ComponentScope {
8520        ComponentScope::VersionControl
8521    }
8522
8523    fn description() -> &'static str {
8524        "The footer shown at the bottom of the git panel."
8525    }
8526
8527    fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
8528        let unknown_upstream = None;
8529        let no_remote_upstream = Some(UpstreamTracking::Gone);
8530        let ahead_of_upstream = Some(
8531            UpstreamTrackingStatus {
8532                ahead: 2,
8533                behind: 0,
8534            }
8535            .into(),
8536        );
8537        let behind_upstream = Some(
8538            UpstreamTrackingStatus {
8539                ahead: 0,
8540                behind: 2,
8541            }
8542            .into(),
8543        );
8544        let ahead_and_behind_upstream = Some(
8545            UpstreamTrackingStatus {
8546                ahead: 3,
8547                behind: 1,
8548            }
8549            .into(),
8550        );
8551
8552        let not_ahead_or_behind_upstream = Some(
8553            UpstreamTrackingStatus {
8554                ahead: 0,
8555                behind: 0,
8556            }
8557            .into(),
8558        );
8559
8560        fn branch(upstream: Option<UpstreamTracking>) -> Branch {
8561            Branch {
8562                is_head: true,
8563                ref_name: "some-branch".into(),
8564                upstream: upstream.map(|tracking| Upstream {
8565                    ref_name: "origin/some-branch".into(),
8566                    tracking,
8567                }),
8568                most_recent_commit: Some(CommitSummary {
8569                    sha: "abc123".into(),
8570                    subject: "Modify stuff".into(),
8571                    commit_timestamp: 1710932954,
8572                    author_name: "John Doe".into(),
8573                    has_parent: true,
8574                }),
8575            }
8576        }
8577
8578        fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
8579            Branch {
8580                is_head: true,
8581                ref_name: branch_name.to_string().into(),
8582                upstream: upstream.map(|tracking| Upstream {
8583                    ref_name: format!("zed/{}", branch_name).into(),
8584                    tracking,
8585                }),
8586                most_recent_commit: Some(CommitSummary {
8587                    sha: "abc123".into(),
8588                    subject: "Modify stuff".into(),
8589                    commit_timestamp: 1710932954,
8590                    author_name: "John Doe".into(),
8591                    has_parent: true,
8592                }),
8593            }
8594        }
8595
8596        fn active_repository(id: usize) -> SharedString {
8597            format!("repo-{}", id).into()
8598        }
8599
8600        let example_width = px(340.);
8601
8602        v_flex()
8603            .gap_6()
8604            .w_full()
8605            .flex_none()
8606            .children(vec![
8607                example_group_with_title(
8608                    "Action Button States",
8609                    vec![
8610                        single_example(
8611                            "No Branch",
8612                            div()
8613                                .w(example_width)
8614                                .overflow_hidden()
8615                                .child(PanelRepoFooter::new_preview(active_repository(1), None))
8616                                .into_any_element(),
8617                        ),
8618                        single_example(
8619                            "Remote status unknown",
8620                            div()
8621                                .w(example_width)
8622                                .overflow_hidden()
8623                                .child(PanelRepoFooter::new_preview(
8624                                    active_repository(2),
8625                                    Some(branch(unknown_upstream)),
8626                                ))
8627                                .into_any_element(),
8628                        ),
8629                        single_example(
8630                            "No Remote Upstream",
8631                            div()
8632                                .w(example_width)
8633                                .overflow_hidden()
8634                                .child(PanelRepoFooter::new_preview(
8635                                    active_repository(3),
8636                                    Some(branch(no_remote_upstream)),
8637                                ))
8638                                .into_any_element(),
8639                        ),
8640                        single_example(
8641                            "Not Ahead or Behind",
8642                            div()
8643                                .w(example_width)
8644                                .overflow_hidden()
8645                                .child(PanelRepoFooter::new_preview(
8646                                    active_repository(4),
8647                                    Some(branch(not_ahead_or_behind_upstream)),
8648                                ))
8649                                .into_any_element(),
8650                        ),
8651                        single_example(
8652                            "Behind remote",
8653                            div()
8654                                .w(example_width)
8655                                .overflow_hidden()
8656                                .child(PanelRepoFooter::new_preview(
8657                                    active_repository(5),
8658                                    Some(branch(behind_upstream)),
8659                                ))
8660                                .into_any_element(),
8661                        ),
8662                        single_example(
8663                            "Ahead of remote",
8664                            div()
8665                                .w(example_width)
8666                                .overflow_hidden()
8667                                .child(PanelRepoFooter::new_preview(
8668                                    active_repository(6),
8669                                    Some(branch(ahead_of_upstream)),
8670                                ))
8671                                .into_any_element(),
8672                        ),
8673                        single_example(
8674                            "Ahead and behind remote",
8675                            div()
8676                                .w(example_width)
8677                                .overflow_hidden()
8678                                .child(PanelRepoFooter::new_preview(
8679                                    active_repository(7),
8680                                    Some(branch(ahead_and_behind_upstream)),
8681                                ))
8682                                .into_any_element(),
8683                        ),
8684                    ],
8685                )
8686                .grow()
8687                .vertical(),
8688            ])
8689            .children(vec![
8690                example_group_with_title(
8691                    "Labels",
8692                    vec![
8693                        single_example(
8694                            "Short Branch & Repo",
8695                            div()
8696                                .w(example_width)
8697                                .overflow_hidden()
8698                                .child(PanelRepoFooter::new_preview(
8699                                    SharedString::from("zed"),
8700                                    Some(custom("main", behind_upstream)),
8701                                ))
8702                                .into_any_element(),
8703                        ),
8704                        single_example(
8705                            "Long Branch",
8706                            div()
8707                                .w(example_width)
8708                                .overflow_hidden()
8709                                .child(PanelRepoFooter::new_preview(
8710                                    SharedString::from("zed"),
8711                                    Some(custom(
8712                                        "redesign-and-update-git-ui-list-entry-style",
8713                                        behind_upstream,
8714                                    )),
8715                                ))
8716                                .into_any_element(),
8717                        ),
8718                        single_example(
8719                            "Long Repo",
8720                            div()
8721                                .w(example_width)
8722                                .overflow_hidden()
8723                                .child(PanelRepoFooter::new_preview(
8724                                    SharedString::from("zed-industries-community-examples"),
8725                                    Some(custom("gpui", ahead_of_upstream)),
8726                                ))
8727                                .into_any_element(),
8728                        ),
8729                        single_example(
8730                            "Long Repo & Branch",
8731                            div()
8732                                .w(example_width)
8733                                .overflow_hidden()
8734                                .child(PanelRepoFooter::new_preview(
8735                                    SharedString::from("zed-industries-community-examples"),
8736                                    Some(custom(
8737                                        "redesign-and-update-git-ui-list-entry-style",
8738                                        behind_upstream,
8739                                    )),
8740                                ))
8741                                .into_any_element(),
8742                        ),
8743                        single_example(
8744                            "Uppercase Repo",
8745                            div()
8746                                .w(example_width)
8747                                .overflow_hidden()
8748                                .child(PanelRepoFooter::new_preview(
8749                                    SharedString::from("LICENSES"),
8750                                    Some(custom("main", ahead_of_upstream)),
8751                                ))
8752                                .into_any_element(),
8753                        ),
8754                        single_example(
8755                            "Uppercase Branch",
8756                            div()
8757                                .w(example_width)
8758                                .overflow_hidden()
8759                                .child(PanelRepoFooter::new_preview(
8760                                    SharedString::from("zed"),
8761                                    Some(custom("update-README", behind_upstream)),
8762                                ))
8763                                .into_any_element(),
8764                        ),
8765                    ],
8766                )
8767                .grow()
8768                .vertical(),
8769            ])
8770            .into_any_element()
8771    }
8772}
8773
8774pub(crate) fn open_output(
8775    operation: impl Into<SharedString>,
8776    workspace: &mut Workspace,
8777    output: &str,
8778    window: &mut Window,
8779    cx: &mut Context<Workspace>,
8780) {
8781    let operation = operation.into();
8782
8783    let plain_text = terminal::strip_ansi_text(output.as_bytes());
8784
8785    let buffer = cx.new(|cx| Buffer::local(plain_text.as_str(), cx));
8786    buffer.update(cx, |buffer, cx| {
8787        buffer.set_capability(language::Capability::ReadOnly, cx);
8788    });
8789    let editor = cx.new(|cx| {
8790        let mut editor = Editor::for_buffer(buffer, None, window, cx);
8791        editor.buffer().update(cx, |buffer, cx| {
8792            buffer.set_title(format!("Output from git {operation}"), cx);
8793        });
8794        editor.set_read_only(true);
8795        editor
8796    });
8797
8798    workspace.add_item_to_center(Box::new(editor), window, cx);
8799}
8800
8801pub(crate) fn show_error_toast(
8802    workspace: Entity<Workspace>,
8803    action: impl Into<SharedString>,
8804    e: anyhow::Error,
8805    cx: &mut App,
8806) {
8807    let action = action.into();
8808    let message = format_git_error_toast_message(&e);
8809    if message
8810        .matches(git::repository::REMOTE_CANCELLED_BY_USER)
8811        .next()
8812        .is_some()
8813    { // Hide the cancelled by user message
8814    } else {
8815        cx.defer(move |cx| {
8816            workspace.update(cx, |workspace, cx| {
8817                let workspace_weak = cx.weak_entity();
8818                let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
8819                    this.icon(
8820                        Icon::new(IconName::XCircle)
8821                            .size(IconSize::Small)
8822                            .color(Color::Error),
8823                    )
8824                    .action("View Log", move |window, cx| {
8825                        let message = message.clone();
8826                        let action = action.clone();
8827                        workspace_weak
8828                            .update(cx, move |workspace, cx| {
8829                                open_output(action, workspace, &message, window, cx)
8830                            })
8831                            .ok();
8832                    })
8833                });
8834                workspace.toggle_status_toast(toast, cx)
8835            });
8836        });
8837    }
8838}
8839
8840fn rpc_error_raw_message_from_chain(error: &anyhow::Error) -> Option<&str> {
8841    error
8842        .chain()
8843        .find_map(|cause| cause.downcast_ref::<RpcError>().map(RpcError::raw_message))
8844}
8845
8846fn format_git_error_toast_message(error: &anyhow::Error) -> String {
8847    if let Some(message) = rpc_error_raw_message_from_chain(error) {
8848        message.trim().to_string()
8849    } else {
8850        error.to_string().trim().to_string()
8851    }
8852}
8853
8854pub(crate) fn commit_title_exceeds_limit(title: &str, max_length: usize) -> bool {
8855    max_length > 0 && title.chars().count() > max_length
8856}
8857
8858#[cfg(test)]
8859mod tests {
8860    use editor::SplittableEditor;
8861    use git::{
8862        repository::repo_path,
8863        status::{StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode},
8864    };
8865    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, px};
8866    use indoc::indoc;
8867    use project::FakeFs;
8868    use search::{BufferSearchBar, buffer_search::Deploy};
8869    use serde_json::json;
8870    use settings::SettingsStore;
8871    use std::any::TypeId;
8872    use theme::LoadThemes;
8873    use util::path;
8874    use util::rel_path::rel_path;
8875
8876    use workspace::{MultiWorkspace, ToolbarItemEvent, ToolbarItemLocation};
8877
8878    use super::*;
8879
8880    fn init_test(cx: &mut gpui::TestAppContext) {
8881        zlog::init_test();
8882
8883        cx.update(|cx| {
8884            let settings_store = SettingsStore::test(cx);
8885            cx.set_global(settings_store);
8886            theme_settings::init(LoadThemes::JustBase, cx);
8887            language_model::init(cx);
8888            editor::init(cx);
8889            crate::init(cx);
8890        });
8891    }
8892
8893    #[test]
8894    fn test_tree_view_directory_expansion_is_scoped_to_section() {
8895        let entry = |path, status| GitStatusEntry {
8896            repo_path: repo_path(path),
8897            status,
8898            staging: StageStatus::Unstaged,
8899            diff_stat: None,
8900        };
8901        let mut state = TreeViewState::default();
8902        let mut seen_directories = HashSet::default();
8903
8904        state.build_tree_entries(
8905            Section::Tracked,
8906            vec![entry("src/tracked.rs", StatusCode::Modified.worktree())],
8907            &mut seen_directories,
8908        );
8909        state.build_tree_entries(
8910            Section::New,
8911            vec![entry("src/new.rs", FileStatus::Untracked)],
8912            &mut seen_directories,
8913        );
8914
8915        let tracked_key = TreeKey {
8916            section: Section::Tracked,
8917            path: repo_path("src"),
8918        };
8919        let new_key = TreeKey {
8920            section: Section::New,
8921            path: repo_path("src"),
8922        };
8923        state.expanded_dirs.insert(tracked_key.clone(), false);
8924
8925        let tracked_entries = state.build_tree_entries(
8926            Section::Tracked,
8927            vec![entry("src/tracked.rs", StatusCode::Modified.worktree())],
8928            &mut seen_directories,
8929        );
8930        let new_entries = state.build_tree_entries(
8931            Section::New,
8932            vec![entry("src/new.rs", FileStatus::Untracked)],
8933            &mut seen_directories,
8934        );
8935
8936        assert_eq!(state.expanded_dirs.get(&tracked_key), Some(&false));
8937        assert_eq!(state.expanded_dirs.get(&new_key), Some(&true));
8938        assert!(matches!(
8939            tracked_entries.first(),
8940            Some((GitListEntry::Directory(entry), _)) if !entry.expanded
8941        ));
8942        assert!(matches!(
8943            new_entries.first(),
8944            Some((GitListEntry::Directory(entry), _)) if entry.expanded
8945        ));
8946    }
8947
8948    fn register_git_commit_language(project: &Entity<Project>, cx: &mut VisualTestContext) {
8949        project.read_with(cx, |project, _| {
8950            project.languages().add(Arc::new(language::Language::new(
8951                language::LanguageConfig {
8952                    name: "Git Commit".into(),
8953                    ..Default::default()
8954                },
8955                None,
8956            )));
8957        });
8958    }
8959
8960    fn entry_index_for_repo_path(panel: &GitPanel, repo_path: &RepoPath) -> Option<usize> {
8961        panel.entries.iter().position(|entry| {
8962            entry
8963                .status_entry()
8964                .is_some_and(|entry| &entry.repo_path == repo_path)
8965        })
8966    }
8967
8968    async fn await_git_panel_entries(panel: &Entity<GitPanel>, cx: &mut VisualTestContext) {
8969        let handle = cx.update_window_entity(panel, |panel, _, _| {
8970            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
8971        });
8972        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
8973        handle.await;
8974    }
8975
8976    fn assert_editor_opened_with_path(
8977        workspace: &Entity<Workspace>,
8978        expected_path: &Path,
8979        cx: &mut VisualTestContext,
8980    ) {
8981        workspace.update_in(cx, |workspace, _window, cx| {
8982            let editor = workspace
8983                .item_of_type::<editor::Editor>(cx)
8984                .expect("Editor should exist after View File");
8985            let file_path = editor
8986                .read(cx)
8987                .active_buffer(cx)
8988                .expect("Buffer should have an active buffer")
8989                .read(cx)
8990                .file()
8991                .cloned()
8992                .expect("Buffer should have a file");
8993            assert_eq!(file_path.path().as_ref().as_std_path(), expected_path);
8994        });
8995    }
8996
8997    async fn setup_git_panel_with_changes(
8998        cx: &mut TestAppContext,
8999        tree: serde_json::Value,
9000        status_entries: &[(&str, git::status::StatusCode)],
9001    ) -> (
9002        Arc<FakeFs>,
9003        Entity<Project>,
9004        Entity<Workspace>,
9005        Entity<GitPanel>,
9006        VisualTestContext,
9007    ) {
9008        let fs = FakeFs::new(cx.background_executor.clone());
9009        fs.insert_tree(path!("/project"), tree).await;
9010
9011        if !status_entries.is_empty() {
9012            fs.set_status_for_repo(
9013                path!("/project/.git").as_ref(),
9014                &status_entries
9015                    .iter()
9016                    .map(|(path, status)| (*path, status.worktree()))
9017                    .collect::<Vec<_>>(),
9018            );
9019        }
9020
9021        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
9022        let window_handle =
9023            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9024        let workspace = window_handle
9025            .read_with(cx, |mw, _| mw.workspace().clone())
9026            .unwrap();
9027        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
9028
9029        cx.read(|cx| {
9030            project
9031                .read(cx)
9032                .worktrees(cx)
9033                .next()
9034                .unwrap()
9035                .read(cx)
9036                .as_local()
9037                .unwrap()
9038                .scan_complete()
9039        })
9040        .await;
9041
9042        cx.executor().run_until_parked();
9043
9044        let panel = workspace.update_in(&mut cx, GitPanel::new);
9045        await_git_panel_entries(&panel, &mut cx).await;
9046
9047        (fs, project, workspace, panel, cx)
9048    }
9049
9050    #[gpui::test]
9051    async fn test_skip_hooks_toggle(cx: &mut TestAppContext) {
9052        init_test(cx);
9053        let (_, _, _, panel, mut cx) = setup_git_panel_with_changes(
9054            cx,
9055            json!({
9056                ".git": {},
9057                "file": "modified\n",
9058            }),
9059            &[("file", StatusCode::Modified)],
9060        )
9061        .await;
9062
9063        panel.update_in(&mut cx, |panel, window, cx| {
9064            assert!(!panel.skip_hooks_enabled());
9065            panel.toggle_skip_hooks(&SkipHooks, window, cx);
9066            assert!(panel.skip_hooks_enabled());
9067            assert!(panel.commit_options().no_verify);
9068
9069            panel.toggle_skip_hooks(&SkipHooks, window, cx);
9070            assert!(!panel.skip_hooks_enabled());
9071            assert!(!panel.commit_options().no_verify);
9072        });
9073    }
9074
9075    #[gpui::test]
9076    async fn test_skip_hooks_clears_after_successful_commit(cx: &mut TestAppContext) {
9077        init_test(cx);
9078        let (fs, _, _, panel, mut cx) = setup_git_panel_with_changes(
9079            cx,
9080            json!({
9081                ".git": {},
9082                "file": "modified\n",
9083            }),
9084            &[("file", StatusCode::Modified)],
9085        )
9086        .await;
9087
9088        panel.update_in(&mut cx, |panel, window, cx| {
9089            panel
9090                .commit_message_buffer(cx)
9091                .update(cx, |buffer, cx| buffer.set_text("Test commit", cx));
9092            panel.toggle_skip_hooks(&SkipHooks, window, cx);
9093            assert!(panel.commit_options().no_verify);
9094
9095            let focus_handle = panel.commit_editor.focus_handle(cx);
9096            focus_handle.focus(window, cx);
9097            assert!(panel.commit(&focus_handle, window, cx));
9098        });
9099        cx.run_until_parked();
9100
9101        panel.read_with(&cx, |panel, _| {
9102            assert!(!panel.skip_hooks_enabled());
9103        });
9104        let commit_count = fs
9105            .with_git_state(path!("/project/.git").as_ref(), false, |state| {
9106                state.commit_history.len()
9107            })
9108            .expect("fake repository should exist");
9109        assert_eq!(commit_count, 1);
9110    }
9111
9112    #[gpui::test]
9113    async fn test_skip_hooks_remains_enabled_after_failed_commit(cx: &mut TestAppContext) {
9114        init_test(cx);
9115        let (fs, _, _, panel, mut cx) = setup_git_panel_with_changes(
9116            cx,
9117            json!({
9118                ".git": {},
9119                "file": "modified\n",
9120            }),
9121            &[("file", StatusCode::Modified)],
9122        )
9123        .await;
9124
9125        fs.set_status_for_repo(
9126            path!("/project/.git").as_ref(),
9127            &[("file", StatusCode::Modified.index())],
9128        );
9129        cx.run_until_parked();
9130        await_git_panel_entries(&panel, &mut cx).await;
9131        fs.with_git_state(path!("/project/.git").as_ref(), false, |state| {
9132            state.index_contents = state.head_contents.clone();
9133        })
9134        .expect("fake repository should exist");
9135
9136        panel.update_in(&mut cx, |panel, window, cx| {
9137            panel
9138                .commit_message_buffer(cx)
9139                .update(cx, |buffer, cx| buffer.set_text("Test commit", cx));
9140            panel.toggle_skip_hooks(&SkipHooks, window, cx);
9141            assert!(panel.commit_options().no_verify);
9142
9143            let focus_handle = panel.commit_editor.focus_handle(cx);
9144            focus_handle.focus(window, cx);
9145            assert!(panel.commit(&focus_handle, window, cx));
9146        });
9147        cx.run_until_parked();
9148
9149        panel.read_with(&cx, |panel, _| {
9150            assert!(panel.skip_hooks_enabled());
9151        });
9152        let commit_count = fs
9153            .with_git_state(path!("/project/.git").as_ref(), false, |state| {
9154                state.commit_history.len()
9155            })
9156            .expect("fake repository should exist");
9157        assert_eq!(commit_count, 0);
9158    }
9159
9160    #[gpui::test]
9161    async fn test_view_file_tracked(cx: &mut TestAppContext) {
9162        init_test(cx);
9163
9164        let fs = FakeFs::new(cx.background_executor.clone());
9165        fs.insert_tree(
9166            path!("/project"),
9167            json!({
9168                ".git": {},
9169                "tracked": "tracked\n",
9170            }),
9171        )
9172        .await;
9173
9174        fs.set_head_and_index_for_repo(
9175            path!("/project/.git").as_ref(),
9176            &[("tracked", "old tracked\n".into())],
9177        );
9178
9179        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
9180        let window_handle =
9181            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9182        let workspace = window_handle
9183            .read_with(cx, |mw, _| mw.workspace().clone())
9184            .unwrap();
9185        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
9186
9187        cx.read(|cx| {
9188            project
9189                .read(cx)
9190                .worktrees(cx)
9191                .next()
9192                .unwrap()
9193                .read(cx)
9194                .as_local()
9195                .unwrap()
9196                .scan_complete()
9197        })
9198        .await;
9199
9200        let panel = workspace.update_in(&mut cx, GitPanel::new);
9201        await_git_panel_entries(&panel, &mut cx).await;
9202
9203        let entry_index = panel
9204            .read_with(&cx, |panel, _| {
9205                entry_index_for_repo_path(panel, &repo_path("tracked"))
9206            })
9207            .expect("tracked file should exist in the changes list");
9208
9209        panel.update_in(&mut cx, |panel, window, cx| {
9210            panel.selected_entry = Some(entry_index);
9211            panel.view_file(&ViewFile, window, cx);
9212        });
9213        cx.run_until_parked();
9214
9215        assert_editor_opened_with_path(&workspace, Path::new("tracked"), &mut cx);
9216    }
9217
9218    #[gpui::test]
9219    async fn test_view_file_untracked(cx: &mut TestAppContext) {
9220        init_test(cx);
9221
9222        let fs = FakeFs::new(cx.background_executor.clone());
9223        fs.insert_tree(
9224            path!("/project"),
9225            json!({
9226                ".git": {},
9227                "tracked": "tracked\n",
9228                "untracked": "\n",
9229            }),
9230        )
9231        .await;
9232
9233        fs.set_head_and_index_for_repo(
9234            path!("/project/.git").as_ref(),
9235            &[("tracked", "old tracked\n".into())],
9236        );
9237
9238        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
9239        let window_handle =
9240            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9241        let workspace = window_handle
9242            .read_with(cx, |mw, _| mw.workspace().clone())
9243            .unwrap();
9244        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
9245
9246        cx.read(|cx| {
9247            project
9248                .read(cx)
9249                .worktrees(cx)
9250                .next()
9251                .unwrap()
9252                .read(cx)
9253                .as_local()
9254                .unwrap()
9255                .scan_complete()
9256        })
9257        .await;
9258
9259        cx.update(|_window, cx| {
9260            SettingsStore::update_global(cx, |store, cx| {
9261                store.update_user_settings(cx, |settings| {
9262                    settings.git_panel.get_or_insert_default().sort_by = Some(GitPanelSortBy::Path);
9263                })
9264            });
9265        });
9266
9267        let panel = workspace.update_in(&mut cx, GitPanel::new);
9268        await_git_panel_entries(&panel, &mut cx).await;
9269
9270        let entry_index = panel
9271            .read_with(&cx, |panel, _| {
9272                entry_index_for_repo_path(panel, &repo_path("untracked"))
9273            })
9274            .expect("untracked file should exist in the changes list");
9275
9276        panel.update_in(&mut cx, |panel, window, cx| {
9277            panel.selected_entry = Some(entry_index);
9278            panel.view_file(&ViewFile, window, cx);
9279        });
9280        cx.run_until_parked();
9281
9282        assert_editor_opened_with_path(&workspace, Path::new("untracked"), &mut cx);
9283    }
9284
9285    #[gpui::test]
9286    async fn test_view_file_tree_view(cx: &mut TestAppContext) {
9287        init_test(cx);
9288
9289        let (_, _project, workspace, panel, mut cx) = setup_git_panel_with_changes(
9290            cx,
9291            json!({
9292                ".git": {},
9293                "src": {
9294                    "a": {
9295                        "foo.rs": "fn foo() {}",
9296                    },
9297                },
9298            }),
9299            &[("src/a/foo.rs", StatusCode::Modified)],
9300        )
9301        .await;
9302
9303        cx.update(|_window, cx| {
9304            SettingsStore::update_global(cx, |store, cx| {
9305                store.update_user_settings(cx, |settings| {
9306                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
9307                })
9308            });
9309        });
9310        await_git_panel_entries(&panel, &mut cx).await;
9311
9312        let entry_index = panel
9313            .read_with(&cx, |panel, _| {
9314                entry_index_for_repo_path(panel, &repo_path("src/a/foo.rs"))
9315            })
9316            .expect("foo.rs should exist in the tree view changes list");
9317
9318        panel.update_in(&mut cx, |panel, window, cx| {
9319            panel.selected_entry = Some(entry_index);
9320            panel.view_file(&ViewFile, window, cx);
9321        });
9322        cx.run_until_parked();
9323
9324        assert_editor_opened_with_path(&workspace, Path::new("src/a/foo.rs"), &mut cx);
9325    }
9326
9327    async fn history_panel_for_project(
9328        fs: Arc<FakeFs>,
9329        cx: &mut TestAppContext,
9330    ) -> Entity<GitPanel> {
9331        let project = Project::test(fs, [Path::new(path!("/root/project"))], cx).await;
9332        let window_handle =
9333            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9334        let workspace = window_handle
9335            .read_with(cx, |mw, _| mw.workspace().clone())
9336            .unwrap();
9337        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
9338
9339        cx.read(|cx| {
9340            project
9341                .read(cx)
9342                .worktrees(cx)
9343                .next()
9344                .unwrap()
9345                .read(cx)
9346                .as_local()
9347                .unwrap()
9348                .scan_complete()
9349        })
9350        .await;
9351        cx.executor().run_until_parked();
9352
9353        let panel = workspace.update_in(cx, GitPanel::new);
9354        panel.update_in(cx, |panel, window, cx| {
9355            panel.activate_history_tab(&ActivateHistoryTab, window, cx);
9356        });
9357        cx.run_until_parked();
9358        panel
9359    }
9360
9361    async fn wait_for_commit_history_to_settle(panel: &Entity<GitPanel>, cx: &mut TestAppContext) {
9362        cx.condition(panel, |panel, _| {
9363            !matches!(panel.commit_history, CommitHistory::Loading)
9364        })
9365        .await;
9366    }
9367
9368    #[test]
9369    fn test_format_git_error_toast_message_prefers_raw_rpc_message() {
9370        let rpc_error = RpcError::from_proto(
9371            &proto::Error {
9372                message:
9373                    "Your local changes to the following files would be overwritten by merge\n"
9374                        .to_string(),
9375                code: proto::ErrorCode::Internal as i32,
9376                tags: Default::default(),
9377            },
9378            "Pull",
9379        );
9380
9381        let message = format_git_error_toast_message(&rpc_error);
9382        assert_eq!(
9383            message,
9384            "Your local changes to the following files would be overwritten by merge"
9385        );
9386    }
9387
9388    #[test]
9389    fn test_format_git_error_toast_message_prefers_raw_rpc_message_when_wrapped() {
9390        let rpc_error = RpcError::from_proto(
9391            &proto::Error {
9392                message:
9393                    "Your local changes to the following files would be overwritten by merge\n"
9394                        .to_string(),
9395                code: proto::ErrorCode::Internal as i32,
9396                tags: Default::default(),
9397            },
9398            "Pull",
9399        );
9400        let wrapped = rpc_error.context("sending pull request");
9401
9402        let message = format_git_error_toast_message(&wrapped);
9403        assert_eq!(
9404            message,
9405            "Your local changes to the following files would be overwritten by merge"
9406        );
9407    }
9408
9409    #[gpui::test]
9410    async fn test_history_tab_stops_loading_for_unborn_branch(cx: &mut TestAppContext) {
9411        init_test(cx);
9412
9413        let fs = FakeFs::new(cx.background_executor.clone());
9414        fs.insert_tree("/root", json!({ "project": { ".git": {} } }))
9415            .await;
9416
9417        let dot_git = Path::new(path!("/root/project/.git"));
9418        fs.set_branch_name(dot_git, Some("main"));
9419        fs.with_git_state(dot_git, false, |state| {
9420            state.refs.remove("HEAD");
9421        })
9422        .unwrap();
9423
9424        let panel = history_panel_for_project(fs.clone(), cx).await;
9425
9426        wait_for_commit_history_to_settle(&panel, cx).await;
9427        panel.read_with(cx, |panel, _| {
9428            assert_eq!(panel.commit_history, CommitHistory::Loaded(Rc::from([])));
9429        });
9430    }
9431
9432    #[gpui::test]
9433    async fn test_history_tab_loads_detached_head(cx: &mut TestAppContext) {
9434        init_test(cx);
9435
9436        let fs = FakeFs::new(cx.background_executor.clone());
9437        fs.insert_tree("/root", json!({ "project": { ".git": {} } }))
9438            .await;
9439
9440        let dot_git = Path::new(path!("/root/project/.git"));
9441        let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap();
9442        fs.with_git_state(dot_git, false, |state| {
9443            state.current_branch_name = None;
9444            state.refs.insert("HEAD".into(), sha.to_string());
9445            state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData {
9446                sha,
9447                parents: SmallVec::new(),
9448                ref_names: Vec::new(),
9449            })];
9450        })
9451        .unwrap();
9452
9453        let panel = history_panel_for_project(fs.clone(), cx).await;
9454
9455        wait_for_commit_history_to_settle(&panel, cx).await;
9456        panel.read_with(cx, |panel, _| {
9457            assert_eq!(
9458                panel.commit_history,
9459                CommitHistory::Loaded(Rc::from([CommitHistoryEntry {
9460                    sha,
9461                    tag_names: Vec::new(),
9462                }]))
9463            );
9464        });
9465    }
9466
9467    #[gpui::test]
9468    async fn test_history_tab_surfaces_load_error(cx: &mut TestAppContext) {
9469        init_test(cx);
9470
9471        let fs = FakeFs::new(cx.background_executor.clone());
9472        fs.insert_tree("/root", json!({ "project": { ".git": {} } }))
9473            .await;
9474
9475        let dot_git = Path::new(path!("/root/project/.git"));
9476        let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap();
9477        fs.with_git_state(dot_git, false, |state| {
9478            state.current_branch_name = None;
9479            state.refs.insert("HEAD".into(), sha.to_string());
9480            state.graph_commits = vec![Arc::new(git::repository::InitialGraphCommitData {
9481                sha,
9482                parents: SmallVec::new(),
9483                ref_names: Vec::new(),
9484            })];
9485        })
9486        .unwrap();
9487        fs.set_graph_error(dot_git, Some("simulated git log failure".into()));
9488
9489        let panel = history_panel_for_project(fs.clone(), cx).await;
9490
9491        wait_for_commit_history_to_settle(&panel, cx).await;
9492        panel.read_with(cx, |panel, _| {
9493            assert!(matches!(panel.commit_history, CommitHistory::Error(_)));
9494        });
9495    }
9496
9497    #[gpui::test]
9498    async fn test_history_tab_without_repository(cx: &mut TestAppContext) {
9499        init_test(cx);
9500
9501        let fs = FakeFs::new(cx.background_executor.clone());
9502        fs.insert_tree("/root", json!({ "project": {} })).await;
9503
9504        let panel = history_panel_for_project(fs.clone(), cx).await;
9505
9506        panel.read_with(cx, |panel, _| {
9507            assert_eq!(panel.commit_history, CommitHistory::Loading);
9508        });
9509    }
9510
9511    #[test]
9512    fn test_commit_history_from_response() {
9513        let sha: Oid = "0123456789012345678901234567890123456789".parse().unwrap();
9514        let error = SharedString::from("git log failed");
9515        let entries: Rc<[CommitHistoryEntry]> = Rc::from([CommitHistoryEntry {
9516            sha,
9517            tag_names: Vec::new(),
9518        }]);
9519        let no_entries: Rc<[CommitHistoryEntry]> = Rc::from([]);
9520
9521        // Commits win even while the fetch task still reports `is_loading`.
9522        assert_eq!(
9523            commit_history_from_response(entries.clone(), true, None),
9524            CommitHistory::Loaded(entries.clone())
9525        );
9526        assert_eq!(
9527            commit_history_from_response(entries.clone(), false, None),
9528            CommitHistory::Loaded(entries.clone())
9529        );
9530        // Commits also take precedence over a concurrently reported error.
9531        assert_eq!(
9532            commit_history_from_response(entries.clone(), true, Some(error.clone())),
9533            CommitHistory::Loaded(entries)
9534        );
9535
9536        // With no commits a terminal error beats the loading state.
9537        assert_eq!(
9538            commit_history_from_response(no_entries.clone(), true, Some(error.clone())),
9539            CommitHistory::Error(error.clone())
9540        );
9541        assert_eq!(
9542            commit_history_from_response(no_entries.clone(), false, Some(error.clone())),
9543            CommitHistory::Error(error)
9544        );
9545
9546        // When no commits and no error, loading vs. finished-empty hinges on `is_loading`.
9547        assert_eq!(
9548            commit_history_from_response(no_entries.clone(), true, None),
9549            CommitHistory::Loading
9550        );
9551        assert_eq!(
9552            commit_history_from_response(no_entries.clone(), false, None),
9553            CommitHistory::Loaded(no_entries)
9554        );
9555    }
9556
9557    #[gpui::test]
9558    async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
9559        init_test(cx);
9560        let fs = FakeFs::new(cx.background_executor.clone());
9561        fs.insert_tree(
9562            "/root",
9563            json!({
9564                "zed": {
9565                    ".git": {},
9566                    "crates": {
9567                        "gpui": {
9568                            "gpui.rs": "fn main() {}"
9569                        },
9570                        "util": {
9571                            "util.rs": "fn do_it() {}"
9572                        }
9573                    }
9574                },
9575            }),
9576        )
9577        .await;
9578
9579        fs.set_status_for_repo(
9580            Path::new(path!("/root/zed/.git")),
9581            &[
9582                ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()),
9583                ("crates/util/util.rs", StatusCode::Modified.worktree()),
9584            ],
9585        );
9586        let project =
9587            Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
9588        let window_handle =
9589            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9590        let workspace = window_handle
9591            .read_with(cx, |mw, _| mw.workspace().clone())
9592            .unwrap();
9593        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
9594
9595        cx.read(|cx| {
9596            project
9597                .read(cx)
9598                .worktrees(cx)
9599                .next()
9600                .unwrap()
9601                .read(cx)
9602                .as_local()
9603                .unwrap()
9604                .scan_complete()
9605        })
9606        .await;
9607
9608        cx.executor().run_until_parked();
9609
9610        let panel = workspace.update_in(cx, GitPanel::new);
9611
9612        let handle = cx.update_window_entity(&panel, |panel, _, _| {
9613            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
9614        });
9615        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
9616        handle.await;
9617
9618        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
9619        pretty_assertions::assert_eq!(
9620            entries,
9621            [
9622                GitListEntry::Header(GitHeaderEntry {
9623                    header: Section::Tracked
9624                }),
9625                GitListEntry::Status(GitStatusEntry {
9626                    repo_path: repo_path("crates/gpui/gpui.rs"),
9627                    status: StatusCode::Modified.worktree(),
9628                    staging: StageStatus::Unstaged,
9629                    diff_stat: Some(DiffStat {
9630                        added: 1,
9631                        deleted: 1,
9632                    }),
9633                }),
9634                GitListEntry::Status(GitStatusEntry {
9635                    repo_path: repo_path("crates/util/util.rs"),
9636                    status: StatusCode::Modified.worktree(),
9637                    staging: StageStatus::Unstaged,
9638                    diff_stat: Some(DiffStat {
9639                        added: 1,
9640                        deleted: 1,
9641                    }),
9642                },),
9643            ],
9644        );
9645
9646        let handle = cx.update_window_entity(&panel, |panel, _, _| {
9647            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
9648        });
9649        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
9650        handle.await;
9651        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
9652        pretty_assertions::assert_eq!(
9653            entries,
9654            [
9655                GitListEntry::Header(GitHeaderEntry {
9656                    header: Section::Tracked
9657                }),
9658                GitListEntry::Status(GitStatusEntry {
9659                    repo_path: repo_path("crates/gpui/gpui.rs"),
9660                    status: StatusCode::Modified.worktree(),
9661                    staging: StageStatus::Unstaged,
9662                    diff_stat: Some(DiffStat {
9663                        added: 1,
9664                        deleted: 1,
9665                    }),
9666                }),
9667                GitListEntry::Status(GitStatusEntry {
9668                    repo_path: repo_path("crates/util/util.rs"),
9669                    status: StatusCode::Modified.worktree(),
9670                    staging: StageStatus::Unstaged,
9671                    diff_stat: Some(DiffStat {
9672                        added: 1,
9673                        deleted: 1,
9674                    }),
9675                },),
9676            ],
9677        );
9678    }
9679
9680    #[gpui::test]
9681    async fn test_discard_prompt_escapes_markdown_in_file_name(cx: &mut TestAppContext) {
9682        init_test(cx);
9683        let fs = FakeFs::new(cx.background_executor.clone());
9684        fs.insert_tree(
9685            "/root",
9686            json!({
9687                "project": {
9688                    ".git": {},
9689                    "__somefile__": "modified\n",
9690                },
9691            }),
9692        )
9693        .await;
9694
9695        fs.set_status_for_repo(
9696            Path::new(path!("/root/project/.git")),
9697            &[("__somefile__", StatusCode::Modified.worktree())],
9698        );
9699
9700        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
9701        let window_handle =
9702            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9703        let workspace = window_handle
9704            .read_with(cx, |mw, _| mw.workspace().clone())
9705            .unwrap();
9706        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
9707
9708        cx.read(|cx| {
9709            project
9710                .read(cx)
9711                .worktrees(cx)
9712                .next()
9713                .unwrap()
9714                .read(cx)
9715                .as_local()
9716                .unwrap()
9717                .scan_complete()
9718        })
9719        .await;
9720
9721        cx.executor().run_until_parked();
9722
9723        let panel = workspace.update_in(cx, GitPanel::new);
9724
9725        let handle = cx.update_window_entity(&panel, |panel, _, _| {
9726            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
9727        });
9728        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
9729        handle.await;
9730
9731        panel.update_in(cx, |panel, window, cx| {
9732            panel.selected_entry = Some(1);
9733            panel.revert_selected(&git::RestoreFile::default(), window, cx);
9734        });
9735
9736        let (message, _detail) = cx
9737            .pending_prompt()
9738            .expect("discard should show a confirmation prompt");
9739
9740        assert_eq!(
9741            message,
9742            "Are you sure you want to discard changes to `__somefile__`?"
9743        );
9744    }
9745
9746    #[gpui::test]
9747    async fn test_group_by_staging_section_membership_and_order(cx: &mut TestAppContext) {
9748        use GitListEntry::*;
9749
9750        init_test(cx);
9751        let fs = FakeFs::new(cx.background_executor.clone());
9752        fs.insert_tree(
9753            path!("/project"),
9754            json!({
9755                ".git": {},
9756                "conflict.rs": "conflicted content",
9757                "new.rs": "new content",
9758                "partial.rs": "partial content",
9759                "partial_new.rs": "partial new content",
9760                "staged.rs": "staged content",
9761                "unstaged.rs": "unstaged content",
9762            }),
9763        )
9764        .await;
9765
9766        fs.set_status_for_repo(
9767            path!("/project/.git").as_ref(),
9768            &[
9769                (
9770                    "conflict.rs",
9771                    UnmergedStatus {
9772                        first_head: UnmergedStatusCode::Updated,
9773                        second_head: UnmergedStatusCode::Updated,
9774                    }
9775                    .into(),
9776                ),
9777                ("new.rs", FileStatus::Untracked),
9778                (
9779                    "partial.rs",
9780                    TrackedStatus {
9781                        index_status: StatusCode::Modified,
9782                        worktree_status: StatusCode::Modified,
9783                    }
9784                    .into(),
9785                ),
9786                (
9787                    "partial_new.rs",
9788                    TrackedStatus {
9789                        index_status: StatusCode::Added,
9790                        worktree_status: StatusCode::Modified,
9791                    }
9792                    .into(),
9793                ),
9794                ("staged.rs", FileStatus::index(StatusCode::Modified)),
9795                ("unstaged.rs", StatusCode::Modified.worktree()),
9796            ],
9797        );
9798        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
9799            state.head_contents.insert(
9800                repo_path("partial.rs"),
9801                "head one\nhead two\nhead three\nhead four".into(),
9802            );
9803            state
9804                .index_contents
9805                .insert(repo_path("partial.rs"), "index one\nindex two".into());
9806        })
9807        .expect("fake repository should exist");
9808
9809        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
9810        let window_handle =
9811            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
9812        let workspace = window_handle
9813            .read_with(cx, |mw, _| mw.workspace().clone())
9814            .unwrap();
9815        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
9816
9817        cx.update(|_window, cx| {
9818            SettingsStore::update_global(cx, |store, cx| {
9819                store.update_user_settings(cx, |settings| {
9820                    settings.git_panel.get_or_insert_default().group_by =
9821                        Some(GitPanelGroupBy::Staging);
9822                })
9823            });
9824        });
9825
9826        cx.read(|cx| {
9827            project
9828                .read(cx)
9829                .worktrees(cx)
9830                .next()
9831                .unwrap()
9832                .read(cx)
9833                .as_local()
9834                .unwrap()
9835                .scan_complete()
9836        })
9837        .await;
9838
9839        cx.executor().run_until_parked();
9840
9841        let panel = workspace.update_in(&mut cx, GitPanel::new);
9842        await_git_panel_entries(&panel, &mut cx).await;
9843
9844        let entries = panel.read_with(&mut cx, |panel, _| {
9845            assert_eq!(panel.entry_count, 6);
9846            assert_eq!(
9847                panel
9848                    .change_entries_by_path()
9849                    .filter(|entry| entry.status.is_created())
9850                    .map(|entry| &*entry.repo_path)
9851                    .sorted()
9852                    .collect::<Vec<_>>(),
9853                [rel_path("new.rs"), rel_path("partial_new.rs")]
9854            );
9855
9856            let partial_path = repo_path("partial.rs");
9857            let projections = panel
9858                .projected_entries_by_path
9859                .get(&partial_path)
9860                .expect("partially staged entry should have projections");
9861            assert_eq!(
9862                projections.as_slice(),
9863                &[
9864                    ProjectedChangeEntry {
9865                        section: Section::Staged,
9866                        index: 3,
9867                    },
9868                    ProjectedChangeEntry {
9869                        section: Section::Unstaged,
9870                        index: 8,
9871                    },
9872                ]
9873            );
9874            assert_eq!(
9875                panel.stage_intent_for_entry_index(projections[0].index),
9876                StageIntent::Unstage
9877            );
9878            assert_eq!(
9879                panel.stage_intent_for_entry_index(projections[1].index),
9880                StageIntent::Stage
9881            );
9882            assert_eq!(
9883                panel.entries[projections[0].index]
9884                    .status_entry()
9885                    .and_then(|entry| entry.diff_stat),
9886                Some(DiffStat {
9887                    added: 2,
9888                    deleted: 4,
9889                })
9890            );
9891            assert_eq!(
9892                panel.entries[projections[1].index]
9893                    .status_entry()
9894                    .and_then(|entry| entry.diff_stat),
9895                Some(DiffStat {
9896                    added: 1,
9897                    deleted: 2,
9898                })
9899            );
9900            panel.entries.clone()
9901        });
9902
9903        #[rustfmt::skip]
9904        pretty_assertions::assert_matches!(
9905            entries.as_slice(),
9906            &[
9907                Header(GitHeaderEntry { header: Section::Conflict }),
9908                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
9909                Header(GitHeaderEntry { header: Section::Staged }),
9910                Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }),
9911                Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }),
9912                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
9913                Header(GitHeaderEntry { header: Section::Unstaged }),
9914                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
9915                Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }),
9916                Status(GitStatusEntry { staging: StageStatus::PartiallyStaged, .. }),
9917                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
9918            ],
9919        );
9920        assert_entry_paths(
9921            &entries,
9922            &[
9923                None,
9924                Some("conflict.rs"),
9925                None,
9926                Some("partial.rs"),
9927                Some("partial_new.rs"),
9928                Some("staged.rs"),
9929                None,
9930                Some("new.rs"),
9931                Some("partial.rs"),
9932                Some("partial_new.rs"),
9933                Some("unstaged.rs"),
9934            ],
9935        );
9936
9937        let worktree_id =
9938            cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
9939        panel.update_in(&mut cx, |panel, window, cx| {
9940            panel.select_entry_by_path(
9941                ProjectPath {
9942                    worktree_id,
9943                    path: rel_path("partial.rs").into_arc(),
9944                },
9945                window,
9946                cx,
9947            );
9948        });
9949        panel.read_with(&cx, |panel, _| {
9950            assert_eq!(
9951                panel.selected_entry,
9952                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged)
9953            );
9954        });
9955
9956        panel.update_in(&mut cx, |panel, window, cx| {
9957            panel.selected_entry =
9958                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged);
9959            panel.select_entry_by_path(
9960                ProjectPath {
9961                    worktree_id,
9962                    path: rel_path("partial.rs").into_arc(),
9963                },
9964                window,
9965                cx,
9966            );
9967        });
9968        panel.read_with(&cx, |panel, _| {
9969            assert_eq!(
9970                panel.selected_entry,
9971                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged)
9972            );
9973        });
9974
9975        panel.update_in(&mut cx, |panel, _window, _cx| {
9976            panel.selected_entry =
9977                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged);
9978        });
9979
9980        fs.set_status_for_repo(
9981            path!("/project/.git").as_ref(),
9982            &[
9983                (
9984                    "conflict.rs",
9985                    UnmergedStatus {
9986                        first_head: UnmergedStatusCode::Updated,
9987                        second_head: UnmergedStatusCode::Updated,
9988                    }
9989                    .into(),
9990                ),
9991                ("new.rs", FileStatus::Untracked),
9992                ("partial.rs", StatusCode::Modified.worktree()),
9993                (
9994                    "partial_new.rs",
9995                    TrackedStatus {
9996                        index_status: StatusCode::Added,
9997                        worktree_status: StatusCode::Modified,
9998                    }
9999                    .into(),
10000                ),
10001                ("staged.rs", FileStatus::index(StatusCode::Modified)),
10002                ("unstaged.rs", StatusCode::Modified.worktree()),
10003            ],
10004        );
10005        cx.run_until_parked();
10006        await_git_panel_entries(&panel, &mut cx).await;
10007
10008        panel.read_with(&cx, |panel, _| {
10009            let selected_entry = panel
10010                .get_selected_entry()
10011                .and_then(GitListEntry::status_entry)
10012                .expect("selected change should remain selected");
10013            assert_eq!(selected_entry.repo_path, repo_path("partial.rs"));
10014            assert_eq!(
10015                panel.selected_entry,
10016                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged)
10017            );
10018        });
10019    }
10020
10021    #[gpui::test]
10022    async fn test_staging_conflict_mark_resolved_transition(cx: &mut TestAppContext) {
10023        use GitListEntry::*;
10024
10025        init_test(cx);
10026        let fs = FakeFs::new(cx.background_executor.clone());
10027        fs.insert_tree(
10028            path!("/project"),
10029            json!({
10030                ".git": {},
10031                "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n",
10032            }),
10033        )
10034        .await;
10035
10036        let unresolved_status = FileStatus::Unmerged(UnmergedStatus {
10037            first_head: UnmergedStatusCode::Updated,
10038            second_head: UnmergedStatusCode::Updated,
10039        });
10040        fs.set_status_for_repo(
10041            path!("/project/.git").as_ref(),
10042            &[("conflict.rs", unresolved_status)],
10043        );
10044
10045        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
10046        let window_handle =
10047            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10048        let workspace = window_handle
10049            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
10050            .unwrap();
10051        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
10052
10053        cx.update(|_window, cx| {
10054            SettingsStore::update_global(cx, |store, cx| {
10055                store.update_user_settings(cx, |settings| {
10056                    settings.git_panel.get_or_insert_default().group_by =
10057                        Some(GitPanelGroupBy::Staging);
10058                })
10059            });
10060        });
10061
10062        cx.read(|cx| {
10063            project
10064                .read(cx)
10065                .worktrees(cx)
10066                .next()
10067                .unwrap()
10068                .read(cx)
10069                .as_local()
10070                .unwrap()
10071                .scan_complete()
10072        })
10073        .await;
10074        cx.executor().run_until_parked();
10075
10076        let panel = workspace.update_in(&mut cx, GitPanel::new);
10077        await_git_panel_entries(&panel, &mut cx).await;
10078
10079        let conflict_entry = panel.read_with(&cx, |panel, _| {
10080            pretty_assertions::assert_matches!(
10081                panel.entries.as_slice(),
10082                &[
10083                    Header(GitHeaderEntry {
10084                        header: Section::Conflict
10085                    }),
10086                    Status(GitStatusEntry {
10087                        status: FileStatus::Unmerged(..),
10088                        ..
10089                    }),
10090                    Header(GitHeaderEntry {
10091                        header: Section::Staged
10092                    }),
10093                    EmptySection(Section::Staged),
10094                    Header(GitHeaderEntry {
10095                        header: Section::Unstaged
10096                    }),
10097                    EmptySection(Section::Unstaged),
10098                ],
10099            );
10100            panel
10101                .entries
10102                .get(1)
10103                .and_then(GitListEntry::status_entry)
10104                .cloned()
10105                .expect("conflict entry should exist")
10106        });
10107
10108        panel.update_in(&mut cx, |panel, _window, cx| {
10109            panel.change_file_stage(true, vec![conflict_entry.clone()], cx);
10110        });
10111        cx.run_until_parked();
10112
10113        panel.read_with(&cx, |panel, _| {
10114            assert!(matches!(
10115                panel.entries.as_slice(),
10116                [
10117                    Header(GitHeaderEntry {
10118                        header: Section::Conflict
10119                    }),
10120                    Status(GitStatusEntry {
10121                        status: FileStatus::Unmerged(..),
10122                        ..
10123                    }),
10124                    Header(GitHeaderEntry {
10125                        header: Section::Staged
10126                    }),
10127                    EmptySection(Section::Staged),
10128                    Header(GitHeaderEntry {
10129                        header: Section::Unstaged
10130                    }),
10131                    EmptySection(Section::Unstaged),
10132                ]
10133            ));
10134        });
10135
10136        fs.set_status_for_repo(
10137            path!("/project/.git").as_ref(),
10138            &[("conflict.rs", FileStatus::index(StatusCode::Modified))],
10139        );
10140        cx.run_until_parked();
10141        await_git_panel_entries(&panel, &mut cx).await;
10142
10143        panel.read_with(&cx, |panel, _| {
10144            pretty_assertions::assert_matches!(
10145                panel.entries.as_slice(),
10146                &[
10147                    Header(GitHeaderEntry {
10148                        header: Section::Staged
10149                    }),
10150                    Status(GitStatusEntry {
10151                        staging: StageStatus::Staged,
10152                        ..
10153                    }),
10154                    Header(GitHeaderEntry {
10155                        header: Section::Unstaged
10156                    }),
10157                    EmptySection(Section::Unstaged),
10158                ],
10159            );
10160            assert_eq!(panel.entry_count, 1);
10161        });
10162    }
10163
10164    #[gpui::test]
10165    async fn test_resolved_conflict_is_locked_against_unstaging(cx: &mut TestAppContext) {
10166        use GitListEntry::*;
10167
10168        init_test(cx);
10169        let fs = FakeFs::new(cx.background_executor.clone());
10170        fs.insert_tree(
10171            path!("/project"),
10172            json!({
10173                ".git": {},
10174                "conflict.rs": "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n",
10175                "staged.rs": "staged content",
10176                "unstaged.rs": "unstaged content",
10177            }),
10178        )
10179        .await;
10180
10181        let unresolved_status = FileStatus::Unmerged(UnmergedStatus {
10182            first_head: UnmergedStatusCode::Updated,
10183            second_head: UnmergedStatusCode::Updated,
10184        });
10185        fs.set_status_for_repo(
10186            path!("/project/.git").as_ref(),
10187            &[
10188                ("conflict.rs", unresolved_status),
10189                ("staged.rs", FileStatus::index(StatusCode::Modified)),
10190                ("unstaged.rs", StatusCode::Modified.worktree()),
10191            ],
10192        );
10193        // With MERGE_HEAD present (an in-progress merge), a resolved conflict
10194        // keeps rendering under the Conflict section instead of moving to Staged.
10195        fs.with_git_state(path!("/project/.git").as_ref(), true, |state| {
10196            state.refs.insert("MERGE_HEAD".into(), "merge-sha".into());
10197        })
10198        .unwrap();
10199
10200        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
10201        let window_handle =
10202            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10203        let workspace = window_handle
10204            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
10205            .unwrap();
10206        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
10207
10208        cx.update(|_window, cx| {
10209            SettingsStore::update_global(cx, |store, cx| {
10210                store.update_user_settings(cx, |settings| {
10211                    settings.git_panel.get_or_insert_default().group_by =
10212                        Some(GitPanelGroupBy::Staging);
10213                })
10214            });
10215        });
10216
10217        cx.read(|cx| {
10218            project
10219                .read(cx)
10220                .worktrees(cx)
10221                .next()
10222                .unwrap()
10223                .read(cx)
10224                .as_local()
10225                .unwrap()
10226                .scan_complete()
10227        })
10228        .await;
10229        cx.executor().run_until_parked();
10230
10231        let panel = workspace.update_in(&mut cx, GitPanel::new);
10232        await_git_panel_entries(&panel, &mut cx).await;
10233
10234        fn stage_status_of(
10235            panel: &Entity<GitPanel>,
10236            cx: &VisualTestContext,
10237            path: &str,
10238        ) -> StageStatus {
10239            panel.read_with(cx, |panel, cx| {
10240                let repo = panel
10241                    .active_repository
10242                    .as_ref()
10243                    .expect("active repository should exist")
10244                    .read(cx);
10245                let entry = panel
10246                    .change_entries_by_path()
10247                    .find(|entry| entry.repo_path == repo_path(path))
10248                    .expect("entry should exist")
10249                    .clone();
10250                GitPanel::stage_status_for_entry(&entry, repo)
10251            })
10252        }
10253
10254        let conflict_entry = panel.read_with(&cx, |panel, _| {
10255            pretty_assertions::assert_matches!(
10256                panel.entries.as_slice(),
10257                &[
10258                    Header(GitHeaderEntry {
10259                        header: Section::Conflict
10260                    }),
10261                    Status(GitStatusEntry {
10262                        status: FileStatus::Unmerged(..),
10263                        ..
10264                    }),
10265                    Header(GitHeaderEntry {
10266                        header: Section::Staged
10267                    }),
10268                    Status(GitStatusEntry {
10269                        staging: StageStatus::Staged,
10270                        ..
10271                    }),
10272                    Header(GitHeaderEntry {
10273                        header: Section::Unstaged
10274                    }),
10275                    Status(GitStatusEntry {
10276                        staging: StageStatus::Unstaged,
10277                        ..
10278                    }),
10279                ],
10280            );
10281            panel
10282                .entries
10283                .get(1)
10284                .and_then(GitListEntry::status_entry)
10285                .cloned()
10286                .expect("conflict entry should exist")
10287        });
10288
10289        // Resolve the conflict: simulate what `git add` does to the status.
10290        panel.update_in(&mut cx, |panel, window, cx| {
10291            panel.toggle_staged_for_entry(
10292                &GitListEntry::Status(conflict_entry.clone()),
10293                StageIntent::Toggle,
10294                window,
10295                cx,
10296            );
10297        });
10298        fs.set_status_for_repo(
10299            path!("/project/.git").as_ref(),
10300            &[
10301                ("conflict.rs", FileStatus::index(StatusCode::Modified)),
10302                ("staged.rs", FileStatus::index(StatusCode::Modified)),
10303                ("unstaged.rs", StatusCode::Modified.worktree()),
10304            ],
10305        );
10306        cx.run_until_parked();
10307        await_git_panel_entries(&panel, &mut cx).await;
10308
10309        // The resolved conflict stays in the Conflict section, staged.
10310        panel.read_with(&cx, |panel, cx| {
10311            assert_eq!(
10312                panel.section_for_entry_index(
10313                    panel
10314                        .entry_by_path(&repo_path("conflict.rs"))
10315                        .expect("conflict entry should exist")
10316                ),
10317                Some(Section::Conflict)
10318            );
10319            assert!(
10320                panel.is_resolved_conflict(
10321                    panel.entry_by_path(&repo_path("conflict.rs")).unwrap(),
10322                    cx
10323                )
10324            );
10325        });
10326        assert_eq!(
10327            stage_status_of(&panel, &cx, "conflict.rs"),
10328            StageStatus::Staged
10329        );
10330
10331        // The keyboard toggle must not unstage a resolved conflict.
10332        panel.update_in(&mut cx, |panel, window, cx| {
10333            panel.selected_entry = panel.entry_by_path(&repo_path("conflict.rs"));
10334            panel.toggle_staged_for_selected(&ToggleStaged, window, cx);
10335        });
10336        cx.run_until_parked();
10337        assert_eq!(
10338            stage_status_of(&panel, &cx, "conflict.rs"),
10339            StageStatus::Staged
10340        );
10341
10342        // "Unstage All" on the Staged header must skip resolved conflicts while
10343        // still unstaging regular staged files.
10344        panel.update_in(&mut cx, |panel, window, cx| {
10345            panel.toggle_staged_for_entry(
10346                &GitListEntry::Header(GitHeaderEntry {
10347                    header: Section::Staged,
10348                }),
10349                StageIntent::Unstage,
10350                window,
10351                cx,
10352            );
10353        });
10354        cx.run_until_parked();
10355        assert_eq!(
10356            stage_status_of(&panel, &cx, "staged.rs"),
10357            StageStatus::Unstaged
10358        );
10359        assert_eq!(
10360            stage_status_of(&panel, &cx, "conflict.rs"),
10361            StageStatus::Staged
10362        );
10363
10364        // A shift-click range sweep anchored at the conflict must skip it too.
10365        let staged_entry = panel.read_with(&cx, |panel, _| {
10366            panel
10367                .change_entries_by_path()
10368                .find(|entry| entry.repo_path == repo_path("staged.rs"))
10369                .cloned()
10370                .expect("staged entry should exist")
10371        });
10372        panel.update_in(&mut cx, |panel, _window, cx| {
10373            panel.change_file_stage(true, vec![staged_entry], cx);
10374        });
10375        cx.run_until_parked();
10376        await_git_panel_entries(&panel, &mut cx).await;
10377
10378        panel.update_in(&mut cx, |panel, _window, cx| {
10379            panel.set_bulk_staging_anchor(repo_path("conflict.rs"), cx);
10380            let last_index = panel.entries.len() - 1;
10381            panel.stage_bulk(last_index, false, cx);
10382        });
10383        cx.run_until_parked();
10384        assert_eq!(
10385            stage_status_of(&panel, &cx, "staged.rs"),
10386            StageStatus::Unstaged
10387        );
10388        assert_eq!(
10389            stage_status_of(&panel, &cx, "conflict.rs"),
10390            StageStatus::Staged
10391        );
10392    }
10393
10394    #[gpui::test]
10395    async fn test_group_by_staging_primary_action_stages_partially_staged_files(
10396        cx: &mut TestAppContext,
10397    ) {
10398        init_test(cx);
10399        let fs = FakeFs::new(cx.background_executor.clone());
10400        fs.insert_tree(
10401            path!("/project"),
10402            json!({
10403                ".git": {},
10404                "partial.rs": "partial content",
10405                "staged.rs": "staged content",
10406            }),
10407        )
10408        .await;
10409
10410        fs.set_status_for_repo(
10411            path!("/project/.git").as_ref(),
10412            &[
10413                (
10414                    "partial.rs",
10415                    TrackedStatus {
10416                        index_status: StatusCode::Modified,
10417                        worktree_status: StatusCode::Modified,
10418                    }
10419                    .into(),
10420                ),
10421                ("staged.rs", FileStatus::index(StatusCode::Modified)),
10422            ],
10423        );
10424
10425        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
10426        let window_handle =
10427            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10428        let workspace = window_handle
10429            .read_with(cx, |mw, _| mw.workspace().clone())
10430            .unwrap();
10431        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
10432
10433        cx.update(|_window, cx| {
10434            SettingsStore::update_global(cx, |store, cx| {
10435                store.update_user_settings(cx, |settings| {
10436                    settings.git_panel.get_or_insert_default().group_by =
10437                        Some(GitPanelGroupBy::Staging);
10438                })
10439            });
10440        });
10441
10442        cx.read(|cx| {
10443            project
10444                .read(cx)
10445                .worktrees(cx)
10446                .next()
10447                .unwrap()
10448                .read(cx)
10449                .as_local()
10450                .unwrap()
10451                .scan_complete()
10452        })
10453        .await;
10454
10455        cx.executor().run_until_parked();
10456
10457        let panel = workspace.update_in(&mut cx, GitPanel::new);
10458        await_git_panel_entries(&panel, &mut cx).await;
10459
10460        panel.read_with(&mut cx, |panel, _| {
10461            assert_eq!(panel.entry_count, 2);
10462            assert_eq!(panel.total_staged_count(), panel.entry_count);
10463            assert!(panel.has_unstaged_changes());
10464            assert!(panel.primary_changes_action_stages());
10465        });
10466    }
10467
10468    #[gpui::test]
10469    async fn test_group_by_staging_open_diff_uses_section_diff(cx: &mut TestAppContext) {
10470        init_test(cx);
10471        cx.update(search::buffer_search::init);
10472        let fs = FakeFs::new(cx.background_executor.clone());
10473        fs.insert_tree(
10474            path!("/project"),
10475            json!({
10476                ".git": {},
10477                "partial.rs": "partial content",
10478            }),
10479        )
10480        .await;
10481
10482        fs.set_status_for_repo(
10483            path!("/project/.git").as_ref(),
10484            &[(
10485                "partial.rs",
10486                TrackedStatus {
10487                    index_status: StatusCode::Modified,
10488                    worktree_status: StatusCode::Modified,
10489                }
10490                .into(),
10491            )],
10492        );
10493
10494        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
10495        let window_handle =
10496            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10497        let workspace = window_handle
10498            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
10499            .unwrap();
10500        let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
10501
10502        cx.update(|_window, cx| {
10503            SettingsStore::update_global(cx, |store, cx| {
10504                store.update_user_settings(cx, |settings| {
10505                    settings.git_panel.get_or_insert_default().group_by =
10506                        Some(GitPanelGroupBy::Staging);
10507                })
10508            });
10509        });
10510
10511        cx.read(|cx| {
10512            project
10513                .read(cx)
10514                .worktrees(cx)
10515                .next()
10516                .unwrap()
10517                .read(cx)
10518                .as_local()
10519                .unwrap()
10520                .scan_complete()
10521        })
10522        .await;
10523        cx.executor().run_until_parked();
10524
10525        let panel = workspace.update_in(&mut cx, GitPanel::new);
10526        await_git_panel_entries(&panel, &mut cx).await;
10527
10528        panel.update_in(&mut cx, |panel, window, cx| {
10529            panel.selected_entry =
10530                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Staged);
10531            panel.open_diff(&menu::Confirm, window, cx);
10532        });
10533        cx.run_until_parked();
10534
10535        workspace.read_with(&cx, |workspace, cx| {
10536            assert!(workspace.active_item_as::<StagedDiff>(cx).is_some());
10537            assert_eq!(workspace.items_of_type::<StagedDiff>(cx).count(), 1);
10538            assert_eq!(workspace.items_of_type::<UnstagedDiff>(cx).count(), 0);
10539            assert_eq!(workspace.items_of_type::<ProjectDiff>(cx).count(), 0);
10540        });
10541
10542        panel.update_in(&mut cx, |panel, window, cx| {
10543            panel.open_solo_diff(&menu::SecondaryConfirm, window, cx);
10544        });
10545        cx.run_until_parked();
10546
10547        let search_bar = workspace.update_in(&mut cx, |workspace, window, cx| {
10548            let search_bar = cx.new(|cx| BufferSearchBar::new(None, window, cx));
10549            workspace.active_pane().update(cx, |pane, cx| {
10550                pane.toolbar().update(cx, |toolbar, cx| {
10551                    toolbar.add_item(search_bar.clone(), window, cx)
10552                });
10553            });
10554            search_bar
10555        });
10556
10557        let split_editor = workspace.read_with(&cx, |workspace, cx| {
10558            let solo_diff = workspace
10559                .active_item_as::<SoloDiffView>(cx)
10560                .expect("SoloDiffView should be active");
10561            let searchable = solo_diff
10562                .read(cx)
10563                .as_searchable(&solo_diff, cx)
10564                .expect("SoloDiffView should expose its editor to buffer search");
10565            let split_editor = searchable
10566                .act_as_type(TypeId::of::<SplittableEditor>(), cx)
10567                .and_then(|entity| entity.downcast::<SplittableEditor>().ok())
10568                .expect("the split editor should be the searchable item");
10569            assert_eq!(workspace.items_of_type::<StagedDiff>(cx).count(), 1);
10570            assert_eq!(workspace.items_of_type::<SoloDiffView>(cx).count(), 1);
10571            split_editor
10572        });
10573
10574        let mut search_bar_events = cx.events::<ToolbarItemEvent, BufferSearchBar>(&search_bar);
10575        cx.dispatch_action(Deploy::find());
10576        cx.run_until_parked();
10577        cx.read(|cx| assert!(!search_bar.read(cx).is_dismissed()));
10578        assert_eq!(
10579            search_bar_events
10580                .try_recv()
10581                .expect("search bar location event"),
10582            ToolbarItemEvent::ChangeLocation(ToolbarItemLocation::Secondary)
10583        );
10584
10585        search_bar
10586            .update_in(&mut cx, |search_bar, window, cx| {
10587                search_bar.search("partial", None, false, window, cx)
10588            })
10589            .await
10590            .expect("buffer search should complete");
10591
10592        let focused_editor = cx.read(|cx| split_editor.read(cx).focused_editor().clone());
10593        focused_editor.update_in(&mut cx, |editor, _window, cx| {
10594            assert_eq!(editor.search_background_highlights(cx).len(), 1);
10595        });
10596
10597        panel.update_in(&mut cx, |panel, window, cx| {
10598            panel.selected_entry =
10599                panel.entry_by_path_in_section(&repo_path("partial.rs"), Section::Unstaged);
10600            panel.open_diff(&menu::Confirm, window, cx);
10601        });
10602        cx.run_until_parked();
10603
10604        workspace.read_with(&cx, |workspace, cx| {
10605            assert!(workspace.active_item_as::<UnstagedDiff>(cx).is_some());
10606            assert_eq!(workspace.items_of_type::<StagedDiff>(cx).count(), 1);
10607            assert_eq!(workspace.items_of_type::<UnstagedDiff>(cx).count(), 1);
10608            assert_eq!(workspace.items_of_type::<ProjectDiff>(cx).count(), 0);
10609        });
10610    }
10611
10612    #[gpui::test]
10613    async fn test_bulk_staging(cx: &mut TestAppContext) {
10614        use GitListEntry::*;
10615
10616        init_test(cx);
10617        let fs = FakeFs::new(cx.background_executor.clone());
10618        fs.insert_tree(
10619            "/root",
10620            json!({
10621                "project": {
10622                    ".git": {},
10623                    "src": {
10624                        "main.rs": "fn main() {}",
10625                        "lib.rs": "pub fn hello() {}",
10626                        "utils.rs": "pub fn util() {}"
10627                    },
10628                    "tests": {
10629                        "test.rs": "fn test() {}"
10630                    },
10631                    "new_file.txt": "new content",
10632                    "another_new.rs": "// new file",
10633                    "conflict.txt": "conflicted content"
10634                }
10635            }),
10636        )
10637        .await;
10638
10639        fs.set_status_for_repo(
10640            Path::new(path!("/root/project/.git")),
10641            &[
10642                ("src/main.rs", StatusCode::Modified.worktree()),
10643                ("src/lib.rs", StatusCode::Modified.worktree()),
10644                ("tests/test.rs", StatusCode::Modified.worktree()),
10645                ("new_file.txt", FileStatus::Untracked),
10646                ("another_new.rs", FileStatus::Untracked),
10647                ("src/utils.rs", FileStatus::Untracked),
10648                (
10649                    "conflict.txt",
10650                    UnmergedStatus {
10651                        first_head: UnmergedStatusCode::Updated,
10652                        second_head: UnmergedStatusCode::Updated,
10653                    }
10654                    .into(),
10655                ),
10656            ],
10657        );
10658
10659        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
10660        let window_handle =
10661            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10662        let workspace = window_handle
10663            .read_with(cx, |mw, _| mw.workspace().clone())
10664            .unwrap();
10665        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
10666
10667        cx.read(|cx| {
10668            project
10669                .read(cx)
10670                .worktrees(cx)
10671                .next()
10672                .unwrap()
10673                .read(cx)
10674                .as_local()
10675                .unwrap()
10676                .scan_complete()
10677        })
10678        .await;
10679
10680        cx.executor().run_until_parked();
10681
10682        let panel = workspace.update_in(cx, GitPanel::new);
10683
10684        let handle = cx.update_window_entity(&panel, |panel, _, _| {
10685            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
10686        });
10687        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
10688        handle.await;
10689
10690        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
10691        #[rustfmt::skip]
10692        pretty_assertions::assert_matches!(
10693            entries.as_slice(),
10694            &[
10695                Header(GitHeaderEntry { header: Section::Conflict }),
10696                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10697                Header(GitHeaderEntry { header: Section::Tracked }),
10698                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10699                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10700                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10701                Header(GitHeaderEntry { header: Section::New }),
10702                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10703                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10704                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10705            ],
10706        );
10707
10708        let second_status_entry = entries[3].clone();
10709        panel.update_in(cx, |panel, window, cx| {
10710            panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx);
10711        });
10712
10713        panel.update_in(cx, |panel, window, cx| {
10714            panel.selected_entry = Some(7);
10715            panel.stage_range(&git::StageRange, window, cx);
10716        });
10717
10718        cx.read(|cx| {
10719            project
10720                .read(cx)
10721                .worktrees(cx)
10722                .next()
10723                .unwrap()
10724                .read(cx)
10725                .as_local()
10726                .unwrap()
10727                .scan_complete()
10728        })
10729        .await;
10730
10731        cx.executor().run_until_parked();
10732
10733        let handle = cx.update_window_entity(&panel, |panel, _, _| {
10734            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
10735        });
10736        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
10737        handle.await;
10738
10739        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
10740        #[rustfmt::skip]
10741        pretty_assertions::assert_matches!(
10742            entries.as_slice(),
10743            &[
10744                Header(GitHeaderEntry { header: Section::Conflict }),
10745                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10746                Header(GitHeaderEntry { header: Section::Tracked }),
10747                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10748                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10749                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10750                Header(GitHeaderEntry { header: Section::New }),
10751                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10752                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10753                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10754            ],
10755        );
10756
10757        let third_status_entry = entries[4].clone();
10758        panel.update_in(cx, |panel, window, cx| {
10759            panel.toggle_staged_for_entry(&third_status_entry, StageIntent::Toggle, window, cx);
10760        });
10761
10762        panel.update_in(cx, |panel, window, cx| {
10763            panel.selected_entry = Some(9);
10764            panel.stage_range(&git::StageRange, window, cx);
10765        });
10766
10767        cx.read(|cx| {
10768            project
10769                .read(cx)
10770                .worktrees(cx)
10771                .next()
10772                .unwrap()
10773                .read(cx)
10774                .as_local()
10775                .unwrap()
10776                .scan_complete()
10777        })
10778        .await;
10779
10780        cx.executor().run_until_parked();
10781
10782        let handle = cx.update_window_entity(&panel, |panel, _, _| {
10783            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
10784        });
10785        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
10786        handle.await;
10787
10788        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
10789        #[rustfmt::skip]
10790        pretty_assertions::assert_matches!(
10791            entries.as_slice(),
10792            &[
10793                Header(GitHeaderEntry { header: Section::Conflict }),
10794                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10795                Header(GitHeaderEntry { header: Section::Tracked }),
10796                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10797                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10798                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10799                Header(GitHeaderEntry { header: Section::New }),
10800                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10801                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10802                Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
10803            ],
10804        );
10805    }
10806
10807    #[gpui::test]
10808    async fn test_bulk_staging_with_sort_by_paths(cx: &mut TestAppContext) {
10809        use GitListEntry::*;
10810
10811        init_test(cx);
10812        let fs = FakeFs::new(cx.background_executor.clone());
10813        fs.insert_tree(
10814            "/root",
10815            json!({
10816                "project": {
10817                    ".git": {},
10818                    "src": {
10819                        "main.rs": "fn main() {}",
10820                        "lib.rs": "pub fn hello() {}",
10821                        "utils.rs": "pub fn util() {}"
10822                    },
10823                    "tests": {
10824                        "test.rs": "fn test() {}"
10825                    },
10826                    "new_file.txt": "new content",
10827                    "another_new.rs": "// new file",
10828                    "conflict.txt": "conflicted content"
10829                }
10830            }),
10831        )
10832        .await;
10833
10834        fs.set_status_for_repo(
10835            Path::new(path!("/root/project/.git")),
10836            &[
10837                ("src/main.rs", StatusCode::Modified.worktree()),
10838                ("src/lib.rs", StatusCode::Modified.worktree()),
10839                ("tests/test.rs", StatusCode::Modified.worktree()),
10840                ("new_file.txt", FileStatus::Untracked),
10841                ("another_new.rs", FileStatus::Untracked),
10842                ("src/utils.rs", FileStatus::Untracked),
10843                (
10844                    "conflict.txt",
10845                    UnmergedStatus {
10846                        first_head: UnmergedStatusCode::Updated,
10847                        second_head: UnmergedStatusCode::Updated,
10848                    }
10849                    .into(),
10850                ),
10851            ],
10852        );
10853
10854        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
10855        let window_handle =
10856            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
10857        let workspace = window_handle
10858            .read_with(cx, |mw, _| mw.workspace().clone())
10859            .unwrap();
10860        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
10861
10862        cx.read(|cx| {
10863            project
10864                .read(cx)
10865                .worktrees(cx)
10866                .next()
10867                .unwrap()
10868                .read(cx)
10869                .as_local()
10870                .unwrap()
10871                .scan_complete()
10872        })
10873        .await;
10874
10875        cx.executor().run_until_parked();
10876
10877        let panel = workspace.update_in(cx, GitPanel::new);
10878
10879        let handle = cx.update_window_entity(&panel, |panel, _, _| {
10880            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
10881        });
10882        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
10883        handle.await;
10884
10885        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
10886        #[rustfmt::skip]
10887        pretty_assertions::assert_matches!(
10888            entries.as_slice(),
10889            &[
10890                Header(GitHeaderEntry { header: Section::Conflict }),
10891                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10892                Header(GitHeaderEntry { header: Section::Tracked }),
10893                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10894                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10895                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10896                Header(GitHeaderEntry { header: Section::New }),
10897                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10898                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10899                Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
10900            ],
10901        );
10902
10903        assert_entry_paths(
10904            &entries,
10905            &[
10906                None,
10907                Some("conflict.txt"),
10908                None,
10909                Some("src/lib.rs"),
10910                Some("src/main.rs"),
10911                Some("tests/test.rs"),
10912                None,
10913                Some("another_new.rs"),
10914                Some("new_file.txt"),
10915                Some("src/utils.rs"),
10916            ],
10917        );
10918
10919        let second_status_entry = entries[3].clone();
10920        panel.update_in(cx, |panel, window, cx| {
10921            panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx);
10922        });
10923
10924        cx.update(|_window, cx| {
10925            SettingsStore::update_global(cx, |store, cx| {
10926                store.update_user_settings(cx, |settings| {
10927                    settings.git_panel.get_or_insert_default().group_by =
10928                        Some(GitPanelGroupBy::None);
10929                })
10930            });
10931        });
10932
10933        panel.update_in(cx, |panel, window, cx| {
10934            panel.selected_entry = Some(7);
10935            panel.stage_range(&git::StageRange, window, cx);
10936        });
10937
10938        cx.read(|cx| {
10939            project
10940                .read(cx)
10941                .worktrees(cx)
10942                .next()
10943                .unwrap()
10944                .read(cx)
10945                .as_local()
10946                .unwrap()
10947                .scan_complete()
10948        })
10949        .await;
10950
10951        cx.executor().run_until_parked();
10952
10953        let handle = cx.update_window_entity(&panel, |panel, _, _| {
10954            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
10955        });
10956        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
10957        handle.await;
10958
10959        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
10960        #[rustfmt::skip]
10961        pretty_assertions::assert_matches!(
10962            entries.as_slice(),
10963            &[
10964                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
10965                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
10966                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
10967                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
10968                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
10969                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
10970                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
10971            ],
10972        );
10973
10974        assert_entry_paths(
10975            &entries,
10976            &[
10977                Some("another_new.rs"),
10978                Some("conflict.txt"),
10979                Some("new_file.txt"),
10980                Some("src/lib.rs"),
10981                Some("src/main.rs"),
10982                Some("src/utils.rs"),
10983                Some("tests/test.rs"),
10984            ],
10985        );
10986
10987        let third_status_entry = entries[4].clone();
10988        panel.update_in(cx, |panel, window, cx| {
10989            panel.toggle_staged_for_entry(&third_status_entry, StageIntent::Toggle, window, cx);
10990        });
10991
10992        panel.update_in(cx, |panel, window, cx| {
10993            panel.selected_entry = Some(9);
10994            panel.stage_range(&git::StageRange, window, cx);
10995        });
10996
10997        cx.read(|cx| {
10998            project
10999                .read(cx)
11000                .worktrees(cx)
11001                .next()
11002                .unwrap()
11003                .read(cx)
11004                .as_local()
11005                .unwrap()
11006                .scan_complete()
11007        })
11008        .await;
11009
11010        cx.executor().run_until_parked();
11011
11012        let handle = cx.update_window_entity(&panel, |panel, _, _| {
11013            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
11014        });
11015        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
11016        handle.await;
11017
11018        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
11019        #[rustfmt::skip]
11020        pretty_assertions::assert_matches!(
11021            entries.as_slice(),
11022            &[
11023                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
11024                Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
11025                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
11026                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
11027                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
11028                Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
11029                Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
11030            ],
11031        );
11032
11033        assert_entry_paths(
11034            &entries,
11035            &[
11036                Some("another_new.rs"),
11037                Some("conflict.txt"),
11038                Some("new_file.txt"),
11039                Some("src/lib.rs"),
11040                Some("src/main.rs"),
11041                Some("src/utils.rs"),
11042                Some("tests/test.rs"),
11043            ],
11044        );
11045    }
11046
11047    #[gpui::test]
11048    async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
11049        init_test(cx);
11050        let fs = FakeFs::new(cx.background_executor.clone());
11051        fs.insert_tree(
11052            "/root",
11053            json!({
11054                "project": {
11055                    ".git": {},
11056                    "src": {
11057                        "main.rs": "fn main() {}"
11058                    }
11059                }
11060            }),
11061        )
11062        .await;
11063
11064        fs.set_status_for_repo(
11065            Path::new(path!("/root/project/.git")),
11066            &[("src/main.rs", StatusCode::Modified.worktree())],
11067        );
11068
11069        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
11070        let window_handle =
11071            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11072        let workspace = window_handle
11073            .read_with(cx, |mw, _| mw.workspace().clone())
11074            .unwrap();
11075        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11076
11077        let panel = workspace.update_in(cx, GitPanel::new);
11078
11079        // Test: User has commit message, enables amend (saves message), then disables (restores message)
11080        panel.update(cx, |panel, cx| {
11081            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11082                let start = buffer.anchor_before(0);
11083                let end = buffer.anchor_after(buffer.len());
11084                buffer.edit([(start..end, "Initial commit message")], None, cx);
11085            });
11086
11087            panel.set_amend_pending(true, cx);
11088            assert!(panel.original_commit_message.is_some());
11089
11090            panel.set_amend_pending(false, cx);
11091            let current_message = panel.commit_message_buffer(cx).read(cx).text();
11092            assert_eq!(current_message, "Initial commit message");
11093            assert!(panel.original_commit_message.is_none());
11094        });
11095
11096        // Test: User has empty commit message, enables amend, then disables (clears message)
11097        panel.update(cx, |panel, cx| {
11098            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11099                let start = buffer.anchor_before(0);
11100                let end = buffer.anchor_after(buffer.len());
11101                buffer.edit([(start..end, "")], None, cx);
11102            });
11103
11104            panel.set_amend_pending(true, cx);
11105            assert!(panel.original_commit_message.is_none());
11106
11107            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11108                let start = buffer.anchor_before(0);
11109                let end = buffer.anchor_after(buffer.len());
11110                buffer.edit([(start..end, "Previous commit message")], None, cx);
11111            });
11112
11113            panel.set_amend_pending(false, cx);
11114            let current_message = panel.commit_message_buffer(cx).read(cx).text();
11115            assert_eq!(current_message, "");
11116        });
11117    }
11118
11119    #[gpui::test]
11120    async fn test_commit_message_restored_after_reconnect(cx: &mut TestAppContext) {
11121        init_test(cx);
11122        let fs = FakeFs::new(cx.background_executor.clone());
11123        fs.insert_tree(
11124            "/root",
11125            json!({
11126                "project-a": {
11127                    ".git": {},
11128                    "src": {
11129                        "main.rs": "fn main() {}"
11130                    }
11131                },
11132                "project-b": {
11133                    ".git": {},
11134                    "src": {
11135                        "main.rs": "fn main() {}"
11136                    }
11137                }
11138            }),
11139        )
11140        .await;
11141
11142        fs.set_status_for_repo(
11143            Path::new(path!("/root/project-a/.git")),
11144            &[("src/main.rs", StatusCode::Modified.worktree())],
11145        );
11146        fs.set_status_for_repo(
11147            Path::new(path!("/root/project-b/.git")),
11148            &[("src/main.rs", StatusCode::Modified.worktree())],
11149        );
11150
11151        let project = Project::test(
11152            fs.clone(),
11153            [
11154                Path::new(path!("/root/project-a")),
11155                Path::new(path!("/root/project-b")),
11156            ],
11157            cx,
11158        )
11159        .await;
11160        let (repository_a, repository_b) = project.read_with(cx, |project, cx| {
11161            let git_store = project.git_store().clone();
11162            let mut repository_a = None;
11163            let mut repository_b = None;
11164            for repository in git_store.read(cx).repositories().values() {
11165                let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
11166                if work_directory_abs_path.as_ref() == Path::new(path!("/root/project-a")) {
11167                    repository_a = Some(repository.clone());
11168                } else if work_directory_abs_path.as_ref() == Path::new(path!("/root/project-b")) {
11169                    repository_b = Some(repository.clone());
11170                }
11171            }
11172            (
11173                repository_a.expect("should have repository for project-a"),
11174                repository_b.expect("should have repository for project-b"),
11175            )
11176        });
11177        repository_a.update(cx, |repository, cx| repository.set_as_active_repository(cx));
11178
11179        let window_handle =
11180            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11181        let workspace = window_handle
11182            .read_with(cx, |mw, _| mw.workspace().clone())
11183            .unwrap();
11184        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11185
11186        register_git_commit_language(&project, cx);
11187        let panel = workspace.update_in(cx, GitPanel::new);
11188        cx.run_until_parked();
11189
11190        let message_a = "Restore repository A message";
11191        panel.update(cx, |panel, cx| {
11192            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11193                let start = buffer.anchor_before(0);
11194                let end = buffer.anchor_after(buffer.len());
11195                buffer.edit([(start..end, message_a)], None, cx);
11196            });
11197        });
11198
11199        repository_b.update(cx, |repository, cx| repository.set_as_active_repository(cx));
11200        cx.run_until_parked();
11201
11202        let message_b = "Restore repository B message";
11203        let serialized_panel = panel.update(cx, |panel, cx| {
11204            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11205                let start = buffer.anchor_before(0);
11206                let end = buffer.anchor_after(buffer.len());
11207                buffer.edit([(start..end, message_b)], None, cx);
11208            });
11209
11210            SerializedGitPanel {
11211                signoff_enabled: false,
11212                commit_messages: panel.serialized_commit_messages(cx),
11213            }
11214        });
11215
11216        for repository in [&repository_a, &repository_b] {
11217            let buffer = repository.read_with(cx, |repository, _| {
11218                repository
11219                    .commit_message_buffer()
11220                    .expect("repository commit message buffer should be open")
11221                    .clone()
11222            });
11223            buffer.update(cx, |buffer, cx| {
11224                let start = buffer.anchor_before(0);
11225                let end = buffer.anchor_after(buffer.len());
11226                buffer.edit([(start..end, "")], None, cx);
11227            });
11228        }
11229
11230        let restored_panel = workspace.update_in(cx, |workspace, window, cx| {
11231            GitPanel::new_with_serialized_panel(workspace, Some(serialized_panel), window, cx)
11232        });
11233        cx.run_until_parked();
11234
11235        restored_panel.read_with(cx, |panel, cx| {
11236            assert_eq!(panel.commit_message_buffer(cx).read(cx).text(), message_b);
11237        });
11238
11239        repository_a.update(cx, |repository, cx| repository.set_as_active_repository(cx));
11240        cx.run_until_parked();
11241
11242        restored_panel.read_with(cx, |panel, cx| {
11243            assert_eq!(panel.commit_message_buffer(cx).read(cx).text(), message_a);
11244        });
11245
11246        restored_panel.update(cx, |panel, cx| {
11247            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11248                let start = buffer.anchor_before(0);
11249                let end = buffer.anchor_after(buffer.len());
11250                buffer.edit([(start..end, "")], None, cx);
11251            });
11252        });
11253
11254        let mismatched_serialized_panel = SerializedGitPanel {
11255            signoff_enabled: false,
11256            commit_messages: BTreeMap::from_iter([(
11257                path!("/root/other-project").to_string(),
11258                SerializedCommitMessage {
11259                    message: Some(message_a.to_string()),
11260                    original_message: None,
11261                    ..Default::default()
11262                },
11263            )]),
11264        };
11265        let mismatched_panel = workspace.update_in(cx, |workspace, window, cx| {
11266            GitPanel::new_with_serialized_panel(
11267                workspace,
11268                Some(mismatched_serialized_panel),
11269                window,
11270                cx,
11271            )
11272        });
11273        cx.run_until_parked();
11274
11275        mismatched_panel.read_with(cx, |panel, cx| {
11276            // The draft is not restored because the serialized work directory
11277            // does not match the active repository, so it cannot leak across
11278            // repositories.
11279            assert_eq!(panel.commit_message_buffer(cx).read(cx).text(), "");
11280        });
11281    }
11282
11283    #[gpui::test]
11284    async fn test_pending_commit_state_is_per_repository(cx: &mut TestAppContext) {
11285        init_test(cx);
11286        let fs = FakeFs::new(cx.background_executor.clone());
11287        fs.insert_tree(
11288            "/root",
11289            json!({
11290                "project-a": {
11291                    ".git": {},
11292                    "src": {
11293                        "main.rs": "fn main() {}"
11294                    }
11295                },
11296                "project-b": {
11297                    ".git": {},
11298                    "src": {
11299                        "main.rs": "fn main() {}"
11300                    }
11301                }
11302            }),
11303        )
11304        .await;
11305
11306        fs.set_status_for_repo(
11307            Path::new(path!("/root/project-a/.git")),
11308            &[("src/main.rs", StatusCode::Modified.worktree())],
11309        );
11310        fs.set_status_for_repo(
11311            Path::new(path!("/root/project-b/.git")),
11312            &[("src/main.rs", StatusCode::Modified.worktree())],
11313        );
11314
11315        let project = Project::test(
11316            fs.clone(),
11317            [
11318                Path::new(path!("/root/project-a")),
11319                Path::new(path!("/root/project-b")),
11320            ],
11321            cx,
11322        )
11323        .await;
11324        let (repository_a, repository_b) = project.read_with(cx, |project, cx| {
11325            let git_store = project.git_store().clone();
11326            let mut repository_a = None;
11327            let mut repository_b = None;
11328            for repository in git_store.read(cx).repositories().values() {
11329                let work_directory_abs_path = &repository.read(cx).work_directory_abs_path;
11330                if work_directory_abs_path.as_ref() == Path::new(path!("/root/project-a")) {
11331                    repository_a = Some(repository.clone());
11332                } else if work_directory_abs_path.as_ref() == Path::new(path!("/root/project-b")) {
11333                    repository_b = Some(repository.clone());
11334                }
11335            }
11336            (
11337                repository_a.expect("should have repository for project-a"),
11338                repository_b.expect("should have repository for project-b"),
11339            )
11340        });
11341        repository_a.update(cx, |repository, cx| repository.set_as_active_repository(cx));
11342
11343        let window_handle =
11344            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11345        let workspace = window_handle
11346            .read_with(cx, |mw, _| mw.workspace().clone())
11347            .unwrap();
11348        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11349
11350        register_git_commit_language(&project, cx);
11351        let panel = workspace.update_in(cx, GitPanel::new);
11352        cx.run_until_parked();
11353
11354        // Enter an amend on repository A, then simulate the amend flow loading
11355        // the last commit message into the editor.
11356        panel.update(cx, |panel, cx| {
11357            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11358                let start = buffer.anchor_before(0);
11359                let end = buffer.anchor_after(buffer.len());
11360                buffer.edit([(start..end, "Draft for A")], None, cx);
11361            });
11362            panel.set_amend_pending(true, cx);
11363            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11364                let start = buffer.anchor_before(0);
11365                let end = buffer.anchor_after(buffer.len());
11366                buffer.edit([(start..end, "Amended message")], None, cx);
11367            });
11368            assert!(panel.amend_pending());
11369            panel.set_skip_hooks_enabled(true, cx);
11370            assert!(panel.skip_hooks_enabled());
11371        });
11372
11373        // Switching the active repository away exits the amend state instead of
11374        // carrying it over to repository B.
11375        repository_b.update(cx, |repository, cx| repository.set_as_active_repository(cx));
11376        cx.run_until_parked();
11377
11378        panel.update(cx, |panel, cx| {
11379            assert!(!panel.amend_pending());
11380            assert!(!panel.skip_hooks_enabled());
11381            // Only the active repository may serialize a pending amend, and we
11382            // just left repository A's amend, so nothing is left pending.
11383            let serialized = panel.serialized_commit_messages(cx);
11384            assert!(serialized.values().all(|message| !message.amend_pending));
11385        });
11386
11387        // Repository A's pre-amend draft is restored, discarding the amend edit.
11388        let buffer_a = repository_a.read_with(cx, |repository, _| {
11389            repository
11390                .commit_message_buffer()
11391                .expect("repository commit message buffer should be open")
11392                .clone()
11393        });
11394        buffer_a.read_with(cx, |buffer, _| {
11395            assert_eq!(buffer.text(), "Draft for A");
11396        });
11397    }
11398
11399    #[gpui::test]
11400    async fn test_amend(cx: &mut TestAppContext) {
11401        init_test(cx);
11402        let fs = FakeFs::new(cx.background_executor.clone());
11403        fs.insert_tree(
11404            "/root",
11405            json!({
11406                "project": {
11407                    ".git": {},
11408                    "src": {
11409                        "main.rs": "fn main() {}"
11410                    }
11411                }
11412            }),
11413        )
11414        .await;
11415
11416        fs.set_status_for_repo(
11417            Path::new(path!("/root/project/.git")),
11418            &[("src/main.rs", StatusCode::Modified.worktree())],
11419        );
11420
11421        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
11422        let window_handle =
11423            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11424        let workspace = window_handle
11425            .read_with(cx, |mw, _| mw.workspace().clone())
11426            .unwrap();
11427        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11428
11429        // Wait for the project scanning to finish so that `head_commit(cx)` is
11430        // actually set, otherwise no head commit would be available from which
11431        // to fetch the latest commit message from.
11432        cx.executor().run_until_parked();
11433
11434        let panel = workspace.update_in(cx, GitPanel::new);
11435        panel.read_with(cx, |panel, cx| {
11436            assert!(panel.active_repository.is_some());
11437            assert!(panel.head_commit(cx).is_some());
11438        });
11439
11440        panel.update_in(cx, |panel, window, cx| {
11441            // Update the commit editor's message to ensure that its contents
11442            // are later restored, after amending is finished.
11443            panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
11444                buffer.set_text("refactor: update main.rs", cx);
11445            });
11446
11447            // Start amending the previous commit.
11448            panel.focus_editor(&Default::default(), window, cx);
11449            panel.on_amend(&Amend, window, cx);
11450        });
11451
11452        // Since `GitPanel.amend` attempts to fetch the latest commit message in
11453        // a background task, we need to wait for it to complete before being
11454        // able to assert that the commit message editor's state has been
11455        // updated.
11456        cx.run_until_parked();
11457
11458        panel.update_in(cx, |panel, window, cx| {
11459            assert_eq!(
11460                panel.commit_message_buffer(cx).read(cx).text(),
11461                "initial commit"
11462            );
11463            assert_eq!(
11464                panel.original_commit_message,
11465                Some("refactor: update main.rs".to_string())
11466            );
11467
11468            // Finish amending the previous commit.
11469            panel.focus_editor(&Default::default(), window, cx);
11470            panel.on_amend(&Amend, window, cx);
11471        });
11472
11473        // Since the actual commit logic is run in a background task, we need to
11474        // await its completion to actually ensure that the commit message
11475        // editor's contents are set to the original message and haven't been
11476        // cleared.
11477        cx.run_until_parked();
11478
11479        panel.update_in(cx, |panel, _window, cx| {
11480            // After amending, the commit editor's message should be restored to
11481            // the original message.
11482            assert_eq!(
11483                panel.commit_message_buffer(cx).read(cx).text(),
11484                "refactor: update main.rs"
11485            );
11486            assert!(panel.original_commit_message.is_none());
11487        });
11488    }
11489
11490    #[gpui::test]
11491    async fn test_open_diff(cx: &mut TestAppContext) {
11492        init_test(cx);
11493
11494        let fs = FakeFs::new(cx.background_executor.clone());
11495        fs.insert_tree(
11496            path!("/project"),
11497            json!({
11498                ".git": {},
11499                "tracked": "tracked\n",
11500                "untracked": "\n",
11501            }),
11502        )
11503        .await;
11504
11505        fs.set_head_and_index_for_repo(
11506            path!("/project/.git").as_ref(),
11507            &[("tracked", "old tracked\n".into())],
11508        );
11509
11510        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
11511        let window_handle =
11512            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11513        let workspace = window_handle
11514            .read_with(cx, |mw, _| mw.workspace().clone())
11515            .unwrap();
11516        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11517        let panel = workspace.update_in(cx, GitPanel::new);
11518
11519        // Disable status grouping and wait for entries to be updated,
11520        // as there should no longer be separators between Tracked and Untracked
11521        // files.
11522        cx.update(|_window, cx| {
11523            SettingsStore::update_global(cx, |store, cx| {
11524                store.update_user_settings(cx, |settings| {
11525                    settings.git_panel.get_or_insert_default().group_by =
11526                        Some(GitPanelGroupBy::None);
11527                })
11528            });
11529        });
11530
11531        cx.update_window_entity(&panel, |panel, _, _| {
11532            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
11533        })
11534        .await;
11535
11536        // Confirm that `Open Diff` still works for the untracked file, updating
11537        // the Project Diff's active path.
11538        panel.update_in(cx, |panel, window, cx| {
11539            panel.selected_entry = Some(1);
11540            panel.open_diff(&menu::Confirm, window, cx);
11541        });
11542        cx.run_until_parked();
11543
11544        workspace.update_in(cx, |workspace, _window, cx| {
11545            let active_path = workspace
11546                .item_of_type::<ProjectDiff>(cx)
11547                .expect("ProjectDiff should exist")
11548                .read(cx)
11549                .active_project_path(cx)
11550                .expect("active_project_path should exist");
11551
11552            assert_eq!(active_path.path, rel_path("untracked").into_arc());
11553        });
11554    }
11555
11556    #[gpui::test]
11557    async fn test_remote_operation_serialization(cx: &mut TestAppContext) {
11558        init_test(cx);
11559
11560        let fs = FakeFs::new(cx.background_executor.clone());
11561        fs.insert_tree(
11562            path!("/project"),
11563            json!({
11564                ".git": {},
11565            }),
11566        )
11567        .await;
11568
11569        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
11570        let window_handle =
11571            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11572        let workspace = window_handle
11573            .read_with(cx, |mw, _| mw.workspace().clone())
11574            .unwrap();
11575        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11576        let panel = workspace.update_in(cx, GitPanel::new);
11577
11578        panel.update(cx, |panel, cx| {
11579            // The first remote operation starts and records its kind, which the
11580            // button uses to render an "in progress" tooltip.
11581            assert!(panel.start_remote_operation(RemoteOperationKind::Fetch, cx));
11582            assert!(matches!(
11583                panel.pending_remote_operation,
11584                Some(RemoteOperationKind::Fetch)
11585            ));
11586
11587            // A second remote operation is refused while one is pending, even a
11588            // different kind: we serialize all remote ops.
11589            assert!(!panel.start_remote_operation(RemoteOperationKind::Push, cx));
11590
11591            // Clearing the pending operation re-opens the gate.
11592            panel.clear_remote_operation(cx);
11593            assert!(panel.pending_remote_operation.is_none());
11594            assert!(panel.start_remote_operation(RemoteOperationKind::Pull, cx));
11595        });
11596    }
11597
11598    #[gpui::test]
11599    async fn test_tree_view_without_status_grouping_combines_statuses(cx: &mut TestAppContext) {
11600        init_test(cx);
11601
11602        let fs = FakeFs::new(cx.background_executor.clone());
11603        fs.insert_tree(
11604            path!("/project"),
11605            json!({
11606                ".git": {},
11607                "src": {
11608                    "main.rs": "fn main() {}",
11609                    "utils.rs": "pub fn util() {}",
11610                },
11611                "tests": {
11612                    "main_test.rs": "#[test] fn test_main() {}",
11613                },
11614            }),
11615        )
11616        .await;
11617
11618        fs.set_status_for_repo(
11619            path!("/project/.git").as_ref(),
11620            &[
11621                ("src/main.rs", StatusCode::Modified.worktree()),
11622                ("src/utils.rs", FileStatus::Untracked),
11623                ("tests/main_test.rs", StatusCode::Modified.worktree()),
11624            ],
11625        );
11626
11627        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
11628        let window_handle =
11629            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11630        let workspace = window_handle
11631            .read_with(cx, |mw, _| mw.workspace().clone())
11632            .unwrap();
11633        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11634
11635        cx.read(|cx| {
11636            project
11637                .read(cx)
11638                .worktrees(cx)
11639                .next()
11640                .unwrap()
11641                .read(cx)
11642                .as_local()
11643                .unwrap()
11644                .scan_complete()
11645        })
11646        .await;
11647
11648        cx.executor().run_until_parked();
11649        cx.update(|_window, cx| {
11650            SettingsStore::update_global(cx, |store, cx| {
11651                store.update_user_settings(cx, |settings| {
11652                    let git_panel = settings.git_panel.get_or_insert_default();
11653                    git_panel.tree_view = Some(true);
11654                    git_panel.group_by = Some(GitPanelGroupBy::None);
11655                })
11656            });
11657        });
11658
11659        let panel = workspace.update_in(cx, GitPanel::new);
11660        let handle = cx.update_window_entity(&panel, |panel, _, _| {
11661            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
11662        });
11663
11664        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
11665        handle.await;
11666
11667        panel.read_with(cx, |panel, _| {
11668            assert!(
11669                panel
11670                    .entries
11671                    .iter()
11672                    .all(|entry| !matches!(entry, GitListEntry::Header(_))),
11673                "status headers should not be shown when grouping is disabled",
11674            );
11675
11676            let tree_state = panel
11677                .view_mode
11678                .tree_state()
11679                .expect("tree view state should exist");
11680            let src_key = panel
11681                .entries
11682                .iter()
11683                .find_map(|entry| match entry {
11684                    GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
11685                        Some(&dir.key)
11686                    }
11687                    _ => None,
11688                })
11689                .expect("src directory should exist in tree view");
11690            let src_descendants = tree_state
11691                .directory_descendants
11692                .get(src_key)
11693                .expect("src descendants should be tracked");
11694
11695            assert!(
11696                src_descendants
11697                    .iter()
11698                    .any(|entry| entry.repo_path == repo_path("src/main.rs"))
11699            );
11700            assert!(
11701                src_descendants
11702                    .iter()
11703                    .any(|entry| entry.repo_path == repo_path("src/utils.rs"))
11704            );
11705        });
11706    }
11707
11708    #[gpui::test]
11709    async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
11710        cx: &mut TestAppContext,
11711    ) {
11712        init_test(cx);
11713
11714        let fs = FakeFs::new(cx.background_executor.clone());
11715        fs.insert_tree(
11716            path!("/project"),
11717            json!({
11718                ".git": {},
11719                "src": {
11720                    "a": {
11721                        "foo.rs": "fn foo() {}",
11722                    },
11723                    "b": {
11724                        "bar.rs": "fn bar() {}",
11725                    },
11726                },
11727            }),
11728        )
11729        .await;
11730
11731        fs.set_status_for_repo(
11732            path!("/project/.git").as_ref(),
11733            &[
11734                ("src/a/foo.rs", StatusCode::Modified.worktree()),
11735                ("src/b/bar.rs", StatusCode::Modified.worktree()),
11736            ],
11737        );
11738
11739        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
11740        let window_handle =
11741            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11742        let workspace = window_handle
11743            .read_with(cx, |mw, _| mw.workspace().clone())
11744            .unwrap();
11745        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11746
11747        cx.read(|cx| {
11748            project
11749                .read(cx)
11750                .worktrees(cx)
11751                .next()
11752                .unwrap()
11753                .read(cx)
11754                .as_local()
11755                .unwrap()
11756                .scan_complete()
11757        })
11758        .await;
11759
11760        cx.executor().run_until_parked();
11761
11762        cx.update(|_window, cx| {
11763            SettingsStore::update_global(cx, |store, cx| {
11764                store.update_user_settings(cx, |settings| {
11765                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
11766                })
11767            });
11768        });
11769
11770        let panel = workspace.update_in(cx, GitPanel::new);
11771
11772        let handle = cx.update_window_entity(&panel, |panel, _, _| {
11773            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
11774        });
11775        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
11776        handle.await;
11777
11778        let src_key = panel.read_with(cx, |panel, _| {
11779            panel
11780                .entries
11781                .iter()
11782                .find_map(|entry| match entry {
11783                    GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
11784                        Some(dir.key.clone())
11785                    }
11786                    _ => None,
11787                })
11788                .expect("src directory should exist in tree view")
11789        });
11790
11791        panel.update_in(cx, |panel, window, cx| {
11792            panel.toggle_directory(&src_key, window, cx);
11793        });
11794
11795        panel.read_with(cx, |panel, _| {
11796            let state = panel
11797                .view_mode
11798                .tree_state()
11799                .expect("tree view state should exist");
11800            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
11801        });
11802
11803        let worktree_id =
11804            cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
11805        let project_path = ProjectPath {
11806            worktree_id,
11807            path: RelPath::from_unix_str("src/a/foo.rs").unwrap().into_arc(),
11808        };
11809
11810        panel.update_in(cx, |panel, window, cx| {
11811            panel.select_entry_by_path(project_path, window, cx);
11812        });
11813
11814        panel.read_with(cx, |panel, _| {
11815            let state = panel
11816                .view_mode
11817                .tree_state()
11818                .expect("tree view state should exist");
11819            assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
11820
11821            let selected_ix = panel.selected_entry.expect("selection should be set");
11822            assert!(state.logical_indices.contains(&selected_ix));
11823
11824            let selected_entry = panel
11825                .entries
11826                .get(selected_ix)
11827                .and_then(|entry| entry.status_entry())
11828                .expect("selected entry should be a status entry");
11829            assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
11830        });
11831    }
11832
11833    #[gpui::test]
11834    async fn test_tree_view_select_next_at_last_visible_collapsed_directory(
11835        cx: &mut TestAppContext,
11836    ) {
11837        init_test(cx);
11838
11839        let fs = FakeFs::new(cx.background_executor.clone());
11840        fs.insert_tree(
11841            path!("/project"),
11842            json!({
11843                ".git": {},
11844                "bar": {
11845                    "bar1.py": "print('bar1')",
11846                    "bar2.py": "print('bar2')",
11847                },
11848                "foo": {
11849                    "foo1.py": "print('foo1')",
11850                    "foo2.py": "print('foo2')",
11851                },
11852                "foobar.py": "print('foobar')",
11853            }),
11854        )
11855        .await;
11856
11857        fs.set_status_for_repo(
11858            path!("/project/.git").as_ref(),
11859            &[
11860                ("bar/bar1.py", StatusCode::Modified.worktree()),
11861                ("bar/bar2.py", StatusCode::Modified.worktree()),
11862                ("foo/foo1.py", StatusCode::Modified.worktree()),
11863                ("foo/foo2.py", StatusCode::Modified.worktree()),
11864                ("foobar.py", FileStatus::Untracked),
11865            ],
11866        );
11867
11868        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
11869        let window_handle =
11870            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
11871        let workspace = window_handle
11872            .read_with(cx, |mw, _| mw.workspace().clone())
11873            .unwrap();
11874        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
11875
11876        cx.read(|cx| {
11877            project
11878                .read(cx)
11879                .worktrees(cx)
11880                .next()
11881                .unwrap()
11882                .read(cx)
11883                .as_local()
11884                .unwrap()
11885                .scan_complete()
11886        })
11887        .await;
11888
11889        cx.executor().run_until_parked();
11890        cx.update(|_window, cx| {
11891            SettingsStore::update_global(cx, |store, cx| {
11892                store.update_user_settings(cx, |settings| {
11893                    settings.git_panel.get_or_insert_default().tree_view = Some(true);
11894                })
11895            });
11896        });
11897
11898        let panel = workspace.update_in(cx, GitPanel::new);
11899        let handle = cx.update_window_entity(&panel, |panel, _, _| {
11900            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
11901        });
11902
11903        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
11904        handle.await;
11905
11906        let foo_key = panel.read_with(cx, |panel, _| {
11907            panel
11908                .entries
11909                .iter()
11910                .find_map(|entry| match entry {
11911                    GitListEntry::Directory(dir) if dir.key.path == repo_path("foo") => {
11912                        Some(dir.key.clone())
11913                    }
11914                    _ => None,
11915                })
11916                .expect("foo directory should exist in tree view")
11917        });
11918
11919        panel.update_in(cx, |panel, window, cx| {
11920            panel.toggle_directory(&foo_key, window, cx);
11921        });
11922
11923        let foo_idx = panel.read_with(cx, |panel, _| {
11924            let state = panel
11925                .view_mode
11926                .tree_state()
11927                .expect("tree view state should exist");
11928            assert_eq!(state.expanded_dirs.get(&foo_key).copied(), Some(false));
11929
11930            let foo_idx = panel
11931                .entries
11932                .iter()
11933                .enumerate()
11934                .find_map(|(index, entry)| match entry {
11935                    GitListEntry::Directory(dir) if dir.key.path == repo_path("foo") => Some(index),
11936                    _ => None,
11937                })
11938                .expect("foo directory should exist in tree view");
11939
11940            let foo_logical_idx = state
11941                .logical_indices
11942                .iter()
11943                .position(|&index| index == foo_idx)
11944                .expect("foo directory should be visible");
11945            let next_logical_idx = state.logical_indices[foo_logical_idx + 1];
11946            assert!(matches!(
11947                panel.entries.get(next_logical_idx),
11948                Some(GitListEntry::Header(GitHeaderEntry {
11949                    header: Section::New
11950                }))
11951            ));
11952
11953            foo_idx
11954        });
11955
11956        panel.update_in(cx, |panel, window, cx| {
11957            panel.selected_entry = Some(foo_idx);
11958            panel.select_next(&menu::SelectNext, window, cx);
11959        });
11960
11961        panel.read_with(cx, |panel, _| {
11962            let selected_idx = panel.selected_entry.expect("selection should be set");
11963            let selected_entry = panel
11964                .entries
11965                .get(selected_idx)
11966                .and_then(|entry| entry.status_entry())
11967                .expect("selected entry should be a status entry");
11968            assert_eq!(selected_entry.repo_path, repo_path("foobar.py"));
11969        });
11970    }
11971
11972    fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
11973        assert_eq!(entries.len(), expected_paths.len());
11974        for (entry, expected_path) in entries.iter().zip(expected_paths) {
11975            assert_eq!(
11976                entry.status_entry().map(|status| status
11977                    .repo_path
11978                    .as_ref()
11979                    .as_std_path()
11980                    .to_string_lossy()
11981                    .to_string()),
11982                expected_path.map(|s| s.to_string())
11983            );
11984        }
11985    }
11986
11987    #[test]
11988    fn test_compress_diff_no_truncation() {
11989        let diff = indoc! {"
11990            --- a/file.txt
11991            +++ b/file.txt
11992            @@ -1,2 +1,2 @@
11993            -old
11994            +new
11995        "};
11996        let result = GitPanel::compress_commit_diff(diff, 1000);
11997        assert_eq!(result, diff);
11998    }
11999
12000    #[test]
12001    fn test_compress_diff_truncate_long_lines() {
12002        let long_line = "🦀".repeat(300);
12003        let diff = indoc::formatdoc! {"
12004            --- a/file.txt
12005            +++ b/file.txt
12006            @@ -1,2 +1,3 @@
12007             context
12008            +{}
12009             more context
12010        ", long_line};
12011        let result = GitPanel::compress_commit_diff(&diff, 100);
12012        assert!(result.contains("...[truncated]"));
12013        assert!(result.len() < diff.len());
12014    }
12015
12016    #[test]
12017    fn test_compress_diff_truncate_hunks() {
12018        let diff = indoc! {"
12019            --- a/file.txt
12020            +++ b/file.txt
12021            @@ -1,2 +1,2 @@
12022             context
12023            -old1
12024            +new1
12025            @@ -5,2 +5,2 @@
12026             context 2
12027            -old2
12028            +new2
12029            @@ -10,2 +10,2 @@
12030             context 3
12031            -old3
12032            +new3
12033        "};
12034        let result = GitPanel::compress_commit_diff(diff, 100);
12035        let expected = indoc! {"
12036            --- a/file.txt
12037            +++ b/file.txt
12038            @@ -1,2 +1,2 @@
12039             context
12040            -old1
12041            +new1
12042            [...skipped 2 hunks...]
12043        "};
12044        assert_eq!(result, expected);
12045    }
12046
12047    #[test]
12048    fn test_commit_message_prompt_includes_user_agents_md_before_project_rules() {
12049        let prompt = GitPanel::build_commit_message_prompt(
12050            "Write a commit message.",
12051            Some("Use terse commit messages."),
12052            Some("Use the git_ui prefix."),
12053            Some("Follow the configured commit message format."),
12054            "Update generated message",
12055            "diff --git a/file b/file",
12056        );
12057
12058        assert!(prompt.contains("Use terse commit messages."));
12059        assert!(prompt.contains("Use the git_ui prefix."));
12060        assert!(prompt.contains("Follow the configured commit message format."));
12061        assert!(prompt.contains("Update generated message"));
12062        assert!(prompt.contains("diff --git a/file b/file"));
12063
12064        let user_agents_md_index = prompt.find("<rules>").unwrap();
12065        let project_rules_index = prompt.find("<project_rules>").unwrap();
12066        let instructions_index = prompt.find("<commit_message_instructions>").unwrap();
12067        assert!(user_agents_md_index < project_rules_index);
12068        assert!(project_rules_index < instructions_index);
12069    }
12070
12071    #[test]
12072    fn test_commit_message_prompt_omits_blank_instructions() {
12073        let prompt = GitPanel::build_commit_message_prompt(
12074            "Write a commit message.",
12075            None,
12076            None,
12077            Some("   \n  "),
12078            "",
12079            "diff --git a/file b/file",
12080        );
12081
12082        assert!(!prompt.contains("<commit_message_instructions>"));
12083    }
12084
12085    #[gpui::test]
12086    async fn test_suggest_commit_message(cx: &mut TestAppContext) {
12087        init_test(cx);
12088
12089        let fs = FakeFs::new(cx.background_executor.clone());
12090        fs.insert_tree(
12091            path!("/project"),
12092            json!({
12093                ".git": {},
12094                "tracked": "tracked\n",
12095                "untracked": "\n",
12096            }),
12097        )
12098        .await;
12099
12100        fs.set_head_and_index_for_repo(
12101            path!("/project/.git").as_ref(),
12102            &[("tracked", "old tracked\n".into())],
12103        );
12104
12105        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
12106        let window_handle =
12107            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
12108        let workspace = window_handle
12109            .read_with(cx, |mw, _| mw.workspace().clone())
12110            .unwrap();
12111        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
12112        let panel = workspace.update_in(cx, GitPanel::new);
12113
12114        let handle = cx.update_window_entity(&panel, |panel, _, _| {
12115            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
12116        });
12117        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
12118        handle.await;
12119
12120        let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
12121
12122        // GitPanel
12123        // - Tracked:
12124        // - [] tracked
12125        // - Untracked
12126        // - [] untracked
12127        //
12128        // The commit message should now read:
12129        // "Update tracked"
12130        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
12131        assert_eq!(message, Some("Update tracked".to_string()));
12132
12133        let first_status_entry = entries[1].clone();
12134        panel.update_in(cx, |panel, window, cx| {
12135            panel.toggle_staged_for_entry(&first_status_entry, StageIntent::Toggle, window, cx);
12136        });
12137
12138        cx.read(|cx| {
12139            project
12140                .read(cx)
12141                .worktrees(cx)
12142                .next()
12143                .unwrap()
12144                .read(cx)
12145                .as_local()
12146                .unwrap()
12147                .scan_complete()
12148        })
12149        .await;
12150
12151        cx.executor().run_until_parked();
12152
12153        let handle = cx.update_window_entity(&panel, |panel, _, _| {
12154            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
12155        });
12156        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
12157        handle.await;
12158
12159        // GitPanel
12160        // - Tracked:
12161        // - [x] tracked
12162        // - Untracked
12163        // - [] untracked
12164        //
12165        // The commit message should still read:
12166        // "Update tracked"
12167        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
12168        assert_eq!(message, Some("Update tracked".to_string()));
12169
12170        let second_status_entry = entries[3].clone();
12171        panel.update_in(cx, |panel, window, cx| {
12172            panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx);
12173        });
12174
12175        cx.read(|cx| {
12176            project
12177                .read(cx)
12178                .worktrees(cx)
12179                .next()
12180                .unwrap()
12181                .read(cx)
12182                .as_local()
12183                .unwrap()
12184                .scan_complete()
12185        })
12186        .await;
12187
12188        cx.executor().run_until_parked();
12189
12190        let handle = cx.update_window_entity(&panel, |panel, _, _| {
12191            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
12192        });
12193        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
12194        handle.await;
12195
12196        // GitPanel
12197        // - Tracked:
12198        // - [x] tracked
12199        // - Untracked
12200        // - [x] untracked
12201        //
12202        // The commit message should now read:
12203        // "Enter commit message"
12204        // (which means we should see None returned).
12205        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
12206        assert!(message.is_none());
12207
12208        panel.update_in(cx, |panel, window, cx| {
12209            panel.toggle_staged_for_entry(&first_status_entry, StageIntent::Toggle, window, cx);
12210        });
12211
12212        cx.read(|cx| {
12213            project
12214                .read(cx)
12215                .worktrees(cx)
12216                .next()
12217                .unwrap()
12218                .read(cx)
12219                .as_local()
12220                .unwrap()
12221                .scan_complete()
12222        })
12223        .await;
12224
12225        cx.executor().run_until_parked();
12226
12227        let handle = cx.update_window_entity(&panel, |panel, _, _| {
12228            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
12229        });
12230        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
12231        handle.await;
12232
12233        // GitPanel
12234        // - Tracked:
12235        // - [] tracked
12236        // - Untracked
12237        // - [x] untracked
12238        //
12239        // The commit message should now read:
12240        // "Update untracked"
12241        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
12242        assert_eq!(message, Some("Create untracked".to_string()));
12243
12244        panel.update_in(cx, |panel, window, cx| {
12245            panel.toggle_staged_for_entry(&second_status_entry, StageIntent::Toggle, window, cx);
12246        });
12247
12248        cx.read(|cx| {
12249            project
12250                .read(cx)
12251                .worktrees(cx)
12252                .next()
12253                .unwrap()
12254                .read(cx)
12255                .as_local()
12256                .unwrap()
12257                .scan_complete()
12258        })
12259        .await;
12260
12261        cx.executor().run_until_parked();
12262
12263        let handle = cx.update_window_entity(&panel, |panel, _, _| {
12264            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
12265        });
12266        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
12267        handle.await;
12268
12269        // GitPanel
12270        // - Tracked:
12271        // - [] tracked
12272        // - Untracked
12273        // - [] untracked
12274        //
12275        // The commit message should now read:
12276        // "Update tracked"
12277        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
12278        assert_eq!(message, Some("Update tracked".to_string()));
12279
12280        cx.update(|_window, cx| {
12281            SettingsStore::update_global(cx, |store, cx| {
12282                store.update_user_settings(cx, |settings| {
12283                    settings.git_panel.get_or_insert_default().group_by =
12284                        Some(GitPanelGroupBy::Staging);
12285                });
12286            });
12287        });
12288        await_git_panel_entries(&panel, cx).await;
12289
12290        let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
12291        assert_eq!(message, Some("Update tracked".to_string()));
12292    }
12293
12294    #[test]
12295    fn test_git_output_handler_strips_ansi_codes() {
12296        let cases = [
12297            ("no escape codes here\n", "no escape codes here\n"),
12298            ("\x1b[31mhello\x1b[0m", "hello"),
12299            ("\x1b[1;32mfoo\x1b[0m bar", "foo bar"),
12300            ("progress 10%\rprogress 100%\n", "progress 100%\n"),
12301        ];
12302
12303        for (input, expected) in cases {
12304            assert_eq!(terminal::strip_ansi_text(input.as_bytes()), expected);
12305        }
12306    }
12307
12308    #[test]
12309    fn test_commit_title_exceeds_limit() {
12310        // ASCII only
12311        let within_ascii = "abcde";
12312        let exceeds_ascii = "abcdef";
12313        assert!(!commit_title_exceeds_limit(within_ascii, 5));
12314        assert!(commit_title_exceeds_limit(exceeds_ascii, 5));
12315
12316        // Multi-byte characters are counted as grapheme clusters
12317        let within_japanese = "あいうえお"; // 5 chars, 15 bytes
12318        let exceeds_japanese = "あいうえおか"; // 6 chars, 18 bytes
12319        assert!(!commit_title_exceeds_limit(within_japanese, 5));
12320        assert!(commit_title_exceeds_limit(exceeds_japanese, 5));
12321
12322        // Mixed ASCII + multi-byte
12323        let within_mixed = "abcあ";
12324        let exceeds_mixed = "abcああ";
12325        assert!(!commit_title_exceeds_limit(within_mixed, 4));
12326        assert!(commit_title_exceeds_limit(exceeds_mixed, 4));
12327
12328        // Emoji counts as one character each
12329        let within_emoji = "🚀";
12330        let exceeds_emoji = "🚀🚀";
12331        assert!(!commit_title_exceeds_limit(within_emoji, 1));
12332        assert!(commit_title_exceeds_limit(exceeds_emoji, 1));
12333
12334        // A max_length of 0 disables the limit check
12335        assert!(!commit_title_exceeds_limit(
12336            "anything goes when disabled",
12337            0
12338        ));
12339        assert!(!commit_title_exceeds_limit("", 0));
12340
12341        // Empty title never exceeds a positive limit
12342        assert!(!commit_title_exceeds_limit("", 72));
12343    }
12344
12345    #[gpui::test]
12346    async fn test_dispatch_context_with_focus_states(cx: &mut TestAppContext) {
12347        init_test(cx);
12348
12349        let fs = FakeFs::new(cx.background_executor.clone());
12350        fs.insert_tree(
12351            path!("/project"),
12352            json!({
12353                ".git": {},
12354                "tracked": "tracked\n",
12355            }),
12356        )
12357        .await;
12358
12359        fs.set_head_and_index_for_repo(
12360            path!("/project/.git").as_ref(),
12361            &[("tracked", "old tracked\n".into())],
12362        );
12363
12364        let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
12365        let window_handle =
12366            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
12367        let workspace = window_handle
12368            .read_with(cx, |mw, _| mw.workspace().clone())
12369            .unwrap();
12370        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
12371        let panel = workspace.update_in(cx, GitPanel::new);
12372
12373        let handle = cx.update_window_entity(&panel, |panel, _, _| {
12374            std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
12375        });
12376        cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
12377        handle.await;
12378
12379        // Case 1: Focus the commit editor — should have "CommitEditor" but NOT "menu"/"ChangesList"
12380        panel.update_in(cx, |panel, window, cx| {
12381            panel.focus_editor(&FocusEditor, window, cx);
12382            let editor_is_focused = panel.commit_editor.read(cx).is_focused(window);
12383            assert!(
12384                editor_is_focused,
12385                "commit editor should be focused after focus_editor action"
12386            );
12387            let context = panel.dispatch_context(window, cx);
12388            assert!(
12389                context.contains("GitPanel"),
12390                "should always have GitPanel context"
12391            );
12392            assert!(
12393                context.contains("CommitEditor"),
12394                "should have CommitEditor context when commit editor is focused"
12395            );
12396            assert!(
12397                !context.contains("menu"),
12398                "should not have menu context when commit editor is focused"
12399            );
12400            assert!(
12401                !context.contains("ChangesList"),
12402                "should not have ChangesList context when commit editor is focused"
12403            );
12404        });
12405
12406        // Case 2: Focus the panel's focus handle directly — should have "menu" and "ChangesList".
12407        // We force a draw via simulate_resize to ensure the dispatch tree is populated,
12408        // since contains_focused() depends on the rendered dispatch tree.
12409        panel.update_in(cx, |panel, window, cx| {
12410            panel.focus_handle.focus(window, cx);
12411        });
12412        cx.simulate_resize(gpui::size(px(800.), px(600.)));
12413
12414        panel.update_in(cx, |panel, window, cx| {
12415            let context = panel.dispatch_context(window, cx);
12416            assert!(
12417                context.contains("GitPanel"),
12418                "should always have GitPanel context"
12419            );
12420            assert!(
12421                context.contains("menu"),
12422                "should have menu context when changes list is focused"
12423            );
12424            assert!(
12425                context.contains("ChangesList"),
12426                "should have ChangesList context when changes list is focused"
12427            );
12428            assert!(
12429                !context.contains("CommitEditor"),
12430                "should not have CommitEditor context when changes list is focused"
12431            );
12432        });
12433
12434        // Case 3: Switch back to commit editor and verify context switches correctly
12435        panel.update_in(cx, |panel, window, cx| {
12436            panel.focus_editor(&FocusEditor, window, cx);
12437        });
12438
12439        panel.update_in(cx, |panel, window, cx| {
12440            let context = panel.dispatch_context(window, cx);
12441            assert!(
12442                context.contains("CommitEditor"),
12443                "should have CommitEditor after switching focus back to editor"
12444            );
12445            assert!(
12446                !context.contains("menu"),
12447                "should not have menu after switching focus back to editor"
12448            );
12449        });
12450
12451        // Case 4: Re-focus changes list and verify it transitions back correctly
12452        panel.update_in(cx, |panel, window, cx| {
12453            panel.focus_handle.focus(window, cx);
12454        });
12455        cx.simulate_resize(gpui::size(px(800.), px(600.)));
12456
12457        panel.update_in(cx, |panel, window, cx| {
12458            assert!(
12459                panel.focus_handle.contains_focused(window, cx),
12460                "panel focus handle should report contains_focused when directly focused"
12461            );
12462            let context = panel.dispatch_context(window, cx);
12463            assert!(
12464                context.contains("menu"),
12465                "should have menu context after re-focusing changes list"
12466            );
12467            assert!(
12468                context.contains("ChangesList"),
12469                "should have ChangesList context after re-focusing changes list"
12470            );
12471        });
12472    }
12473
12474    #[gpui::test]
12475    async fn test_fill_commit_editor_toggle(cx: &mut TestAppContext) {
12476        init_test(cx);
12477        let fs = FakeFs::new(cx.background_executor.clone());
12478        fs.insert_tree(
12479            "/root",
12480            json!({ "project": { ".git": {}, "src": { "main.rs": "fn main() {}" } } }),
12481        )
12482        .await;
12483
12484        let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
12485        let window_handle =
12486            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
12487        let workspace = window_handle
12488            .read_with(cx, |mw, _| mw.workspace().clone())
12489            .unwrap();
12490        let cx = &mut VisualTestContext::from_window(window_handle.into(), cx);
12491        cx.executor().run_until_parked();
12492
12493        let panel = workspace.update_in(cx, GitPanel::new);
12494
12495        panel.update_in(cx, |panel, window, cx| {
12496            assert!(!panel.commit_editor_expanded);
12497            assert!(matches!(
12498                panel.commit_editor.read(cx).mode().clone(),
12499                EditorMode::AutoHeight { .. }
12500            ));
12501
12502            panel.toggle_fill_commit_editor(&ToggleFillCommitEditor, window, cx);
12503            assert!(panel.commit_editor_expanded);
12504            assert!(matches!(
12505                panel.commit_editor.read(cx).mode().clone(),
12506                EditorMode::Full { .. }
12507            ));
12508
12509            panel.toggle_fill_commit_editor(&ToggleFillCommitEditor, window, cx);
12510            assert!(!panel.commit_editor_expanded);
12511            assert!(matches!(
12512                panel.commit_editor.read(cx).mode().clone(),
12513                EditorMode::AutoHeight { .. }
12514            ));
12515        });
12516    }
12517
12518    #[gpui::test]
12519    async fn test_focus_handle(cx: &mut TestAppContext) {
12520        init_test(cx);
12521
12522        let (_, _project, workspace, panel, mut cx) = setup_git_panel_with_changes(
12523            cx,
12524            json!({
12525                ".git": {},
12526                "tracked": "tracked\n",
12527            }),
12528            &[("tracked", StatusCode::Modified)],
12529        )
12530        .await;
12531
12532        workspace.update_in(&mut cx, |workspace, window, cx| {
12533            workspace.add_panel(panel.clone(), window, cx);
12534        });
12535
12536        // With changes present and the editor not expanded, the panel's own
12537        // focus handle should be returned, in order for
12538        // `git_panel::ToggleFocus` to focus on the panel itself.
12539        panel.update_in(&mut cx, |panel, _window, cx| {
12540            assert!(!panel.entries.is_empty());
12541            assert!(!panel.commit_editor_expanded);
12542            assert_eq!(panel.focus_handle(cx), panel.focus_handle.clone());
12543        });
12544
12545        // Expand the editor so we can later confirm that toggling focus
12546        // actually focuses on the commit editor, seeing as it has been
12547        // expanded.
12548        panel.update_in(&mut cx, |panel, window, cx| {
12549            panel.toggle_fill_commit_editor(&ToggleFillCommitEditor, window, cx);
12550            assert!(panel.commit_editor_expanded);
12551        });
12552
12553        cx.dispatch_action(super::ToggleFocus);
12554        panel.update_in(&mut cx, |panel, window, cx| {
12555            assert!(panel.commit_editor.focus_handle(cx).is_focused(window));
12556        });
12557    }
12558}
12559
Served at tenant.openagents/omega Member data and write actions are omitted.