Skip to repository content12559 lines · 473.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:33:03.427Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
git_panel.rs
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(¤t_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(®istry, |_, _, 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