1use crate::askpass_modal::AskPassModal;
2use crate::commit_modal::CommitModal;
3use crate::commit_tooltip::CommitTooltip;
4use crate::commit_view::CommitView;
5use crate::git_panel_settings::StatusStyle;
6use crate::project_diff::{self, Diff, ProjectDiff};
7use crate::remote_output::{self, RemoteAction, SuccessMessage};
8use crate::{branch_picker, picker_prompt, render_remote_button};
9use crate::{
10 git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
11};
12use agent_settings::AgentSettings;
13use anyhow::Context as _;
14use askpass::AskPassDelegate;
15use db::kvp::KEY_VALUE_STORE;
16use editor::{
17 Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar,
18 scroll::ScrollbarAutoHide,
19};
20use futures::StreamExt as _;
21use git::blame::ParsedCommitMessage;
22use git::repository::{
23 Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
24 PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
25 UpstreamTrackingStatus, get_git_committer,
26};
27use git::status::StageStatus;
28use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
29use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
30use gpui::{
31 Action, Animation, AnimationExt as _, AsyncApp, AsyncWindowContext, Axis, ClickEvent, Corner,
32 DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext,
33 ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, MouseDownEvent, Point,
34 PromptLevel, ScrollStrategy, Subscription, Task, Transformation, UniformListScrollHandle,
35 WeakEntity, actions, anchored, deferred, percentage, uniform_list,
36};
37use itertools::Itertools;
38use language::{Buffer, File};
39use language_model::{
40 ConfiguredModel, LanguageModel, LanguageModelRegistry, LanguageModelRequest,
41 LanguageModelRequestMessage, Role,
42};
43use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
44use multi_buffer::ExcerptInfo;
45use notifications::status_toast::{StatusToast, ToastIcon};
46use panel::{
47 PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
48 panel_icon_button,
49};
50use project::{
51 DisableAiSettings, Fs, Project, ProjectPath,
52 git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId},
53};
54use serde::{Deserialize, Serialize};
55use settings::{Settings, SettingsStore};
56use std::future::Future;
57use std::ops::Range;
58use std::path::{Path, PathBuf};
59use std::{collections::HashSet, sync::Arc, time::Duration, usize};
60use strum::{IntoEnumIterator, VariantNames};
61use time::OffsetDateTime;
62use ui::{
63 Checkbox, ContextMenu, ElevationIndex, IconPosition, Label, LabelSize, PopoverMenu, Scrollbar,
64 ScrollbarState, SplitButton, Tooltip, prelude::*,
65};
66use util::{ResultExt, TryFutureExt, maybe};
67use workspace::SERIALIZATION_THROTTLE_TIME;
68
69use workspace::{
70 Workspace,
71 dock::{DockPosition, Panel, PanelEvent},
72 notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId},
73};
74use zed_llm_client::CompletionIntent;
75
76actions!(
77 git_panel,
78 [
79 /// Closes the git panel.
80 Close,
81 /// Toggles focus on the git panel.
82 ToggleFocus,
83 /// Opens the git panel menu.
84 OpenMenu,
85 /// Focuses on the commit message editor.
86 FocusEditor,
87 /// Focuses on the changes list.
88 FocusChanges,
89 /// Toggles automatic co-author suggestions.
90 ToggleFillCoAuthors,
91 ]
92);
93
94fn prompt<T>(
95 msg: &str,
96 detail: Option<&str>,
97 window: &mut Window,
98 cx: &mut App,
99) -> Task<anyhow::Result<T>>
100where
101 T: IntoEnumIterator + VariantNames + 'static,
102{
103 let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
104 cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
105}
106
107#[derive(strum::EnumIter, strum::VariantNames)]
108#[strum(serialize_all = "title_case")]
109enum TrashCancel {
110 Trash,
111 Cancel,
112}
113
114struct GitMenuState {
115 has_tracked_changes: bool,
116 has_staged_changes: bool,
117 has_unstaged_changes: bool,
118 has_new_changes: bool,
119}
120
121fn git_panel_context_menu(
122 focus_handle: FocusHandle,
123 state: GitMenuState,
124 window: &mut Window,
125 cx: &mut App,
126) -> Entity<ContextMenu> {
127 ContextMenu::build(window, cx, move |context_menu, _, _| {
128 context_menu
129 .context(focus_handle)
130 .action_disabled_when(
131 !state.has_unstaged_changes,
132 "Stage All",
133 StageAll.boxed_clone(),
134 )
135 .action_disabled_when(
136 !state.has_staged_changes,
137 "Unstage All",
138 UnstageAll.boxed_clone(),
139 )
140 .separator()
141 .action("Open Diff", project_diff::Diff.boxed_clone())
142 .separator()
143 .action_disabled_when(
144 !state.has_tracked_changes,
145 "Discard Tracked Changes",
146 RestoreTrackedFiles.boxed_clone(),
147 )
148 .action_disabled_when(
149 !state.has_new_changes,
150 "Trash Untracked Files",
151 TrashUntrackedFiles.boxed_clone(),
152 )
153 })
154}
155
156const GIT_PANEL_KEY: &str = "GitPanel";
157
158const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
159
160pub fn register(workspace: &mut Workspace) {
161 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
162 workspace.toggle_panel_focus::<GitPanel>(window, cx);
163 });
164 workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
165 CommitModal::toggle(workspace, None, window, cx)
166 });
167}
168
169#[derive(Debug, Clone)]
170pub enum Event {
171 Focus,
172}
173
174#[derive(Serialize, Deserialize)]
175struct SerializedGitPanel {
176 width: Option<Pixels>,
177 #[serde(default)]
178 amend_pending: bool,
179 #[serde(default)]
180 signoff_enabled: bool,
181}
182
183#[derive(Debug, PartialEq, Eq, Clone, Copy)]
184enum Section {
185 Conflict,
186 Tracked,
187 New,
188}
189
190#[derive(Debug, PartialEq, Eq, Clone)]
191struct GitHeaderEntry {
192 header: Section,
193}
194
195impl GitHeaderEntry {
196 pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
197 let this = &self.header;
198 let status = status_entry.status;
199 match this {
200 Section::Conflict => {
201 repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
202 }
203 Section::Tracked => !status.is_created(),
204 Section::New => status.is_created(),
205 }
206 }
207 pub fn title(&self) -> &'static str {
208 match self.header {
209 Section::Conflict => "Conflicts",
210 Section::Tracked => "Tracked",
211 Section::New => "Untracked",
212 }
213 }
214}
215
216#[derive(Debug, PartialEq, Eq, Clone)]
217enum GitListEntry {
218 Status(GitStatusEntry),
219 Header(GitHeaderEntry),
220}
221
222impl GitListEntry {
223 fn status_entry(&self) -> Option<&GitStatusEntry> {
224 match self {
225 GitListEntry::Status(entry) => Some(entry),
226 _ => None,
227 }
228 }
229}
230
231#[derive(Debug, PartialEq, Eq, Clone)]
232pub struct GitStatusEntry {
233 pub(crate) repo_path: RepoPath,
234 pub(crate) abs_path: PathBuf,
235 pub(crate) status: FileStatus,
236 pub(crate) staging: StageStatus,
237}
238
239impl GitStatusEntry {
240 fn display_name(&self) -> String {
241 self.repo_path
242 .file_name()
243 .map(|name| name.to_string_lossy().into_owned())
244 .unwrap_or_else(|| self.repo_path.to_string_lossy().into_owned())
245 }
246
247 fn parent_dir(&self) -> Option<String> {
248 self.repo_path
249 .parent()
250 .map(|parent| parent.to_string_lossy().into_owned())
251 }
252}
253
254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
255enum TargetStatus {
256 Staged,
257 Unstaged,
258 Reverted,
259 Unchanged,
260}
261
262struct PendingOperation {
263 finished: bool,
264 target_status: TargetStatus,
265 entries: Vec<GitStatusEntry>,
266 op_id: usize,
267}
268
269// computed state related to how to render scrollbars
270// one per axis
271// on render we just read this off the panel
272// we update it when
273// - settings change
274// - on focus in, on focus out, on hover, etc.
275#[derive(Debug)]
276struct ScrollbarProperties {
277 axis: Axis,
278 show_scrollbar: bool,
279 show_track: bool,
280 auto_hide: bool,
281 hide_task: Option<Task<()>>,
282 state: ScrollbarState,
283}
284
285impl ScrollbarProperties {
286 // Shows the scrollbar and cancels any pending hide task
287 fn show(&mut self, cx: &mut Context<GitPanel>) {
288 if !self.auto_hide {
289 return;
290 }
291 self.show_scrollbar = true;
292 self.hide_task.take();
293 cx.notify();
294 }
295
296 fn hide(&mut self, window: &mut Window, cx: &mut Context<GitPanel>) {
297 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
298
299 if !self.auto_hide {
300 return;
301 }
302
303 let axis = self.axis;
304 self.hide_task = Some(cx.spawn_in(window, async move |panel, cx| {
305 cx.background_executor()
306 .timer(SCROLLBAR_SHOW_INTERVAL)
307 .await;
308
309 if let Some(panel) = panel.upgrade() {
310 panel
311 .update(cx, |panel, cx| {
312 match axis {
313 Axis::Vertical => panel.vertical_scrollbar.show_scrollbar = false,
314 Axis::Horizontal => panel.horizontal_scrollbar.show_scrollbar = false,
315 }
316 cx.notify();
317 })
318 .log_err();
319 }
320 }));
321 }
322}
323
324pub struct GitPanel {
325 pub(crate) active_repository: Option<Entity<Repository>>,
326 pub(crate) commit_editor: Entity<Editor>,
327 conflicted_count: usize,
328 conflicted_staged_count: usize,
329 add_coauthors: bool,
330 generate_commit_message_task: Option<Task<Option<()>>>,
331 entries: Vec<GitListEntry>,
332 single_staged_entry: Option<GitStatusEntry>,
333 single_tracked_entry: Option<GitStatusEntry>,
334 focus_handle: FocusHandle,
335 fs: Arc<dyn Fs>,
336 horizontal_scrollbar: ScrollbarProperties,
337 vertical_scrollbar: ScrollbarProperties,
338 new_count: usize,
339 entry_count: usize,
340 new_staged_count: usize,
341 pending: Vec<PendingOperation>,
342 pending_commit: Option<Task<()>>,
343 amend_pending: bool,
344 signoff_enabled: bool,
345 pending_serialization: Task<()>,
346 pub(crate) project: Entity<Project>,
347 scroll_handle: UniformListScrollHandle,
348 max_width_item_index: Option<usize>,
349 selected_entry: Option<usize>,
350 marked_entries: Vec<usize>,
351 tracked_count: usize,
352 tracked_staged_count: usize,
353 update_visible_entries_task: Task<()>,
354 width: Option<Pixels>,
355 workspace: WeakEntity<Workspace>,
356 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
357 modal_open: bool,
358 show_placeholders: bool,
359 local_committer: Option<GitCommitter>,
360 local_committer_task: Option<Task<()>>,
361 bulk_staging: Option<BulkStaging>,
362 _settings_subscription: Subscription,
363}
364
365#[derive(Clone, Debug, PartialEq, Eq)]
366struct BulkStaging {
367 repo_id: RepositoryId,
368 anchor: RepoPath,
369}
370
371const MAX_PANEL_EDITOR_LINES: usize = 6;
372
373pub(crate) fn commit_message_editor(
374 commit_message_buffer: Entity<Buffer>,
375 placeholder: Option<SharedString>,
376 project: Entity<Project>,
377 in_panel: bool,
378 window: &mut Window,
379 cx: &mut Context<Editor>,
380) -> Editor {
381 let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
382 let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
383 let mut commit_editor = Editor::new(
384 EditorMode::AutoHeight {
385 min_lines: 1,
386 max_lines: Some(max_lines),
387 },
388 buffer,
389 None,
390 window,
391 cx,
392 );
393 commit_editor.set_collaboration_hub(Box::new(project));
394 commit_editor.set_use_autoclose(false);
395 commit_editor.set_show_gutter(false, cx);
396 commit_editor.set_use_modal_editing(true);
397 commit_editor.set_show_wrap_guides(false, cx);
398 commit_editor.set_show_indent_guides(false, cx);
399 let placeholder = placeholder.unwrap_or("Enter commit message".into());
400 commit_editor.set_placeholder_text(placeholder, cx);
401 commit_editor
402}
403
404impl GitPanel {
405 fn new(
406 workspace: &mut Workspace,
407 window: &mut Window,
408 cx: &mut Context<Workspace>,
409 ) -> Entity<Self> {
410 let project = workspace.project().clone();
411 let app_state = workspace.app_state().clone();
412 let fs = app_state.fs.clone();
413 let git_store = project.read(cx).git_store().clone();
414 let active_repository = project.read(cx).active_repository(cx);
415
416 let git_panel = cx.new(|cx| {
417 let focus_handle = cx.focus_handle();
418 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
419 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
420 this.hide_scrollbars(window, cx);
421 })
422 .detach();
423
424 let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
425 cx.observe_global::<SettingsStore>(move |this, cx| {
426 let is_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
427 if is_sort_by_path != was_sort_by_path {
428 this.update_visible_entries(cx);
429 }
430 was_sort_by_path = is_sort_by_path
431 })
432 .detach();
433
434 // just to let us render a placeholder editor.
435 // Once the active git repo is set, this buffer will be replaced.
436 let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
437 let commit_editor = cx.new(|cx| {
438 commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
439 });
440
441 commit_editor.update(cx, |editor, cx| {
442 editor.clear(window, cx);
443 });
444
445 let scroll_handle = UniformListScrollHandle::new();
446
447 let vertical_scrollbar = ScrollbarProperties {
448 axis: Axis::Vertical,
449 state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
450 show_scrollbar: false,
451 show_track: false,
452 auto_hide: false,
453 hide_task: None,
454 };
455
456 let horizontal_scrollbar = ScrollbarProperties {
457 axis: Axis::Horizontal,
458 state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
459 show_scrollbar: false,
460 show_track: false,
461 auto_hide: false,
462 hide_task: None,
463 };
464
465 let mut assistant_enabled = AgentSettings::get_global(cx).enabled;
466 let mut was_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
467 let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
468 let is_ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
469 if assistant_enabled != AgentSettings::get_global(cx).enabled
470 || was_ai_disabled != is_ai_disabled
471 {
472 assistant_enabled = AgentSettings::get_global(cx).enabled;
473 was_ai_disabled = is_ai_disabled;
474 cx.notify();
475 }
476 });
477
478 cx.subscribe_in(
479 &git_store,
480 window,
481 move |this, _git_store, event, window, cx| match event {
482 GitStoreEvent::ActiveRepositoryChanged(_) => {
483 this.active_repository = this.project.read(cx).active_repository(cx);
484 this.schedule_update(true, window, cx);
485 }
486 GitStoreEvent::RepositoryUpdated(
487 _,
488 RepositoryEvent::Updated { full_scan, .. },
489 true,
490 ) => {
491 this.schedule_update(*full_scan, window, cx);
492 }
493
494 GitStoreEvent::RepositoryAdded(_) | GitStoreEvent::RepositoryRemoved(_) => {
495 this.schedule_update(false, window, cx);
496 }
497 GitStoreEvent::IndexWriteError(error) => {
498 this.workspace
499 .update(cx, |workspace, cx| {
500 workspace.show_error(error, cx);
501 })
502 .ok();
503 }
504 GitStoreEvent::RepositoryUpdated(_, _, _) => {}
505 GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
506 },
507 )
508 .detach();
509
510 let mut this = Self {
511 active_repository,
512 commit_editor,
513 conflicted_count: 0,
514 conflicted_staged_count: 0,
515 add_coauthors: true,
516 generate_commit_message_task: None,
517 entries: Vec::new(),
518 focus_handle: cx.focus_handle(),
519 fs,
520 new_count: 0,
521 new_staged_count: 0,
522 pending: Vec::new(),
523 pending_commit: None,
524 amend_pending: false,
525 signoff_enabled: false,
526 pending_serialization: Task::ready(()),
527 single_staged_entry: None,
528 single_tracked_entry: None,
529 project,
530 scroll_handle,
531 max_width_item_index: None,
532 selected_entry: None,
533 marked_entries: Vec::new(),
534 tracked_count: 0,
535 tracked_staged_count: 0,
536 update_visible_entries_task: Task::ready(()),
537 width: None,
538 show_placeholders: false,
539 local_committer: None,
540 local_committer_task: None,
541 context_menu: None,
542 workspace: workspace.weak_handle(),
543 modal_open: false,
544 entry_count: 0,
545 horizontal_scrollbar,
546 vertical_scrollbar,
547 bulk_staging: None,
548 _settings_subscription,
549 };
550
551 this.schedule_update(false, window, cx);
552 this
553 });
554
555 git_panel
556 }
557
558 fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
559 self.horizontal_scrollbar.hide(window, cx);
560 self.vertical_scrollbar.hide(window, cx);
561 }
562
563 fn update_scrollbar_properties(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
564 // TODO: This PR should have defined Editor's `scrollbar.axis`
565 // as an Option<ScrollbarAxis>, not a ScrollbarAxes as it would allow you to
566 // `.unwrap_or(EditorSettings::get_global(cx).scrollbar.show)`.
567 //
568 // Once this is fixed we can extend the GitPanelSettings with a `scrollbar.axis`
569 // so we can show each axis based on the settings.
570 //
571 // We should fix this. PR: https://github.com/zed-industries/zed/pull/19495
572
573 let show_setting = GitPanelSettings::get_global(cx)
574 .scrollbar
575 .show
576 .unwrap_or(EditorSettings::get_global(cx).scrollbar.show);
577
578 let scroll_handle = self.scroll_handle.0.borrow();
579
580 let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
581 ShowScrollbar::Auto => true,
582 ShowScrollbar::System => cx
583 .try_global::<ScrollbarAutoHide>()
584 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
585 ShowScrollbar::Always => false,
586 ShowScrollbar::Never => false,
587 };
588
589 let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
590 (size.contents.width > size.item.width).then_some(size.contents.width)
591 });
592
593 // is there an item long enough that we should show a horizontal scrollbar?
594 let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
595 longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
596 } else {
597 true
598 };
599
600 let show_horizontal = match (show_setting, item_wider_than_container) {
601 (_, false) => false,
602 (ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always, true) => true,
603 (ShowScrollbar::Never, true) => false,
604 };
605
606 let show_vertical = match show_setting {
607 ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
608 ShowScrollbar::Never => false,
609 };
610
611 let show_horizontal_track =
612 show_horizontal && matches!(show_setting, ShowScrollbar::Always);
613
614 // TODO: we probably should hide the scroll track when the list doesn't need to scroll
615 let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
616
617 self.vertical_scrollbar = ScrollbarProperties {
618 axis: self.vertical_scrollbar.axis,
619 state: self.vertical_scrollbar.state.clone(),
620 show_scrollbar: show_vertical,
621 show_track: show_vertical_track,
622 auto_hide: autohide(show_setting, cx),
623 hide_task: None,
624 };
625
626 self.horizontal_scrollbar = ScrollbarProperties {
627 axis: self.horizontal_scrollbar.axis,
628 state: self.horizontal_scrollbar.state.clone(),
629 show_scrollbar: show_horizontal,
630 show_track: show_horizontal_track,
631 auto_hide: autohide(show_setting, cx),
632 hide_task: None,
633 };
634
635 cx.notify();
636 }
637
638 pub fn entry_by_path(&self, path: &RepoPath, cx: &App) -> Option<usize> {
639 if GitPanelSettings::get_global(cx).sort_by_path {
640 return self
641 .entries
642 .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
643 .ok();
644 }
645
646 if self.conflicted_count > 0 {
647 let conflicted_start = 1;
648 if let Ok(ix) = self.entries[conflicted_start..conflicted_start + self.conflicted_count]
649 .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
650 {
651 return Some(conflicted_start + ix);
652 }
653 }
654 if self.tracked_count > 0 {
655 let tracked_start = if self.conflicted_count > 0 {
656 1 + self.conflicted_count
657 } else {
658 0
659 } + 1;
660 if let Ok(ix) = self.entries[tracked_start..tracked_start + self.tracked_count]
661 .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
662 {
663 return Some(tracked_start + ix);
664 }
665 }
666 if self.new_count > 0 {
667 let untracked_start = if self.conflicted_count > 0 {
668 1 + self.conflicted_count
669 } else {
670 0
671 } + if self.tracked_count > 0 {
672 1 + self.tracked_count
673 } else {
674 0
675 } + 1;
676 if let Ok(ix) = self.entries[untracked_start..untracked_start + self.new_count]
677 .binary_search_by(|entry| entry.status_entry().unwrap().repo_path.cmp(&path))
678 {
679 return Some(untracked_start + ix);
680 }
681 }
682 None
683 }
684
685 pub fn select_entry_by_path(
686 &mut self,
687 path: ProjectPath,
688 _: &mut Window,
689 cx: &mut Context<Self>,
690 ) {
691 let Some(git_repo) = self.active_repository.as_ref() else {
692 return;
693 };
694 let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path, cx) else {
695 return;
696 };
697 let Some(ix) = self.entry_by_path(&repo_path, cx) else {
698 return;
699 };
700 self.selected_entry = Some(ix);
701 cx.notify();
702 }
703
704 fn serialization_key(workspace: &Workspace) -> Option<String> {
705 workspace
706 .database_id()
707 .map(|id| i64::from(id).to_string())
708 .or(workspace.session_id())
709 .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
710 }
711
712 fn serialize(&mut self, cx: &mut Context<Self>) {
713 let width = self.width;
714 let amend_pending = self.amend_pending;
715 let signoff_enabled = self.signoff_enabled;
716
717 self.pending_serialization = cx.spawn(async move |git_panel, cx| {
718 cx.background_executor()
719 .timer(SERIALIZATION_THROTTLE_TIME)
720 .await;
721 let Some(serialization_key) = git_panel
722 .update(cx, |git_panel, cx| {
723 git_panel
724 .workspace
725 .read_with(cx, |workspace, _| Self::serialization_key(workspace))
726 .ok()
727 .flatten()
728 })
729 .ok()
730 .flatten()
731 else {
732 return;
733 };
734 cx.background_spawn(
735 async move {
736 KEY_VALUE_STORE
737 .write_kvp(
738 serialization_key,
739 serde_json::to_string(&SerializedGitPanel {
740 width,
741 amend_pending,
742 signoff_enabled,
743 })?,
744 )
745 .await?;
746 anyhow::Ok(())
747 }
748 .log_err(),
749 )
750 .await;
751 });
752 }
753
754 pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
755 self.modal_open = open;
756 cx.notify();
757 }
758
759 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
760 let mut dispatch_context = KeyContext::new_with_defaults();
761 dispatch_context.add("GitPanel");
762
763 if window
764 .focused(cx)
765 .map_or(false, |focused| self.focus_handle == focused)
766 {
767 dispatch_context.add("menu");
768 dispatch_context.add("ChangesList");
769 }
770
771 if self.commit_editor.read(cx).is_focused(window) {
772 dispatch_context.add("CommitEditor");
773 }
774
775 dispatch_context
776 }
777
778 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
779 cx.emit(PanelEvent::Close);
780 }
781
782 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
783 if !self.focus_handle.contains_focused(window, cx) {
784 cx.emit(Event::Focus);
785 }
786 }
787
788 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
789 if let Some(selected_entry) = self.selected_entry {
790 self.scroll_handle
791 .scroll_to_item(selected_entry, ScrollStrategy::Center);
792 }
793
794 cx.notify();
795 }
796
797 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
798 if !self.entries.is_empty() {
799 self.selected_entry = Some(1);
800 self.scroll_to_selected_entry(cx);
801 }
802 }
803
804 fn select_previous(
805 &mut self,
806 _: &SelectPrevious,
807 _window: &mut Window,
808 cx: &mut Context<Self>,
809 ) {
810 let item_count = self.entries.len();
811 if item_count == 0 {
812 return;
813 }
814
815 if let Some(selected_entry) = self.selected_entry {
816 let new_selected_entry = if selected_entry > 0 {
817 selected_entry - 1
818 } else {
819 selected_entry
820 };
821
822 if matches!(
823 self.entries.get(new_selected_entry),
824 Some(GitListEntry::Header(..))
825 ) {
826 if new_selected_entry > 0 {
827 self.selected_entry = Some(new_selected_entry - 1)
828 }
829 } else {
830 self.selected_entry = Some(new_selected_entry);
831 }
832
833 self.scroll_to_selected_entry(cx);
834 }
835
836 cx.notify();
837 }
838
839 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
840 let item_count = self.entries.len();
841 if item_count == 0 {
842 return;
843 }
844
845 if let Some(selected_entry) = self.selected_entry {
846 let new_selected_entry = if selected_entry < item_count - 1 {
847 selected_entry + 1
848 } else {
849 selected_entry
850 };
851 if matches!(
852 self.entries.get(new_selected_entry),
853 Some(GitListEntry::Header(..))
854 ) {
855 self.selected_entry = Some(new_selected_entry + 1);
856 } else {
857 self.selected_entry = Some(new_selected_entry);
858 }
859
860 self.scroll_to_selected_entry(cx);
861 }
862
863 cx.notify();
864 }
865
866 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
867 if self.entries.last().is_some() {
868 self.selected_entry = Some(self.entries.len() - 1);
869 self.scroll_to_selected_entry(cx);
870 }
871 }
872
873 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
874 self.commit_editor.update(cx, |editor, cx| {
875 window.focus(&editor.focus_handle(cx));
876 });
877 cx.notify();
878 }
879
880 fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
881 let have_entries = self
882 .active_repository
883 .as_ref()
884 .map_or(false, |active_repository| {
885 active_repository.read(cx).status_summary().count > 0
886 });
887 if have_entries && self.selected_entry.is_none() {
888 self.selected_entry = Some(1);
889 self.scroll_to_selected_entry(cx);
890 cx.notify();
891 }
892 }
893
894 fn focus_changes_list(
895 &mut self,
896 _: &FocusChanges,
897 window: &mut Window,
898 cx: &mut Context<Self>,
899 ) {
900 self.select_first_entry_if_none(cx);
901
902 cx.focus_self(window);
903 cx.notify();
904 }
905
906 fn get_selected_entry(&self) -> Option<&GitListEntry> {
907 self.selected_entry.and_then(|i| self.entries.get(i))
908 }
909
910 fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
911 maybe!({
912 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
913 let workspace = self.workspace.upgrade()?;
914 let git_repo = self.active_repository.as_ref()?;
915
916 if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx) {
917 if let Some(project_path) = project_diff.read(cx).active_path(cx) {
918 if Some(&entry.repo_path)
919 == git_repo
920 .read(cx)
921 .project_path_to_repo_path(&project_path, cx)
922 .as_ref()
923 {
924 project_diff.focus_handle(cx).focus(window);
925 project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
926 return None;
927 }
928 }
929 };
930
931 self.workspace
932 .update(cx, |workspace, cx| {
933 ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
934 })
935 .ok();
936 self.focus_handle.focus(window);
937
938 Some(())
939 });
940 }
941
942 fn open_file(
943 &mut self,
944 _: &menu::SecondaryConfirm,
945 window: &mut Window,
946 cx: &mut Context<Self>,
947 ) {
948 maybe!({
949 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
950 let active_repo = self.active_repository.as_ref()?;
951 let path = active_repo
952 .read(cx)
953 .repo_path_to_project_path(&entry.repo_path, cx)?;
954 if entry.status.is_deleted() {
955 return None;
956 }
957
958 self.workspace
959 .update(cx, |workspace, cx| {
960 workspace
961 .open_path_preview(path, None, false, false, true, window, cx)
962 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
963 Some(format!("{e}"))
964 });
965 })
966 .ok()
967 });
968 }
969
970 fn revert_selected(
971 &mut self,
972 action: &git::RestoreFile,
973 window: &mut Window,
974 cx: &mut Context<Self>,
975 ) {
976 maybe!({
977 let list_entry = self.entries.get(self.selected_entry?)?.clone();
978 let entry = list_entry.status_entry()?.to_owned();
979 let skip_prompt = action.skip_prompt || entry.status.is_created();
980
981 let prompt = if skip_prompt {
982 Task::ready(Ok(0))
983 } else {
984 let prompt = window.prompt(
985 PromptLevel::Warning,
986 &format!(
987 "Are you sure you want to restore {}?",
988 entry
989 .repo_path
990 .file_name()
991 .unwrap_or(entry.repo_path.as_os_str())
992 .to_string_lossy()
993 ),
994 None,
995 &["Restore", "Cancel"],
996 cx,
997 );
998 cx.background_spawn(prompt)
999 };
1000
1001 let this = cx.weak_entity();
1002 window
1003 .spawn(cx, async move |cx| {
1004 if prompt.await? != 0 {
1005 return anyhow::Ok(());
1006 }
1007
1008 this.update_in(cx, |this, window, cx| {
1009 this.revert_entry(&entry, window, cx);
1010 })?;
1011
1012 Ok(())
1013 })
1014 .detach();
1015 Some(())
1016 });
1017 }
1018
1019 fn revert_entry(
1020 &mut self,
1021 entry: &GitStatusEntry,
1022 window: &mut Window,
1023 cx: &mut Context<Self>,
1024 ) {
1025 maybe!({
1026 let active_repo = self.active_repository.clone()?;
1027 let path = active_repo
1028 .read(cx)
1029 .repo_path_to_project_path(&entry.repo_path, cx)?;
1030 let workspace = self.workspace.clone();
1031
1032 if entry.status.staging().has_staged() {
1033 self.change_file_stage(false, vec![entry.clone()], cx);
1034 }
1035 let filename = path.path.file_name()?.to_string_lossy();
1036
1037 if !entry.status.is_created() {
1038 self.perform_checkout(vec![entry.clone()], cx);
1039 } else {
1040 let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
1041 cx.spawn_in(window, async move |_, cx| {
1042 match prompt.await? {
1043 TrashCancel::Trash => {}
1044 TrashCancel::Cancel => return Ok(()),
1045 }
1046 let task = workspace.update(cx, |workspace, cx| {
1047 workspace
1048 .project()
1049 .update(cx, |project, cx| project.delete_file(path, true, cx))
1050 })?;
1051 if let Some(task) = task {
1052 task.await?;
1053 }
1054 Ok(())
1055 })
1056 .detach_and_prompt_err(
1057 "Failed to trash file",
1058 window,
1059 cx,
1060 |e, _, _| Some(format!("{e}")),
1061 );
1062 }
1063 Some(())
1064 });
1065 }
1066
1067 fn perform_checkout(&mut self, entries: Vec<GitStatusEntry>, cx: &mut Context<Self>) {
1068 let workspace = self.workspace.clone();
1069 let Some(active_repository) = self.active_repository.clone() else {
1070 return;
1071 };
1072
1073 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1074 self.pending.push(PendingOperation {
1075 op_id,
1076 target_status: TargetStatus::Reverted,
1077 entries: entries.clone(),
1078 finished: false,
1079 });
1080 self.update_visible_entries(cx);
1081 let task = cx.spawn(async move |_, cx| {
1082 let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
1083 workspace.project().update(cx, |project, cx| {
1084 entries
1085 .iter()
1086 .filter_map(|entry| {
1087 let path = active_repository
1088 .read(cx)
1089 .repo_path_to_project_path(&entry.repo_path, cx)?;
1090 Some(project.open_buffer(path, cx))
1091 })
1092 .collect()
1093 })
1094 })?;
1095
1096 let buffers = futures::future::join_all(tasks).await;
1097
1098 active_repository
1099 .update(cx, |repo, cx| {
1100 repo.checkout_files(
1101 "HEAD",
1102 entries
1103 .into_iter()
1104 .map(|entries| entries.repo_path)
1105 .collect(),
1106 cx,
1107 )
1108 })?
1109 .await??;
1110
1111 let tasks: Vec<_> = cx.update(|cx| {
1112 buffers
1113 .iter()
1114 .filter_map(|buffer| {
1115 buffer.as_ref().ok()?.update(cx, |buffer, cx| {
1116 buffer.is_dirty().then(|| buffer.reload(cx))
1117 })
1118 })
1119 .collect()
1120 })?;
1121
1122 futures::future::join_all(tasks).await;
1123
1124 Ok(())
1125 });
1126
1127 cx.spawn(async move |this, cx| {
1128 let result = task.await;
1129
1130 this.update(cx, |this, cx| {
1131 for pending in this.pending.iter_mut() {
1132 if pending.op_id == op_id {
1133 pending.finished = true;
1134 if result.is_err() {
1135 pending.target_status = TargetStatus::Unchanged;
1136 this.update_visible_entries(cx);
1137 }
1138 break;
1139 }
1140 }
1141 result
1142 .map_err(|e| {
1143 this.show_error_toast("checkout", e, cx);
1144 })
1145 .ok();
1146 })
1147 .ok();
1148 })
1149 .detach();
1150 }
1151
1152 fn restore_tracked_files(
1153 &mut self,
1154 _: &RestoreTrackedFiles,
1155 window: &mut Window,
1156 cx: &mut Context<Self>,
1157 ) {
1158 let entries = self
1159 .entries
1160 .iter()
1161 .filter_map(|entry| entry.status_entry().cloned())
1162 .filter(|status_entry| !status_entry.status.is_created())
1163 .collect::<Vec<_>>();
1164
1165 match entries.len() {
1166 0 => return,
1167 1 => return self.revert_entry(&entries[0], window, cx),
1168 _ => {}
1169 }
1170 let mut details = entries
1171 .iter()
1172 .filter_map(|entry| entry.repo_path.0.file_name())
1173 .map(|filename| filename.to_string_lossy())
1174 .take(5)
1175 .join("\n");
1176 if entries.len() > 5 {
1177 details.push_str(&format!("\nand {} more…", entries.len() - 5))
1178 }
1179
1180 #[derive(strum::EnumIter, strum::VariantNames)]
1181 #[strum(serialize_all = "title_case")]
1182 enum RestoreCancel {
1183 RestoreTrackedFiles,
1184 Cancel,
1185 }
1186 let prompt = prompt(
1187 "Discard changes to these files?",
1188 Some(&details),
1189 window,
1190 cx,
1191 );
1192 cx.spawn(async move |this, cx| match prompt.await {
1193 Ok(RestoreCancel::RestoreTrackedFiles) => {
1194 this.update(cx, |this, cx| {
1195 this.perform_checkout(entries, cx);
1196 })
1197 .ok();
1198 }
1199 _ => {
1200 return;
1201 }
1202 })
1203 .detach();
1204 }
1205
1206 fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
1207 let workspace = self.workspace.clone();
1208 let Some(active_repo) = self.active_repository.clone() else {
1209 return;
1210 };
1211 let to_delete = self
1212 .entries
1213 .iter()
1214 .filter_map(|entry| entry.status_entry())
1215 .filter(|status_entry| status_entry.status.is_created())
1216 .cloned()
1217 .collect::<Vec<_>>();
1218
1219 match to_delete.len() {
1220 0 => return,
1221 1 => return self.revert_entry(&to_delete[0], window, cx),
1222 _ => {}
1223 };
1224
1225 let mut details = to_delete
1226 .iter()
1227 .map(|entry| {
1228 entry
1229 .repo_path
1230 .0
1231 .file_name()
1232 .map(|f| f.to_string_lossy())
1233 .unwrap_or_default()
1234 })
1235 .take(5)
1236 .join("\n");
1237
1238 if to_delete.len() > 5 {
1239 details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
1240 }
1241
1242 let prompt = prompt("Trash these files?", Some(&details), window, cx);
1243 cx.spawn_in(window, async move |this, cx| {
1244 match prompt.await? {
1245 TrashCancel::Trash => {}
1246 TrashCancel::Cancel => return Ok(()),
1247 }
1248 let tasks = workspace.update(cx, |workspace, cx| {
1249 to_delete
1250 .iter()
1251 .filter_map(|entry| {
1252 workspace.project().update(cx, |project, cx| {
1253 let project_path = active_repo
1254 .read(cx)
1255 .repo_path_to_project_path(&entry.repo_path, cx)?;
1256 project.delete_file(project_path, true, cx)
1257 })
1258 })
1259 .collect::<Vec<_>>()
1260 })?;
1261 let to_unstage = to_delete
1262 .into_iter()
1263 .filter(|entry| !entry.status.staging().is_fully_unstaged())
1264 .collect();
1265 this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
1266 for task in tasks {
1267 task.await?;
1268 }
1269 Ok(())
1270 })
1271 .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1272 Some(format!("{e}"))
1273 });
1274 }
1275
1276 pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1277 let entries = self
1278 .entries
1279 .iter()
1280 .filter_map(|entry| entry.status_entry())
1281 .filter(|status_entry| status_entry.staging.has_unstaged())
1282 .cloned()
1283 .collect::<Vec<_>>();
1284 self.change_file_stage(true, entries, cx);
1285 }
1286
1287 pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1288 let entries = self
1289 .entries
1290 .iter()
1291 .filter_map(|entry| entry.status_entry())
1292 .filter(|status_entry| status_entry.staging.has_staged())
1293 .cloned()
1294 .collect::<Vec<_>>();
1295 self.change_file_stage(false, entries, cx);
1296 }
1297
1298 fn toggle_staged_for_entry(
1299 &mut self,
1300 entry: &GitListEntry,
1301 _window: &mut Window,
1302 cx: &mut Context<Self>,
1303 ) {
1304 let Some(active_repository) = self.active_repository.as_ref() else {
1305 return;
1306 };
1307 let (stage, repo_paths) = match entry {
1308 GitListEntry::Status(status_entry) => {
1309 if status_entry.status.staging().is_fully_staged() {
1310 if let Some(op) = self.bulk_staging.clone()
1311 && op.anchor == status_entry.repo_path
1312 {
1313 self.bulk_staging = None;
1314 }
1315
1316 (false, vec![status_entry.clone()])
1317 } else {
1318 self.set_bulk_staging_anchor(status_entry.repo_path.clone(), cx);
1319
1320 (true, vec![status_entry.clone()])
1321 }
1322 }
1323 GitListEntry::Header(section) => {
1324 let goal_staged_state = !self.header_state(section.header).selected();
1325 let repository = active_repository.read(cx);
1326 let entries = self
1327 .entries
1328 .iter()
1329 .filter_map(|entry| entry.status_entry())
1330 .filter(|status_entry| {
1331 section.contains(&status_entry, repository)
1332 && status_entry.staging.as_bool() != Some(goal_staged_state)
1333 })
1334 .map(|status_entry| status_entry.clone())
1335 .collect::<Vec<_>>();
1336
1337 (goal_staged_state, entries)
1338 }
1339 };
1340 self.change_file_stage(stage, repo_paths, cx);
1341 }
1342
1343 fn change_file_stage(
1344 &mut self,
1345 stage: bool,
1346 entries: Vec<GitStatusEntry>,
1347 cx: &mut Context<Self>,
1348 ) {
1349 let Some(active_repository) = self.active_repository.clone() else {
1350 return;
1351 };
1352 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1353 self.pending.push(PendingOperation {
1354 op_id,
1355 target_status: if stage {
1356 TargetStatus::Staged
1357 } else {
1358 TargetStatus::Unstaged
1359 },
1360 entries: entries.clone(),
1361 finished: false,
1362 });
1363 let repository = active_repository.read(cx);
1364 self.update_counts(repository);
1365 cx.notify();
1366
1367 cx.spawn({
1368 async move |this, cx| {
1369 let result = cx
1370 .update(|cx| {
1371 if stage {
1372 active_repository.update(cx, |repo, cx| {
1373 let repo_paths = entries
1374 .iter()
1375 .map(|entry| entry.repo_path.clone())
1376 .collect();
1377 repo.stage_entries(repo_paths, cx)
1378 })
1379 } else {
1380 active_repository.update(cx, |repo, cx| {
1381 let repo_paths = entries
1382 .iter()
1383 .map(|entry| entry.repo_path.clone())
1384 .collect();
1385 repo.unstage_entries(repo_paths, cx)
1386 })
1387 }
1388 })?
1389 .await;
1390
1391 this.update(cx, |this, cx| {
1392 for pending in this.pending.iter_mut() {
1393 if pending.op_id == op_id {
1394 pending.finished = true
1395 }
1396 }
1397 result
1398 .map_err(|e| {
1399 this.show_error_toast(if stage { "add" } else { "reset" }, e, cx);
1400 })
1401 .ok();
1402 cx.notify();
1403 })
1404 }
1405 })
1406 .detach();
1407 }
1408
1409 pub fn total_staged_count(&self) -> usize {
1410 self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1411 }
1412
1413 pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1414 self.commit_editor
1415 .read(cx)
1416 .buffer()
1417 .read(cx)
1418 .as_singleton()
1419 .unwrap()
1420 .clone()
1421 }
1422
1423 fn toggle_staged_for_selected(
1424 &mut self,
1425 _: &git::ToggleStaged,
1426 window: &mut Window,
1427 cx: &mut Context<Self>,
1428 ) {
1429 if let Some(selected_entry) = self.get_selected_entry().cloned() {
1430 self.toggle_staged_for_entry(&selected_entry, window, cx);
1431 }
1432 }
1433
1434 fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
1435 let Some(index) = self.selected_entry else {
1436 return;
1437 };
1438 self.stage_bulk(index, cx);
1439 }
1440
1441 fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
1442 let Some(selected_entry) = self.get_selected_entry() else {
1443 return;
1444 };
1445 let Some(status_entry) = selected_entry.status_entry() else {
1446 return;
1447 };
1448 if status_entry.staging != StageStatus::Staged {
1449 self.change_file_stage(true, vec![status_entry.clone()], cx);
1450 }
1451 }
1452
1453 fn unstage_selected(
1454 &mut self,
1455 _: &git::UnstageFile,
1456 _window: &mut Window,
1457 cx: &mut Context<Self>,
1458 ) {
1459 let Some(selected_entry) = self.get_selected_entry() else {
1460 return;
1461 };
1462 let Some(status_entry) = selected_entry.status_entry() else {
1463 return;
1464 };
1465 if status_entry.staging != StageStatus::Unstaged {
1466 self.change_file_stage(false, vec![status_entry.clone()], cx);
1467 }
1468 }
1469
1470 fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1471 if self.amend_pending {
1472 return;
1473 }
1474 if self
1475 .commit_editor
1476 .focus_handle(cx)
1477 .contains_focused(window, cx)
1478 {
1479 telemetry::event!("Git Committed", source = "Git Panel");
1480 self.commit_changes(
1481 CommitOptions {
1482 amend: false,
1483 signoff: self.signoff_enabled,
1484 },
1485 window,
1486 cx,
1487 )
1488 } else {
1489 cx.propagate();
1490 }
1491 }
1492
1493 fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
1494 if self
1495 .commit_editor
1496 .focus_handle(cx)
1497 .contains_focused(window, cx)
1498 {
1499 if self.head_commit(cx).is_some() {
1500 if !self.amend_pending {
1501 self.set_amend_pending(true, cx);
1502 self.load_last_commit_message_if_empty(cx);
1503 } else {
1504 telemetry::event!("Git Amended", source = "Git Panel");
1505 self.set_amend_pending(false, cx);
1506 self.commit_changes(
1507 CommitOptions {
1508 amend: true,
1509 signoff: self.signoff_enabled,
1510 },
1511 window,
1512 cx,
1513 );
1514 }
1515 }
1516 } else {
1517 cx.propagate();
1518 }
1519 }
1520
1521 pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
1522 self.active_repository
1523 .as_ref()
1524 .and_then(|repo| repo.read(cx).head_commit.as_ref())
1525 .cloned()
1526 }
1527
1528 pub fn load_last_commit_message_if_empty(&mut self, cx: &mut Context<Self>) {
1529 if !self.commit_editor.read(cx).is_empty(cx) {
1530 return;
1531 }
1532 let Some(head_commit) = self.head_commit(cx) else {
1533 return;
1534 };
1535 let recent_sha = head_commit.sha.to_string();
1536 let detail_task = self.load_commit_details(recent_sha, cx);
1537 cx.spawn(async move |this, cx| {
1538 if let Ok(message) = detail_task.await.map(|detail| detail.message) {
1539 this.update(cx, |this, cx| {
1540 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1541 let start = buffer.anchor_before(0);
1542 let end = buffer.anchor_after(buffer.len());
1543 buffer.edit([(start..end, message)], None, cx);
1544 });
1545 })
1546 .log_err();
1547 }
1548 })
1549 .detach();
1550 }
1551
1552 fn custom_or_suggested_commit_message(
1553 &self,
1554 window: &mut Window,
1555 cx: &mut Context<Self>,
1556 ) -> Option<String> {
1557 let git_commit_language = self.commit_editor.read(cx).language_at(0, cx);
1558 let message = self.commit_editor.read(cx).text(cx);
1559 if message.is_empty() {
1560 return self
1561 .suggest_commit_message(cx)
1562 .filter(|message| !message.trim().is_empty());
1563 } else if message.trim().is_empty() {
1564 return None;
1565 }
1566 let buffer = cx.new(|cx| {
1567 let mut buffer = Buffer::local(message, cx);
1568 buffer.set_language(git_commit_language, cx);
1569 buffer
1570 });
1571 let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
1572 let wrapped_message = editor.update(cx, |editor, cx| {
1573 editor.select_all(&Default::default(), window, cx);
1574 editor.rewrap(&Default::default(), window, cx);
1575 editor.text(cx)
1576 });
1577 if wrapped_message.trim().is_empty() {
1578 return None;
1579 }
1580 Some(wrapped_message)
1581 }
1582
1583 fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
1584 let text = self.commit_editor.read(cx).text(cx);
1585 if !text.trim().is_empty() {
1586 return true;
1587 } else if text.is_empty() {
1588 return self
1589 .suggest_commit_message(cx)
1590 .is_some_and(|text| !text.trim().is_empty());
1591 } else {
1592 return false;
1593 }
1594 }
1595
1596 pub(crate) fn commit_changes(
1597 &mut self,
1598 options: CommitOptions,
1599 window: &mut Window,
1600 cx: &mut Context<Self>,
1601 ) {
1602 let Some(active_repository) = self.active_repository.clone() else {
1603 return;
1604 };
1605 let error_spawn = |message, window: &mut Window, cx: &mut App| {
1606 let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1607 cx.spawn(async move |_| {
1608 prompt.await.ok();
1609 })
1610 .detach();
1611 };
1612
1613 if self.has_unstaged_conflicts() {
1614 error_spawn(
1615 "There are still conflicts. You must stage these before committing",
1616 window,
1617 cx,
1618 );
1619 return;
1620 }
1621
1622 let commit_message = self.custom_or_suggested_commit_message(window, cx);
1623
1624 let Some(mut message) = commit_message else {
1625 self.commit_editor.read(cx).focus_handle(cx).focus(window);
1626 return;
1627 };
1628
1629 if self.add_coauthors {
1630 self.fill_co_authors(&mut message, cx);
1631 }
1632
1633 let task = if self.has_staged_changes() {
1634 // Repository serializes all git operations, so we can just send a commit immediately
1635 let commit_task = active_repository.update(cx, |repo, cx| {
1636 repo.commit(message.into(), None, options, cx)
1637 });
1638 cx.background_spawn(async move { commit_task.await? })
1639 } else {
1640 let changed_files = self
1641 .entries
1642 .iter()
1643 .filter_map(|entry| entry.status_entry())
1644 .filter(|status_entry| !status_entry.status.is_created())
1645 .map(|status_entry| status_entry.repo_path.clone())
1646 .collect::<Vec<_>>();
1647
1648 if changed_files.is_empty() {
1649 error_spawn("No changes to commit", window, cx);
1650 return;
1651 }
1652
1653 let stage_task =
1654 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1655 cx.spawn(async move |_, cx| {
1656 stage_task.await?;
1657 let commit_task = active_repository.update(cx, |repo, cx| {
1658 repo.commit(message.into(), None, options, cx)
1659 })?;
1660 commit_task.await?
1661 })
1662 };
1663 let task = cx.spawn_in(window, async move |this, cx| {
1664 let result = task.await;
1665 this.update_in(cx, |this, window, cx| {
1666 this.pending_commit.take();
1667 match result {
1668 Ok(()) => {
1669 this.commit_editor
1670 .update(cx, |editor, cx| editor.clear(window, cx));
1671 }
1672 Err(e) => this.show_error_toast("commit", e, cx),
1673 }
1674 })
1675 .ok();
1676 });
1677
1678 self.pending_commit = Some(task);
1679 }
1680
1681 fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1682 let Some(repo) = self.active_repository.clone() else {
1683 return;
1684 };
1685 telemetry::event!("Git Uncommitted");
1686
1687 let confirmation = self.check_for_pushed_commits(window, cx);
1688 let prior_head = self.load_commit_details("HEAD".to_string(), cx);
1689
1690 let task = cx.spawn_in(window, async move |this, cx| {
1691 let result = maybe!(async {
1692 if let Ok(true) = confirmation.await {
1693 let prior_head = prior_head.await?;
1694
1695 repo.update(cx, |repo, cx| {
1696 repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
1697 })?
1698 .await??;
1699
1700 Ok(Some(prior_head))
1701 } else {
1702 Ok(None)
1703 }
1704 })
1705 .await;
1706
1707 this.update_in(cx, |this, window, cx| {
1708 this.pending_commit.take();
1709 match result {
1710 Ok(None) => {}
1711 Ok(Some(prior_commit)) => {
1712 this.commit_editor.update(cx, |editor, cx| {
1713 editor.set_text(prior_commit.message, window, cx)
1714 });
1715 }
1716 Err(e) => this.show_error_toast("reset", e, cx),
1717 }
1718 })
1719 .ok();
1720 });
1721
1722 self.pending_commit = Some(task);
1723 }
1724
1725 fn check_for_pushed_commits(
1726 &mut self,
1727 window: &mut Window,
1728 cx: &mut Context<Self>,
1729 ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
1730 let repo = self.active_repository.clone();
1731 let mut cx = window.to_async(cx);
1732
1733 async move {
1734 let repo = repo.context("No active repository")?;
1735
1736 let pushed_to: Vec<SharedString> = repo
1737 .update(&mut cx, |repo, _| repo.check_for_pushed_commits())?
1738 .await??;
1739
1740 if pushed_to.is_empty() {
1741 Ok(true)
1742 } else {
1743 #[derive(strum::EnumIter, strum::VariantNames)]
1744 #[strum(serialize_all = "title_case")]
1745 enum CancelUncommit {
1746 Uncommit,
1747 Cancel,
1748 }
1749 let detail = format!(
1750 "This commit was already pushed to {}.",
1751 pushed_to.into_iter().join(", ")
1752 );
1753 let result = cx
1754 .update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
1755 .await?;
1756
1757 match result {
1758 CancelUncommit::Cancel => Ok(false),
1759 CancelUncommit::Uncommit => Ok(true),
1760 }
1761 }
1762 }
1763 }
1764
1765 /// Suggests a commit message based on the changed files and their statuses
1766 pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
1767 if let Some(merge_message) = self
1768 .active_repository
1769 .as_ref()
1770 .and_then(|repo| repo.read(cx).merge.message.as_ref())
1771 {
1772 return Some(merge_message.to_string());
1773 }
1774
1775 let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
1776 Some(staged_entry)
1777 } else if let Some(single_tracked_entry) = &self.single_tracked_entry {
1778 Some(single_tracked_entry)
1779 } else {
1780 None
1781 }?;
1782
1783 let action_text = if git_status_entry.status.is_deleted() {
1784 Some("Delete")
1785 } else if git_status_entry.status.is_created() {
1786 Some("Create")
1787 } else if git_status_entry.status.is_modified() {
1788 Some("Update")
1789 } else {
1790 None
1791 }?;
1792
1793 let file_name = git_status_entry
1794 .repo_path
1795 .file_name()
1796 .unwrap_or_default()
1797 .to_string_lossy();
1798
1799 Some(format!("{} {}", action_text, file_name))
1800 }
1801
1802 fn generate_commit_message_action(
1803 &mut self,
1804 _: &git::GenerateCommitMessage,
1805 _window: &mut Window,
1806 cx: &mut Context<Self>,
1807 ) {
1808 self.generate_commit_message(cx);
1809 }
1810
1811 /// Generates a commit message using an LLM.
1812 pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
1813 if !self.can_commit() || DisableAiSettings::get_global(cx).disable_ai {
1814 return;
1815 }
1816
1817 let model = match current_language_model(cx) {
1818 Some(value) => value,
1819 None => return,
1820 };
1821
1822 let Some(repo) = self.active_repository.as_ref() else {
1823 return;
1824 };
1825
1826 telemetry::event!("Git Commit Message Generated");
1827
1828 let diff = repo.update(cx, |repo, cx| {
1829 if self.has_staged_changes() {
1830 repo.diff(DiffType::HeadToIndex, cx)
1831 } else {
1832 repo.diff(DiffType::HeadToWorktree, cx)
1833 }
1834 });
1835
1836 let temperature = AgentSettings::temperature_for_model(&model, cx);
1837
1838 self.generate_commit_message_task = Some(cx.spawn(async move |this, cx| {
1839 async move {
1840 let _defer = cx.on_drop(&this, |this, _cx| {
1841 this.generate_commit_message_task.take();
1842 });
1843
1844 let mut diff_text = match diff.await {
1845 Ok(result) => match result {
1846 Ok(text) => text,
1847 Err(e) => {
1848 Self::show_commit_message_error(&this, &e, cx);
1849 return anyhow::Ok(());
1850 }
1851 },
1852 Err(e) => {
1853 Self::show_commit_message_error(&this, &e, cx);
1854 return anyhow::Ok(());
1855 }
1856 };
1857
1858 const ONE_MB: usize = 1_000_000;
1859 if diff_text.len() > ONE_MB {
1860 diff_text = diff_text.chars().take(ONE_MB).collect()
1861 }
1862
1863 let subject = this.update(cx, |this, cx| {
1864 this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
1865 })?;
1866
1867 let text_empty = subject.trim().is_empty();
1868
1869 let content = if text_empty {
1870 format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}")
1871 } else {
1872 format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n")
1873 };
1874
1875 const PROMPT: &str = include_str!("commit_message_prompt.txt");
1876
1877 let request = LanguageModelRequest {
1878 thread_id: None,
1879 prompt_id: None,
1880 intent: Some(CompletionIntent::GenerateGitCommitMessage),
1881 mode: None,
1882 messages: vec![LanguageModelRequestMessage {
1883 role: Role::User,
1884 content: vec![content.into()],
1885 cache: false,
1886 }],
1887 tools: Vec::new(),
1888 tool_choice: None,
1889 stop: Vec::new(),
1890 temperature,
1891 thinking_allowed: false,
1892 };
1893
1894 let stream = model.stream_completion_text(request, &cx);
1895 match stream.await {
1896 Ok(mut messages) => {
1897 if !text_empty {
1898 this.update(cx, |this, cx| {
1899 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1900 let insert_position = buffer.anchor_before(buffer.len());
1901 buffer.edit([(insert_position..insert_position, "\n")], None, cx)
1902 });
1903 })?;
1904 }
1905
1906 while let Some(message) = messages.stream.next().await {
1907 match message {
1908 Ok(text) => {
1909 this.update(cx, |this, cx| {
1910 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
1911 let insert_position = buffer.anchor_before(buffer.len());
1912 buffer.edit([(insert_position..insert_position, text)], None, cx);
1913 });
1914 })?;
1915 }
1916 Err(e) => {
1917 Self::show_commit_message_error(&this, &e, cx);
1918 break;
1919 }
1920 }
1921 }
1922 }
1923 Err(e) => {
1924 Self::show_commit_message_error(&this, &e, cx);
1925 }
1926 }
1927
1928 anyhow::Ok(())
1929 }
1930 .log_err().await
1931 }));
1932 }
1933
1934 fn get_fetch_options(
1935 &self,
1936 window: &mut Window,
1937 cx: &mut Context<Self>,
1938 ) -> Task<Option<FetchOptions>> {
1939 let repo = self.active_repository.clone();
1940 let workspace = self.workspace.clone();
1941
1942 cx.spawn_in(window, async move |_, cx| {
1943 let repo = repo?;
1944 let remotes = repo
1945 .update(cx, |repo, _| repo.get_remotes(None))
1946 .ok()?
1947 .await
1948 .ok()?
1949 .log_err()?;
1950
1951 let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
1952 if remotes.len() > 1 {
1953 remotes.push(FetchOptions::All);
1954 }
1955 let selection = cx
1956 .update(|window, cx| {
1957 picker_prompt::prompt(
1958 "Pick which remote to fetch",
1959 remotes.iter().map(|r| r.name()).collect(),
1960 workspace,
1961 window,
1962 cx,
1963 )
1964 })
1965 .ok()?
1966 .await?;
1967 remotes.get(selection).cloned()
1968 })
1969 }
1970
1971 pub(crate) fn fetch(
1972 &mut self,
1973 is_fetch_all: bool,
1974 window: &mut Window,
1975 cx: &mut Context<Self>,
1976 ) {
1977 if !self.can_push_and_pull(cx) {
1978 return;
1979 }
1980
1981 let Some(repo) = self.active_repository.clone() else {
1982 return;
1983 };
1984 telemetry::event!("Git Fetched");
1985 let askpass = self.askpass_delegate("git fetch", window, cx);
1986 let this = cx.weak_entity();
1987
1988 let fetch_options = if is_fetch_all {
1989 Task::ready(Some(FetchOptions::All))
1990 } else {
1991 self.get_fetch_options(window, cx)
1992 };
1993
1994 window
1995 .spawn(cx, async move |cx| {
1996 let Some(fetch_options) = fetch_options.await else {
1997 return Ok(());
1998 };
1999 let fetch = repo.update(cx, |repo, cx| {
2000 repo.fetch(fetch_options.clone(), askpass, cx)
2001 })?;
2002
2003 let remote_message = fetch.await?;
2004 this.update(cx, |this, cx| {
2005 let action = match fetch_options {
2006 FetchOptions::All => RemoteAction::Fetch(None),
2007 FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
2008 };
2009 match remote_message {
2010 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2011 Err(e) => {
2012 log::error!("Error while fetching {:?}", e);
2013 this.show_error_toast(action.name(), e, cx)
2014 }
2015 }
2016
2017 anyhow::Ok(())
2018 })
2019 .ok();
2020 anyhow::Ok(())
2021 })
2022 .detach_and_log_err(cx);
2023 }
2024
2025 pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2026 let worktrees = self
2027 .project
2028 .read(cx)
2029 .visible_worktrees(cx)
2030 .collect::<Vec<_>>();
2031
2032 let worktree = if worktrees.len() == 1 {
2033 Task::ready(Some(worktrees.first().unwrap().clone()))
2034 } else if worktrees.len() == 0 {
2035 let result = window.prompt(
2036 PromptLevel::Warning,
2037 "Unable to initialize a git repository",
2038 Some("Open a directory first"),
2039 &["Ok"],
2040 cx,
2041 );
2042 cx.background_executor()
2043 .spawn(async move {
2044 result.await.ok();
2045 })
2046 .detach();
2047 return;
2048 } else {
2049 let worktree_directories = worktrees
2050 .iter()
2051 .map(|worktree| worktree.read(cx).abs_path())
2052 .map(|worktree_abs_path| {
2053 if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2054 Path::new("~")
2055 .join(path)
2056 .to_string_lossy()
2057 .to_string()
2058 .into()
2059 } else {
2060 worktree_abs_path.to_string_lossy().to_string().into()
2061 }
2062 })
2063 .collect_vec();
2064 let prompt = picker_prompt::prompt(
2065 "Where would you like to initialize this git repository?",
2066 worktree_directories,
2067 self.workspace.clone(),
2068 window,
2069 cx,
2070 );
2071
2072 cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2073 };
2074
2075 cx.spawn_in(window, async move |this, cx| {
2076 let worktree = match worktree.await {
2077 Some(worktree) => worktree,
2078 None => {
2079 return;
2080 }
2081 };
2082
2083 let Ok(result) = this.update(cx, |this, cx| {
2084 let fallback_branch_name = GitPanelSettings::get_global(cx)
2085 .fallback_branch_name
2086 .clone();
2087 this.project.read(cx).git_init(
2088 worktree.read(cx).abs_path(),
2089 fallback_branch_name,
2090 cx,
2091 )
2092 }) else {
2093 return;
2094 };
2095
2096 let result = result.await;
2097
2098 this.update_in(cx, |this, _, cx| match result {
2099 Ok(()) => {}
2100 Err(e) => this.show_error_toast("init", e, cx),
2101 })
2102 .ok();
2103 })
2104 .detach();
2105 }
2106
2107 pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2108 if !self.can_push_and_pull(cx) {
2109 return;
2110 }
2111 let Some(repo) = self.active_repository.clone() else {
2112 return;
2113 };
2114 let Some(branch) = repo.read(cx).branch.as_ref() else {
2115 return;
2116 };
2117 telemetry::event!("Git Pulled");
2118 let branch = branch.clone();
2119 let remote = self.get_remote(false, window, cx);
2120 cx.spawn_in(window, async move |this, cx| {
2121 let remote = match remote.await {
2122 Ok(Some(remote)) => remote,
2123 Ok(None) => {
2124 return Ok(());
2125 }
2126 Err(e) => {
2127 log::error!("Failed to get current remote: {}", e);
2128 this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
2129 .ok();
2130 return Ok(());
2131 }
2132 };
2133
2134 let askpass = this.update_in(cx, |this, window, cx| {
2135 this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
2136 })?;
2137
2138 let pull = repo.update(cx, |repo, cx| {
2139 repo.pull(
2140 branch.name().to_owned().into(),
2141 remote.name.clone(),
2142 askpass,
2143 cx,
2144 )
2145 })?;
2146
2147 let remote_message = pull.await?;
2148
2149 let action = RemoteAction::Pull(remote);
2150 this.update(cx, |this, cx| match remote_message {
2151 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2152 Err(e) => {
2153 log::error!("Error while pulling {:?}", e);
2154 this.show_error_toast(action.name(), e, cx)
2155 }
2156 })
2157 .ok();
2158
2159 anyhow::Ok(())
2160 })
2161 .detach_and_log_err(cx);
2162 }
2163
2164 pub(crate) fn push(
2165 &mut self,
2166 force_push: bool,
2167 select_remote: bool,
2168 window: &mut Window,
2169 cx: &mut Context<Self>,
2170 ) {
2171 if !self.can_push_and_pull(cx) {
2172 return;
2173 }
2174 let Some(repo) = self.active_repository.clone() else {
2175 return;
2176 };
2177 let Some(branch) = repo.read(cx).branch.as_ref() else {
2178 return;
2179 };
2180 telemetry::event!("Git Pushed");
2181 let branch = branch.clone();
2182
2183 let options = if force_push {
2184 Some(PushOptions::Force)
2185 } else {
2186 match branch.upstream {
2187 Some(Upstream {
2188 tracking: UpstreamTracking::Gone,
2189 ..
2190 })
2191 | None => Some(PushOptions::SetUpstream),
2192 _ => None,
2193 }
2194 };
2195 let remote = self.get_remote(select_remote, window, cx);
2196
2197 cx.spawn_in(window, async move |this, cx| {
2198 let remote = match remote.await {
2199 Ok(Some(remote)) => remote,
2200 Ok(None) => {
2201 return Ok(());
2202 }
2203 Err(e) => {
2204 log::error!("Failed to get current remote: {}", e);
2205 this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
2206 .ok();
2207 return Ok(());
2208 }
2209 };
2210
2211 let askpass_delegate = this.update_in(cx, |this, window, cx| {
2212 this.askpass_delegate(format!("git push {}", remote.name), window, cx)
2213 })?;
2214
2215 let push = repo.update(cx, |repo, cx| {
2216 repo.push(
2217 branch.name().to_owned().into(),
2218 remote.name.clone(),
2219 options,
2220 askpass_delegate,
2221 cx,
2222 )
2223 })?;
2224
2225 let remote_output = push.await?;
2226
2227 let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
2228 this.update(cx, |this, cx| match remote_output {
2229 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2230 Err(e) => {
2231 log::error!("Error while pushing {:?}", e);
2232 this.show_error_toast(action.name(), e, cx)
2233 }
2234 })?;
2235
2236 anyhow::Ok(())
2237 })
2238 .detach_and_log_err(cx);
2239 }
2240
2241 fn askpass_delegate(
2242 &self,
2243 operation: impl Into<SharedString>,
2244 window: &mut Window,
2245 cx: &mut Context<Self>,
2246 ) -> AskPassDelegate {
2247 let this = cx.weak_entity();
2248 let operation = operation.into();
2249 let window = window.window_handle();
2250 AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
2251 window
2252 .update(cx, |_, window, cx| {
2253 this.update(cx, |this, cx| {
2254 this.workspace.update(cx, |workspace, cx| {
2255 workspace.toggle_modal(window, cx, |window, cx| {
2256 AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
2257 });
2258 })
2259 })
2260 })
2261 .ok();
2262 })
2263 }
2264
2265 fn can_push_and_pull(&self, cx: &App) -> bool {
2266 !self.project.read(cx).is_via_collab()
2267 }
2268
2269 fn get_remote(
2270 &mut self,
2271 always_select: bool,
2272 window: &mut Window,
2273 cx: &mut Context<Self>,
2274 ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
2275 let repo = self.active_repository.clone();
2276 let workspace = self.workspace.clone();
2277 let mut cx = window.to_async(cx);
2278
2279 async move {
2280 let repo = repo.context("No active repository")?;
2281 let current_remotes: Vec<Remote> = repo
2282 .update(&mut cx, |repo, _| {
2283 let current_branch = if always_select {
2284 None
2285 } else {
2286 let current_branch = repo.branch.as_ref().context("No active branch")?;
2287 Some(current_branch.name().to_string())
2288 };
2289 anyhow::Ok(repo.get_remotes(current_branch))
2290 })??
2291 .await??;
2292
2293 let current_remotes: Vec<_> = current_remotes
2294 .into_iter()
2295 .map(|remotes| remotes.name)
2296 .collect();
2297 let selection = cx
2298 .update(|window, cx| {
2299 picker_prompt::prompt(
2300 "Pick which remote to push to",
2301 current_remotes.clone(),
2302 workspace,
2303 window,
2304 cx,
2305 )
2306 })?
2307 .await;
2308
2309 Ok(selection.map(|selection| Remote {
2310 name: current_remotes[selection].clone(),
2311 }))
2312 }
2313 }
2314
2315 pub fn load_local_committer(&mut self, cx: &Context<Self>) {
2316 if self.local_committer_task.is_none() {
2317 self.local_committer_task = Some(cx.spawn(async move |this, cx| {
2318 let committer = get_git_committer(cx).await;
2319 this.update(cx, |this, cx| {
2320 this.local_committer = Some(committer);
2321 cx.notify()
2322 })
2323 .ok();
2324 }));
2325 }
2326 }
2327
2328 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
2329 let mut new_co_authors = Vec::new();
2330 let project = self.project.read(cx);
2331
2332 let Some(room) = self
2333 .workspace
2334 .upgrade()
2335 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
2336 else {
2337 return Vec::default();
2338 };
2339
2340 let room = room.read(cx);
2341
2342 for (peer_id, collaborator) in project.collaborators() {
2343 if collaborator.is_host {
2344 continue;
2345 }
2346
2347 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
2348 continue;
2349 };
2350 if !participant.can_write() {
2351 continue;
2352 }
2353 if let Some(email) = &collaborator.committer_email {
2354 let name = collaborator
2355 .committer_name
2356 .clone()
2357 .or_else(|| participant.user.name.clone())
2358 .unwrap_or_else(|| participant.user.github_login.clone());
2359 new_co_authors.push((name.clone(), email.clone()))
2360 }
2361 }
2362 if !project.is_local() && !project.is_read_only(cx) {
2363 if let Some(local_committer) = self.local_committer(room, cx) {
2364 new_co_authors.push(local_committer);
2365 }
2366 }
2367 new_co_authors
2368 }
2369
2370 fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
2371 let user = room.local_participant_user(cx)?;
2372 let committer = self.local_committer.as_ref()?;
2373 let email = committer.email.clone()?;
2374 let name = committer
2375 .name
2376 .clone()
2377 .or_else(|| user.name.clone())
2378 .unwrap_or_else(|| user.github_login.clone());
2379 Some((name, email))
2380 }
2381
2382 fn toggle_fill_co_authors(
2383 &mut self,
2384 _: &ToggleFillCoAuthors,
2385 _: &mut Window,
2386 cx: &mut Context<Self>,
2387 ) {
2388 self.add_coauthors = !self.add_coauthors;
2389 cx.notify();
2390 }
2391
2392 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
2393 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
2394
2395 let existing_text = message.to_ascii_lowercase();
2396 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
2397 let mut ends_with_co_authors = false;
2398 let existing_co_authors = existing_text
2399 .lines()
2400 .filter_map(|line| {
2401 let line = line.trim();
2402 if line.starts_with(&lowercase_co_author_prefix) {
2403 ends_with_co_authors = true;
2404 Some(line)
2405 } else {
2406 ends_with_co_authors = false;
2407 None
2408 }
2409 })
2410 .collect::<HashSet<_>>();
2411
2412 let new_co_authors = self
2413 .potential_co_authors(cx)
2414 .into_iter()
2415 .filter(|(_, email)| {
2416 !existing_co_authors
2417 .iter()
2418 .any(|existing| existing.contains(email.as_str()))
2419 })
2420 .collect::<Vec<_>>();
2421
2422 if new_co_authors.is_empty() {
2423 return;
2424 }
2425
2426 if !ends_with_co_authors {
2427 message.push('\n');
2428 }
2429 for (name, email) in new_co_authors {
2430 message.push('\n');
2431 message.push_str(CO_AUTHOR_PREFIX);
2432 message.push_str(&name);
2433 message.push_str(" <");
2434 message.push_str(&email);
2435 message.push('>');
2436 }
2437 message.push('\n');
2438 }
2439
2440 fn schedule_update(
2441 &mut self,
2442 clear_pending: bool,
2443 window: &mut Window,
2444 cx: &mut Context<Self>,
2445 ) {
2446 let handle = cx.entity().downgrade();
2447 self.reopen_commit_buffer(window, cx);
2448 self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
2449 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
2450 if let Some(git_panel) = handle.upgrade() {
2451 git_panel
2452 .update_in(cx, |git_panel, window, cx| {
2453 if clear_pending {
2454 git_panel.clear_pending();
2455 }
2456 git_panel.update_visible_entries(cx);
2457 git_panel.update_scrollbar_properties(window, cx);
2458 })
2459 .ok();
2460 }
2461 });
2462 }
2463
2464 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2465 let Some(active_repo) = self.active_repository.as_ref() else {
2466 return;
2467 };
2468 let load_buffer = active_repo.update(cx, |active_repo, cx| {
2469 let project = self.project.read(cx);
2470 active_repo.open_commit_buffer(
2471 Some(project.languages().clone()),
2472 project.buffer_store().clone(),
2473 cx,
2474 )
2475 });
2476
2477 cx.spawn_in(window, async move |git_panel, cx| {
2478 let buffer = load_buffer.await?;
2479 git_panel.update_in(cx, |git_panel, window, cx| {
2480 if git_panel
2481 .commit_editor
2482 .read(cx)
2483 .buffer()
2484 .read(cx)
2485 .as_singleton()
2486 .as_ref()
2487 != Some(&buffer)
2488 {
2489 git_panel.commit_editor = cx.new(|cx| {
2490 commit_message_editor(
2491 buffer,
2492 git_panel.suggest_commit_message(cx).map(SharedString::from),
2493 git_panel.project.clone(),
2494 true,
2495 window,
2496 cx,
2497 )
2498 });
2499 }
2500 })
2501 })
2502 .detach_and_log_err(cx);
2503 }
2504
2505 fn clear_pending(&mut self) {
2506 self.pending.retain(|v| !v.finished)
2507 }
2508
2509 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
2510 let bulk_staging = self.bulk_staging.take();
2511 let last_staged_path_prev_index = bulk_staging
2512 .as_ref()
2513 .and_then(|op| self.entry_by_path(&op.anchor, cx));
2514
2515 self.entries.clear();
2516 self.single_staged_entry.take();
2517 self.single_tracked_entry.take();
2518 self.conflicted_count = 0;
2519 self.conflicted_staged_count = 0;
2520 self.new_count = 0;
2521 self.tracked_count = 0;
2522 self.new_staged_count = 0;
2523 self.tracked_staged_count = 0;
2524 self.entry_count = 0;
2525
2526 let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
2527
2528 let mut changed_entries = Vec::new();
2529 let mut new_entries = Vec::new();
2530 let mut conflict_entries = Vec::new();
2531 let mut single_staged_entry = None;
2532 let mut staged_count = 0;
2533 let mut max_width_item: Option<(RepoPath, usize)> = None;
2534
2535 let Some(repo) = self.active_repository.as_ref() else {
2536 // Just clear entries if no repository is active.
2537 cx.notify();
2538 return;
2539 };
2540
2541 let repo = repo.read(cx);
2542
2543 for entry in repo.cached_status() {
2544 let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
2545 let is_new = entry.status.is_created();
2546 let staging = entry.status.staging();
2547
2548 if self.pending.iter().any(|pending| {
2549 pending.target_status == TargetStatus::Reverted
2550 && !pending.finished
2551 && pending
2552 .entries
2553 .iter()
2554 .any(|pending| pending.repo_path == entry.repo_path)
2555 }) {
2556 continue;
2557 }
2558
2559 let abs_path = repo.work_directory_abs_path.join(&entry.repo_path.0);
2560 let entry = GitStatusEntry {
2561 repo_path: entry.repo_path.clone(),
2562 abs_path,
2563 status: entry.status,
2564 staging,
2565 };
2566
2567 if staging.has_staged() {
2568 staged_count += 1;
2569 single_staged_entry = Some(entry.clone());
2570 }
2571
2572 let width_estimate = Self::item_width_estimate(
2573 entry.parent_dir().map(|s| s.len()).unwrap_or(0),
2574 entry.display_name().len(),
2575 );
2576
2577 match max_width_item.as_mut() {
2578 Some((repo_path, estimate)) => {
2579 if width_estimate > *estimate {
2580 *repo_path = entry.repo_path.clone();
2581 *estimate = width_estimate;
2582 }
2583 }
2584 None => max_width_item = Some((entry.repo_path.clone(), width_estimate)),
2585 }
2586
2587 if sort_by_path {
2588 changed_entries.push(entry);
2589 } else if is_conflict {
2590 conflict_entries.push(entry);
2591 } else if is_new {
2592 new_entries.push(entry);
2593 } else {
2594 changed_entries.push(entry);
2595 }
2596 }
2597
2598 let mut pending_staged_count = 0;
2599 let mut last_pending_staged = None;
2600 let mut pending_status_for_single_staged = None;
2601 for pending in self.pending.iter() {
2602 if pending.target_status == TargetStatus::Staged {
2603 pending_staged_count += pending.entries.len();
2604 last_pending_staged = pending.entries.iter().next().cloned();
2605 }
2606 if let Some(single_staged) = &single_staged_entry {
2607 if pending
2608 .entries
2609 .iter()
2610 .any(|entry| entry.repo_path == single_staged.repo_path)
2611 {
2612 pending_status_for_single_staged = Some(pending.target_status);
2613 }
2614 }
2615 }
2616
2617 if conflict_entries.len() == 0 && staged_count == 1 && pending_staged_count == 0 {
2618 match pending_status_for_single_staged {
2619 Some(TargetStatus::Staged) | None => {
2620 self.single_staged_entry = single_staged_entry;
2621 }
2622 _ => {}
2623 }
2624 } else if conflict_entries.len() == 0 && pending_staged_count == 1 {
2625 self.single_staged_entry = last_pending_staged;
2626 }
2627
2628 if conflict_entries.len() == 0 && changed_entries.len() == 1 {
2629 self.single_tracked_entry = changed_entries.first().cloned();
2630 }
2631
2632 if conflict_entries.len() > 0 {
2633 self.entries.push(GitListEntry::Header(GitHeaderEntry {
2634 header: Section::Conflict,
2635 }));
2636 self.entries
2637 .extend(conflict_entries.into_iter().map(GitListEntry::Status));
2638 }
2639
2640 if changed_entries.len() > 0 {
2641 if !sort_by_path {
2642 self.entries.push(GitListEntry::Header(GitHeaderEntry {
2643 header: Section::Tracked,
2644 }));
2645 }
2646 self.entries
2647 .extend(changed_entries.into_iter().map(GitListEntry::Status));
2648 }
2649 if new_entries.len() > 0 {
2650 self.entries.push(GitListEntry::Header(GitHeaderEntry {
2651 header: Section::New,
2652 }));
2653 self.entries
2654 .extend(new_entries.into_iter().map(GitListEntry::Status));
2655 }
2656
2657 if let Some((repo_path, _)) = max_width_item {
2658 self.max_width_item_index = self.entries.iter().position(|entry| match entry {
2659 GitListEntry::Status(git_status_entry) => git_status_entry.repo_path == repo_path,
2660 GitListEntry::Header(_) => false,
2661 });
2662 }
2663
2664 self.update_counts(repo);
2665
2666 let bulk_staging_anchor_new_index = bulk_staging
2667 .as_ref()
2668 .filter(|op| op.repo_id == repo.id)
2669 .and_then(|op| self.entry_by_path(&op.anchor, cx));
2670 if bulk_staging_anchor_new_index == last_staged_path_prev_index
2671 && let Some(index) = bulk_staging_anchor_new_index
2672 && let Some(entry) = self.entries.get(index)
2673 && let Some(entry) = entry.status_entry()
2674 && self.entry_staging(entry) == StageStatus::Staged
2675 {
2676 self.bulk_staging = bulk_staging;
2677 }
2678
2679 self.select_first_entry_if_none(cx);
2680
2681 let suggested_commit_message = self.suggest_commit_message(cx);
2682 let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
2683
2684 self.commit_editor.update(cx, |editor, cx| {
2685 editor.set_placeholder_text(Arc::from(placeholder_text), cx)
2686 });
2687
2688 cx.notify();
2689 }
2690
2691 fn header_state(&self, header_type: Section) -> ToggleState {
2692 let (staged_count, count) = match header_type {
2693 Section::New => (self.new_staged_count, self.new_count),
2694 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
2695 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
2696 };
2697 if staged_count == 0 {
2698 ToggleState::Unselected
2699 } else if count == staged_count {
2700 ToggleState::Selected
2701 } else {
2702 ToggleState::Indeterminate
2703 }
2704 }
2705
2706 fn update_counts(&mut self, repo: &Repository) {
2707 self.show_placeholders = false;
2708 self.conflicted_count = 0;
2709 self.conflicted_staged_count = 0;
2710 self.new_count = 0;
2711 self.tracked_count = 0;
2712 self.new_staged_count = 0;
2713 self.tracked_staged_count = 0;
2714 self.entry_count = 0;
2715 for entry in &self.entries {
2716 let Some(status_entry) = entry.status_entry() else {
2717 continue;
2718 };
2719 self.entry_count += 1;
2720 if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
2721 self.conflicted_count += 1;
2722 if self.entry_staging(status_entry).has_staged() {
2723 self.conflicted_staged_count += 1;
2724 }
2725 } else if status_entry.status.is_created() {
2726 self.new_count += 1;
2727 if self.entry_staging(status_entry).has_staged() {
2728 self.new_staged_count += 1;
2729 }
2730 } else {
2731 self.tracked_count += 1;
2732 if self.entry_staging(status_entry).has_staged() {
2733 self.tracked_staged_count += 1;
2734 }
2735 }
2736 }
2737 }
2738
2739 fn entry_staging(&self, entry: &GitStatusEntry) -> StageStatus {
2740 for pending in self.pending.iter().rev() {
2741 if pending
2742 .entries
2743 .iter()
2744 .any(|pending_entry| pending_entry.repo_path == entry.repo_path)
2745 {
2746 match pending.target_status {
2747 TargetStatus::Staged => return StageStatus::Staged,
2748 TargetStatus::Unstaged => return StageStatus::Unstaged,
2749 TargetStatus::Reverted => continue,
2750 TargetStatus::Unchanged => continue,
2751 }
2752 }
2753 }
2754 entry.staging
2755 }
2756
2757 pub(crate) fn has_staged_changes(&self) -> bool {
2758 self.tracked_staged_count > 0
2759 || self.new_staged_count > 0
2760 || self.conflicted_staged_count > 0
2761 }
2762
2763 pub(crate) fn has_unstaged_changes(&self) -> bool {
2764 self.tracked_count > self.tracked_staged_count
2765 || self.new_count > self.new_staged_count
2766 || self.conflicted_count > self.conflicted_staged_count
2767 }
2768
2769 fn has_tracked_changes(&self) -> bool {
2770 self.tracked_count > 0
2771 }
2772
2773 pub fn has_unstaged_conflicts(&self) -> bool {
2774 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
2775 }
2776
2777 fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
2778 let action = action.into();
2779 let Some(workspace) = self.workspace.upgrade() else {
2780 return;
2781 };
2782
2783 let message = e.to_string().trim().to_string();
2784 if message
2785 .matches(git::repository::REMOTE_CANCELLED_BY_USER)
2786 .next()
2787 .is_some()
2788 {
2789 return; // Hide the cancelled by user message
2790 } else {
2791 workspace.update(cx, |workspace, cx| {
2792 let workspace_weak = cx.weak_entity();
2793 let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
2794 this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
2795 .action("View Log", move |window, cx| {
2796 let message = message.clone();
2797 let action = action.clone();
2798 workspace_weak
2799 .update(cx, move |workspace, cx| {
2800 Self::open_output(action, workspace, &message, window, cx)
2801 })
2802 .ok();
2803 })
2804 });
2805 workspace.toggle_status_toast(toast, cx)
2806 });
2807 }
2808 }
2809
2810 fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
2811 where
2812 E: std::fmt::Debug + std::fmt::Display,
2813 {
2814 if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
2815 let _ = workspace.update(cx, |workspace, cx| {
2816 struct CommitMessageError;
2817 let notification_id = NotificationId::unique::<CommitMessageError>();
2818 workspace.show_notification(notification_id, cx, |cx| {
2819 cx.new(|cx| {
2820 ErrorMessagePrompt::new(
2821 format!("Failed to generate commit message: {err}"),
2822 cx,
2823 )
2824 })
2825 });
2826 });
2827 }
2828 }
2829
2830 fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
2831 let Some(workspace) = self.workspace.upgrade() else {
2832 return;
2833 };
2834
2835 workspace.update(cx, |workspace, cx| {
2836 let SuccessMessage { message, style } = remote_output::format_output(&action, info);
2837 let workspace_weak = cx.weak_entity();
2838 let operation = action.name();
2839
2840 let status_toast = StatusToast::new(message, cx, move |this, _cx| {
2841 use remote_output::SuccessStyle::*;
2842 match style {
2843 Toast { .. } => this,
2844 ToastWithLog { output } => this
2845 .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
2846 .action("View Log", move |window, cx| {
2847 let output = output.clone();
2848 let output =
2849 format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
2850 workspace_weak
2851 .update(cx, move |workspace, cx| {
2852 Self::open_output(operation, workspace, &output, window, cx)
2853 })
2854 .ok();
2855 }),
2856 PushPrLink { link } => this
2857 .icon(ToastIcon::new(IconName::GitBranchSmall).color(Color::Muted))
2858 .action("Open Pull Request", move |_, cx| cx.open_url(&link)),
2859 }
2860 });
2861 workspace.toggle_status_toast(status_toast, cx)
2862 });
2863 }
2864
2865 fn open_output(
2866 operation: impl Into<SharedString>,
2867 workspace: &mut Workspace,
2868 output: &str,
2869 window: &mut Window,
2870 cx: &mut Context<Workspace>,
2871 ) {
2872 let operation = operation.into();
2873 let buffer = cx.new(|cx| Buffer::local(output, cx));
2874 buffer.update(cx, |buffer, cx| {
2875 buffer.set_capability(language::Capability::ReadOnly, cx);
2876 });
2877 let editor = cx.new(|cx| {
2878 let mut editor = Editor::for_buffer(buffer, None, window, cx);
2879 editor.buffer().update(cx, |buffer, cx| {
2880 buffer.set_title(format!("Output from git {operation}"), cx);
2881 });
2882 editor.set_read_only(true);
2883 editor
2884 });
2885
2886 workspace.add_item_to_center(Box::new(editor), window, cx);
2887 }
2888
2889 pub fn can_commit(&self) -> bool {
2890 (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
2891 }
2892
2893 pub fn can_stage_all(&self) -> bool {
2894 self.has_unstaged_changes()
2895 }
2896
2897 pub fn can_unstage_all(&self) -> bool {
2898 self.has_staged_changes()
2899 }
2900
2901 // eventually we'll need to take depth into account here
2902 // if we add a tree view
2903 fn item_width_estimate(path: usize, file_name: usize) -> usize {
2904 path + file_name
2905 }
2906
2907 fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
2908 let focus_handle = self.focus_handle.clone();
2909 let has_tracked_changes = self.has_tracked_changes();
2910 let has_staged_changes = self.has_staged_changes();
2911 let has_unstaged_changes = self.has_unstaged_changes();
2912 let has_new_changes = self.new_count > 0;
2913
2914 PopoverMenu::new(id.into())
2915 .trigger(
2916 IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
2917 .icon_size(IconSize::Small)
2918 .icon_color(Color::Muted),
2919 )
2920 .menu(move |window, cx| {
2921 Some(git_panel_context_menu(
2922 focus_handle.clone(),
2923 GitMenuState {
2924 has_tracked_changes,
2925 has_staged_changes,
2926 has_unstaged_changes,
2927 has_new_changes,
2928 },
2929 window,
2930 cx,
2931 ))
2932 })
2933 .anchor(Corner::TopRight)
2934 }
2935
2936 pub(crate) fn render_generate_commit_message_button(
2937 &self,
2938 cx: &Context<Self>,
2939 ) -> Option<AnyElement> {
2940 current_language_model(cx).is_some().then(|| {
2941 if self.generate_commit_message_task.is_some() {
2942 return h_flex()
2943 .gap_1()
2944 .child(
2945 Icon::new(IconName::ArrowCircle)
2946 .size(IconSize::XSmall)
2947 .color(Color::Info)
2948 .with_animation(
2949 "arrow-circle",
2950 Animation::new(Duration::from_secs(2)).repeat(),
2951 |icon, delta| {
2952 icon.transform(Transformation::rotate(percentage(delta)))
2953 },
2954 ),
2955 )
2956 .child(
2957 Label::new("Generating Commit...")
2958 .size(LabelSize::Small)
2959 .color(Color::Muted),
2960 )
2961 .into_any_element();
2962 }
2963
2964 let can_commit = self.can_commit();
2965 let editor_focus_handle = self.commit_editor.focus_handle(cx);
2966 IconButton::new("generate-commit-message", IconName::AiEdit)
2967 .shape(ui::IconButtonShape::Square)
2968 .icon_color(Color::Muted)
2969 .tooltip(move |window, cx| {
2970 if can_commit {
2971 Tooltip::for_action_in(
2972 "Generate Commit Message",
2973 &git::GenerateCommitMessage,
2974 &editor_focus_handle,
2975 window,
2976 cx,
2977 )
2978 } else {
2979 Tooltip::simple("No changes to commit", cx)
2980 }
2981 })
2982 .disabled(!can_commit)
2983 .on_click(cx.listener(move |this, _event, _window, cx| {
2984 this.generate_commit_message(cx);
2985 }))
2986 .into_any_element()
2987 })
2988 }
2989
2990 pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
2991 let potential_co_authors = self.potential_co_authors(cx);
2992
2993 let (tooltip_label, icon) = if self.add_coauthors {
2994 ("Remove co-authored-by", IconName::Person)
2995 } else {
2996 ("Add co-authored-by", IconName::UserCheck)
2997 };
2998
2999 if potential_co_authors.is_empty() {
3000 None
3001 } else {
3002 Some(
3003 IconButton::new("co-authors", icon)
3004 .shape(ui::IconButtonShape::Square)
3005 .icon_color(Color::Disabled)
3006 .selected_icon_color(Color::Selected)
3007 .toggle_state(self.add_coauthors)
3008 .tooltip(move |_, cx| {
3009 let title = format!(
3010 "{}:{}{}",
3011 tooltip_label,
3012 if potential_co_authors.len() == 1 {
3013 ""
3014 } else {
3015 "\n"
3016 },
3017 potential_co_authors
3018 .iter()
3019 .map(|(name, email)| format!(" {} <{}>", name, email))
3020 .join("\n")
3021 );
3022 Tooltip::simple(title, cx)
3023 })
3024 .on_click(cx.listener(|this, _, _, cx| {
3025 this.add_coauthors = !this.add_coauthors;
3026 cx.notify();
3027 }))
3028 .into_any_element(),
3029 )
3030 }
3031 }
3032
3033 fn render_git_commit_menu(
3034 &self,
3035 id: impl Into<ElementId>,
3036 keybinding_target: Option<FocusHandle>,
3037 cx: &mut Context<Self>,
3038 ) -> impl IntoElement {
3039 PopoverMenu::new(id.into())
3040 .trigger(
3041 ui::ButtonLike::new_rounded_right("commit-split-button-right")
3042 .layer(ui::ElevationIndex::ModalSurface)
3043 .size(ButtonSize::None)
3044 .child(
3045 h_flex()
3046 .px_1()
3047 .h_full()
3048 .justify_center()
3049 .border_l_1()
3050 .border_color(cx.theme().colors().border)
3051 .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
3052 ),
3053 )
3054 .menu({
3055 let git_panel = cx.entity();
3056 let has_previous_commit = self.head_commit(cx).is_some();
3057 let amend = self.amend_pending();
3058 let signoff = self.signoff_enabled;
3059
3060 move |window, cx| {
3061 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
3062 context_menu
3063 .when_some(keybinding_target.clone(), |el, keybinding_target| {
3064 el.context(keybinding_target.clone())
3065 })
3066 .when(has_previous_commit, |this| {
3067 this.toggleable_entry(
3068 "Amend",
3069 amend,
3070 IconPosition::Start,
3071 Some(Box::new(Amend)),
3072 {
3073 let git_panel = git_panel.downgrade();
3074 move |_, cx| {
3075 git_panel
3076 .update(cx, |git_panel, cx| {
3077 git_panel.toggle_amend_pending(cx);
3078 })
3079 .ok();
3080 }
3081 },
3082 )
3083 })
3084 .toggleable_entry(
3085 "Signoff",
3086 signoff,
3087 IconPosition::Start,
3088 Some(Box::new(Signoff)),
3089 move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
3090 )
3091 }))
3092 }
3093 })
3094 .anchor(Corner::TopRight)
3095 }
3096
3097 pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
3098 if self.has_unstaged_conflicts() {
3099 (false, "You must resolve conflicts before committing")
3100 } else if !self.has_staged_changes() && !self.has_tracked_changes() {
3101 (false, "No changes to commit")
3102 } else if self.pending_commit.is_some() {
3103 (false, "Commit in progress")
3104 } else if !self.has_commit_message(cx) {
3105 (false, "No commit message")
3106 } else if !self.has_write_access(cx) {
3107 (false, "You do not have write access to this project")
3108 } else {
3109 (true, self.commit_button_title())
3110 }
3111 }
3112
3113 pub fn commit_button_title(&self) -> &'static str {
3114 if self.amend_pending {
3115 if self.has_staged_changes() {
3116 "Amend"
3117 } else {
3118 "Amend Tracked"
3119 }
3120 } else {
3121 if self.has_staged_changes() {
3122 "Commit"
3123 } else {
3124 "Commit Tracked"
3125 }
3126 }
3127 }
3128
3129 fn expand_commit_editor(
3130 &mut self,
3131 _: &git::ExpandCommitEditor,
3132 window: &mut Window,
3133 cx: &mut Context<Self>,
3134 ) {
3135 let workspace = self.workspace.clone();
3136 window.defer(cx, move |window, cx| {
3137 workspace
3138 .update(cx, |workspace, cx| {
3139 CommitModal::toggle(workspace, None, window, cx)
3140 })
3141 .ok();
3142 })
3143 }
3144
3145 fn render_panel_header(
3146 &self,
3147 window: &mut Window,
3148 cx: &mut Context<Self>,
3149 ) -> Option<impl IntoElement> {
3150 self.active_repository.as_ref()?;
3151
3152 let text;
3153 let action;
3154 let tooltip;
3155 if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
3156 text = "Unstage All";
3157 action = git::UnstageAll.boxed_clone();
3158 tooltip = "git reset";
3159 } else {
3160 text = "Stage All";
3161 action = git::StageAll.boxed_clone();
3162 tooltip = "git add --all ."
3163 }
3164
3165 let change_string = match self.entry_count {
3166 0 => "No Changes".to_string(),
3167 1 => "1 Change".to_string(),
3168 _ => format!("{} Changes", self.entry_count),
3169 };
3170
3171 Some(
3172 self.panel_header_container(window, cx)
3173 .px_2()
3174 .justify_between()
3175 .child(
3176 panel_button(change_string)
3177 .color(Color::Muted)
3178 .tooltip(Tooltip::for_action_title_in(
3179 "Open Diff",
3180 &Diff,
3181 &self.focus_handle,
3182 ))
3183 .on_click(|_, _, cx| {
3184 cx.defer(|cx| {
3185 cx.dispatch_action(&Diff);
3186 })
3187 }),
3188 )
3189 .child(
3190 h_flex()
3191 .gap_1()
3192 .child(self.render_overflow_menu("overflow_menu"))
3193 .child(
3194 panel_filled_button(text)
3195 .tooltip(Tooltip::for_action_title_in(
3196 tooltip,
3197 action.as_ref(),
3198 &self.focus_handle,
3199 ))
3200 .disabled(self.entry_count == 0)
3201 .on_click(move |_, _, cx| {
3202 let action = action.boxed_clone();
3203 cx.defer(move |cx| {
3204 cx.dispatch_action(action.as_ref());
3205 })
3206 }),
3207 ),
3208 ),
3209 )
3210 }
3211
3212 pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
3213 let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
3214 if !self.can_push_and_pull(cx) {
3215 return None;
3216 }
3217 Some(
3218 h_flex()
3219 .gap_1()
3220 .flex_shrink_0()
3221 .when_some(branch, |this, branch| {
3222 let focus_handle = Some(self.focus_handle(cx));
3223
3224 this.children(render_remote_button(
3225 "remote-button",
3226 &branch,
3227 focus_handle,
3228 true,
3229 ))
3230 })
3231 .into_any_element(),
3232 )
3233 }
3234
3235 pub fn render_footer(
3236 &self,
3237 window: &mut Window,
3238 cx: &mut Context<Self>,
3239 ) -> Option<impl IntoElement> {
3240 let active_repository = self.active_repository.clone()?;
3241 let panel_editor_style = panel_editor_style(true, window, cx);
3242
3243 let enable_coauthors = self.render_co_authors(cx);
3244
3245 let editor_focus_handle = self.commit_editor.focus_handle(cx);
3246 let expand_tooltip_focus_handle = editor_focus_handle.clone();
3247
3248 let branch = active_repository.read(cx).branch.clone();
3249 let head_commit = active_repository.read(cx).head_commit.clone();
3250
3251 let footer_size = px(32.);
3252 let gap = px(9.0);
3253 let max_height = panel_editor_style
3254 .text
3255 .line_height_in_pixels(window.rem_size())
3256 * MAX_PANEL_EDITOR_LINES
3257 + gap;
3258
3259 let git_panel = cx.entity().clone();
3260 let display_name = SharedString::from(Arc::from(
3261 active_repository
3262 .read(cx)
3263 .display_name()
3264 .trim_end_matches("/"),
3265 ));
3266 let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
3267 editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
3268 });
3269
3270 let footer = v_flex()
3271 .child(PanelRepoFooter::new(
3272 display_name,
3273 branch,
3274 head_commit,
3275 Some(git_panel.clone()),
3276 ))
3277 .child(
3278 panel_editor_container(window, cx)
3279 .id("commit-editor-container")
3280 .relative()
3281 .w_full()
3282 .h(max_height + footer_size)
3283 .border_t_1()
3284 .border_color(cx.theme().colors().border)
3285 .cursor_text()
3286 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
3287 window.focus(&this.commit_editor.focus_handle(cx));
3288 }))
3289 .child(
3290 h_flex()
3291 .id("commit-footer")
3292 .border_t_1()
3293 .when(editor_is_long, |el| {
3294 el.border_color(cx.theme().colors().border_variant)
3295 })
3296 .absolute()
3297 .bottom_0()
3298 .left_0()
3299 .w_full()
3300 .px_2()
3301 .h(footer_size)
3302 .flex_none()
3303 .justify_between()
3304 .child(
3305 self.render_generate_commit_message_button(cx)
3306 .unwrap_or_else(|| div().into_any_element()),
3307 )
3308 .child(
3309 h_flex()
3310 .gap_0p5()
3311 .children(enable_coauthors)
3312 .child(self.render_commit_button(cx)),
3313 ),
3314 )
3315 .child(
3316 div()
3317 .pr_2p5()
3318 .on_action(|&editor::actions::MoveUp, _, cx| {
3319 cx.stop_propagation();
3320 })
3321 .on_action(|&editor::actions::MoveDown, _, cx| {
3322 cx.stop_propagation();
3323 })
3324 .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
3325 )
3326 .child(
3327 h_flex()
3328 .absolute()
3329 .top_2()
3330 .right_2()
3331 .opacity(0.5)
3332 .hover(|this| this.opacity(1.0))
3333 .child(
3334 panel_icon_button("expand-commit-editor", IconName::Maximize)
3335 .icon_size(IconSize::Small)
3336 .size(ui::ButtonSize::Default)
3337 .tooltip(move |window, cx| {
3338 Tooltip::for_action_in(
3339 "Open Commit Modal",
3340 &git::ExpandCommitEditor,
3341 &expand_tooltip_focus_handle,
3342 window,
3343 cx,
3344 )
3345 })
3346 .on_click(cx.listener({
3347 move |_, _, window, cx| {
3348 window.dispatch_action(
3349 git::ExpandCommitEditor.boxed_clone(),
3350 cx,
3351 )
3352 }
3353 })),
3354 ),
3355 ),
3356 );
3357
3358 Some(footer)
3359 }
3360
3361 fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3362 let (can_commit, tooltip) = self.configure_commit_button(cx);
3363 let title = self.commit_button_title();
3364 let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
3365 let amend = self.amend_pending();
3366 let signoff = self.signoff_enabled;
3367
3368 div()
3369 .id("commit-wrapper")
3370 .on_hover(cx.listener(move |this, hovered, _, cx| {
3371 this.show_placeholders =
3372 *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
3373 cx.notify()
3374 }))
3375 .child(SplitButton::new(
3376 ui::ButtonLike::new_rounded_left(ElementId::Name(
3377 format!("split-button-left-{}", title).into(),
3378 ))
3379 .layer(ui::ElevationIndex::ModalSurface)
3380 .size(ui::ButtonSize::Compact)
3381 .child(
3382 div()
3383 .child(Label::new(title).size(LabelSize::Small))
3384 .mr_0p5(),
3385 )
3386 .on_click({
3387 let git_panel = cx.weak_entity();
3388 move |_, window, cx| {
3389 telemetry::event!("Git Committed", source = "Git Panel");
3390 git_panel
3391 .update(cx, |git_panel, cx| {
3392 git_panel.set_amend_pending(false, cx);
3393 git_panel.commit_changes(
3394 CommitOptions { amend, signoff },
3395 window,
3396 cx,
3397 );
3398 })
3399 .ok();
3400 }
3401 })
3402 .disabled(!can_commit || self.modal_open)
3403 .tooltip({
3404 let handle = commit_tooltip_focus_handle.clone();
3405 move |window, cx| {
3406 if can_commit {
3407 Tooltip::with_meta_in(
3408 tooltip,
3409 Some(&git::Commit),
3410 format!(
3411 "git commit{}{}",
3412 if amend { " --amend" } else { "" },
3413 if signoff { " --signoff" } else { "" }
3414 ),
3415 &handle.clone(),
3416 window,
3417 cx,
3418 )
3419 } else {
3420 Tooltip::simple(tooltip, cx)
3421 }
3422 }
3423 }),
3424 self.render_git_commit_menu(
3425 ElementId::Name(format!("split-button-right-{}", title).into()),
3426 Some(commit_tooltip_focus_handle.clone()),
3427 cx,
3428 )
3429 .into_any_element(),
3430 ))
3431 }
3432
3433 fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
3434 h_flex()
3435 .py_1p5()
3436 .px_2()
3437 .gap_1p5()
3438 .justify_between()
3439 .border_t_1()
3440 .border_color(cx.theme().colors().border.opacity(0.8))
3441 .child(
3442 div()
3443 .flex_grow()
3444 .overflow_hidden()
3445 .max_w(relative(0.85))
3446 .child(
3447 Label::new("This will update your most recent commit.")
3448 .size(LabelSize::Small)
3449 .truncate(),
3450 ),
3451 )
3452 .child(
3453 panel_button("Cancel")
3454 .size(ButtonSize::Default)
3455 .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
3456 )
3457 }
3458
3459 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
3460 let active_repository = self.active_repository.as_ref()?;
3461 let branch = active_repository.read(cx).branch.as_ref()?;
3462 let commit = branch.most_recent_commit.as_ref()?.clone();
3463 let workspace = self.workspace.clone();
3464 let this = cx.entity();
3465
3466 Some(
3467 h_flex()
3468 .py_1p5()
3469 .px_2()
3470 .gap_1p5()
3471 .justify_between()
3472 .border_t_1()
3473 .border_color(cx.theme().colors().border.opacity(0.8))
3474 .child(
3475 div()
3476 .flex_grow()
3477 .overflow_hidden()
3478 .max_w(relative(0.85))
3479 .child(
3480 Label::new(commit.subject.clone())
3481 .size(LabelSize::Small)
3482 .truncate(),
3483 )
3484 .id("commit-msg-hover")
3485 .on_click({
3486 let commit = commit.clone();
3487 let repo = active_repository.downgrade();
3488 move |_, window, cx| {
3489 CommitView::open(
3490 commit.clone(),
3491 repo.clone(),
3492 workspace.clone().clone(),
3493 window,
3494 cx,
3495 );
3496 }
3497 })
3498 .hoverable_tooltip({
3499 let repo = active_repository.clone();
3500 move |window, cx| {
3501 GitPanelMessageTooltip::new(
3502 this.clone(),
3503 commit.sha.clone(),
3504 repo.clone(),
3505 window,
3506 cx,
3507 )
3508 .into()
3509 }
3510 }),
3511 )
3512 .when(commit.has_parent, |this| {
3513 let has_unstaged = self.has_unstaged_changes();
3514 this.child(
3515 panel_icon_button("undo", IconName::Undo)
3516 .icon_size(IconSize::XSmall)
3517 .icon_color(Color::Muted)
3518 .tooltip(move |window, cx| {
3519 Tooltip::with_meta(
3520 "Uncommit",
3521 Some(&git::Uncommit),
3522 if has_unstaged {
3523 "git reset HEAD^ --soft"
3524 } else {
3525 "git reset HEAD^"
3526 },
3527 window,
3528 cx,
3529 )
3530 })
3531 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
3532 )
3533 }),
3534 )
3535 }
3536
3537 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
3538 h_flex().h_full().flex_grow().justify_center().child(
3539 v_flex()
3540 .gap_2()
3541 .child(h_flex().w_full().justify_around().child(
3542 if self.active_repository.is_some() {
3543 "No changes to commit"
3544 } else {
3545 "No Git repositories"
3546 },
3547 ))
3548 .children({
3549 let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
3550 (worktree_count > 0 && self.active_repository.is_none()).then(|| {
3551 h_flex().w_full().justify_around().child(
3552 panel_filled_button("Initialize Repository")
3553 .tooltip(Tooltip::for_action_title_in(
3554 "git init",
3555 &git::Init,
3556 &self.focus_handle,
3557 ))
3558 .on_click(move |_, _, cx| {
3559 cx.defer(move |cx| {
3560 cx.dispatch_action(&git::Init);
3561 })
3562 }),
3563 )
3564 })
3565 })
3566 .text_ui_sm(cx)
3567 .mx_auto()
3568 .text_color(Color::Placeholder.color(cx)),
3569 )
3570 }
3571
3572 fn render_vertical_scrollbar(
3573 &self,
3574 show_horizontal_scrollbar_container: bool,
3575 cx: &mut Context<Self>,
3576 ) -> impl IntoElement {
3577 div()
3578 .id("git-panel-vertical-scroll")
3579 .occlude()
3580 .flex_none()
3581 .h_full()
3582 .cursor_default()
3583 .absolute()
3584 .right_0()
3585 .top_0()
3586 .bottom_0()
3587 .w(px(12.))
3588 .when(show_horizontal_scrollbar_container, |this| {
3589 this.pb_neg_3p5()
3590 })
3591 .on_mouse_move(cx.listener(|_, _, _, cx| {
3592 cx.notify();
3593 cx.stop_propagation()
3594 }))
3595 .on_hover(|_, _, cx| {
3596 cx.stop_propagation();
3597 })
3598 .on_any_mouse_down(|_, _, cx| {
3599 cx.stop_propagation();
3600 })
3601 .on_mouse_up(
3602 MouseButton::Left,
3603 cx.listener(|this, _, window, cx| {
3604 if !this.vertical_scrollbar.state.is_dragging()
3605 && !this.focus_handle.contains_focused(window, cx)
3606 {
3607 this.vertical_scrollbar.hide(window, cx);
3608 cx.notify();
3609 }
3610
3611 cx.stop_propagation();
3612 }),
3613 )
3614 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3615 cx.notify();
3616 }))
3617 .children(Scrollbar::vertical(
3618 // percentage as f32..end_offset as f32,
3619 self.vertical_scrollbar.state.clone(),
3620 ))
3621 }
3622
3623 /// Renders the horizontal scrollbar.
3624 ///
3625 /// The right offset is used to determine how far to the right the
3626 /// scrollbar should extend to, useful for ensuring it doesn't collide
3627 /// with the vertical scrollbar when visible.
3628 fn render_horizontal_scrollbar(
3629 &self,
3630 right_offset: Pixels,
3631 cx: &mut Context<Self>,
3632 ) -> impl IntoElement {
3633 div()
3634 .id("git-panel-horizontal-scroll")
3635 .occlude()
3636 .flex_none()
3637 .w_full()
3638 .cursor_default()
3639 .absolute()
3640 .bottom_neg_px()
3641 .left_0()
3642 .right_0()
3643 .pr(right_offset)
3644 .on_mouse_move(cx.listener(|_, _, _, cx| {
3645 cx.notify();
3646 cx.stop_propagation()
3647 }))
3648 .on_hover(|_, _, cx| {
3649 cx.stop_propagation();
3650 })
3651 .on_any_mouse_down(|_, _, cx| {
3652 cx.stop_propagation();
3653 })
3654 .on_mouse_up(
3655 MouseButton::Left,
3656 cx.listener(|this, _, window, cx| {
3657 if !this.horizontal_scrollbar.state.is_dragging()
3658 && !this.focus_handle.contains_focused(window, cx)
3659 {
3660 this.horizontal_scrollbar.hide(window, cx);
3661 cx.notify();
3662 }
3663
3664 cx.stop_propagation();
3665 }),
3666 )
3667 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
3668 cx.notify();
3669 }))
3670 .children(Scrollbar::horizontal(
3671 // percentage as f32..end_offset as f32,
3672 self.horizontal_scrollbar.state.clone(),
3673 ))
3674 }
3675
3676 fn render_buffer_header_controls(
3677 &self,
3678 entity: &Entity<Self>,
3679 file: &Arc<dyn File>,
3680 _: &Window,
3681 cx: &App,
3682 ) -> Option<AnyElement> {
3683 let repo = self.active_repository.as_ref()?.read(cx);
3684 let project_path = (file.worktree_id(cx), file.path()).into();
3685 let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
3686 let ix = self.entry_by_path(&repo_path, cx)?;
3687 let entry = self.entries.get(ix)?;
3688
3689 let entry_staging = self.entry_staging(entry.status_entry()?);
3690
3691 let checkbox = Checkbox::new("stage-file", entry_staging.as_bool().into())
3692 .disabled(!self.has_write_access(cx))
3693 .fill()
3694 .elevation(ElevationIndex::Surface)
3695 .on_click({
3696 let entry = entry.clone();
3697 let git_panel = entity.downgrade();
3698 move |_, window, cx| {
3699 git_panel
3700 .update(cx, |this, cx| {
3701 this.toggle_staged_for_entry(&entry, window, cx);
3702 cx.stop_propagation();
3703 })
3704 .ok();
3705 }
3706 });
3707 Some(
3708 h_flex()
3709 .id("start-slot")
3710 .text_lg()
3711 .child(checkbox)
3712 .on_mouse_down(MouseButton::Left, |_, _, cx| {
3713 // prevent the list item active state triggering when toggling checkbox
3714 cx.stop_propagation();
3715 })
3716 .into_any_element(),
3717 )
3718 }
3719
3720 fn render_entries(
3721 &self,
3722 has_write_access: bool,
3723 _: &Window,
3724 cx: &mut Context<Self>,
3725 ) -> impl IntoElement {
3726 let entry_count = self.entries.len();
3727
3728 let scroll_track_size = px(16.);
3729
3730 let h_scroll_offset = if self.vertical_scrollbar.show_scrollbar {
3731 // magic number
3732 px(3.)
3733 } else {
3734 px(0.)
3735 };
3736
3737 v_flex()
3738 .flex_1()
3739 .size_full()
3740 .overflow_hidden()
3741 .relative()
3742 // Show a border on the top and bottom of the container when
3743 // the vertical scrollbar container is visible so we don't have a
3744 // floating left border in the panel.
3745 .when(self.vertical_scrollbar.show_track, |this| {
3746 this.border_t_1()
3747 .border_b_1()
3748 .border_color(cx.theme().colors().border)
3749 })
3750 .child(
3751 h_flex()
3752 .flex_1()
3753 .size_full()
3754 .relative()
3755 .overflow_hidden()
3756 .child(
3757 uniform_list(
3758 "entries",
3759 entry_count,
3760 cx.processor(move |this, range: Range<usize>, window, cx| {
3761 let mut items = Vec::with_capacity(range.end - range.start);
3762
3763 for ix in range {
3764 match &this.entries.get(ix) {
3765 Some(GitListEntry::Status(entry)) => {
3766 items.push(this.render_entry(
3767 ix,
3768 entry,
3769 has_write_access,
3770 window,
3771 cx,
3772 ));
3773 }
3774 Some(GitListEntry::Header(header)) => {
3775 items.push(this.render_list_header(
3776 ix,
3777 header,
3778 has_write_access,
3779 window,
3780 cx,
3781 ));
3782 }
3783 None => {}
3784 }
3785 }
3786
3787 items
3788 }),
3789 )
3790 .when(
3791 !self.horizontal_scrollbar.show_track
3792 && self.horizontal_scrollbar.show_scrollbar,
3793 |this| {
3794 // when not showing the horizontal scrollbar track, make sure we don't
3795 // obscure the last entry
3796 this.pb(scroll_track_size)
3797 },
3798 )
3799 .size_full()
3800 .flex_grow()
3801 .with_sizing_behavior(ListSizingBehavior::Auto)
3802 .with_horizontal_sizing_behavior(
3803 ListHorizontalSizingBehavior::Unconstrained,
3804 )
3805 .with_width_from_item(self.max_width_item_index)
3806 .track_scroll(self.scroll_handle.clone()),
3807 )
3808 .on_mouse_down(
3809 MouseButton::Right,
3810 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
3811 this.deploy_panel_context_menu(event.position, window, cx)
3812 }),
3813 )
3814 .when(self.vertical_scrollbar.show_track, |this| {
3815 this.child(
3816 v_flex()
3817 .h_full()
3818 .flex_none()
3819 .w(scroll_track_size)
3820 .bg(cx.theme().colors().panel_background)
3821 .child(
3822 div()
3823 .size_full()
3824 .flex_1()
3825 .border_l_1()
3826 .border_color(cx.theme().colors().border),
3827 ),
3828 )
3829 })
3830 .when(self.vertical_scrollbar.show_scrollbar, |this| {
3831 this.child(
3832 self.render_vertical_scrollbar(
3833 self.horizontal_scrollbar.show_track,
3834 cx,
3835 ),
3836 )
3837 }),
3838 )
3839 .when(self.horizontal_scrollbar.show_track, |this| {
3840 this.child(
3841 h_flex()
3842 .w_full()
3843 .h(scroll_track_size)
3844 .flex_none()
3845 .relative()
3846 .child(
3847 div()
3848 .w_full()
3849 .flex_1()
3850 // for some reason the horizontal scrollbar is 1px
3851 // taller than the vertical scrollbar??
3852 .h(scroll_track_size - px(1.))
3853 .bg(cx.theme().colors().panel_background)
3854 .border_t_1()
3855 .border_color(cx.theme().colors().border),
3856 )
3857 .when(self.vertical_scrollbar.show_track, |this| {
3858 this.child(
3859 div()
3860 .flex_none()
3861 // -1px prevents a missing pixel between the two container borders
3862 .w(scroll_track_size - px(1.))
3863 .h_full(),
3864 )
3865 .child(
3866 // HACK: Fill the missing 1px 🥲
3867 div()
3868 .absolute()
3869 .right(scroll_track_size - px(1.))
3870 .bottom(scroll_track_size - px(1.))
3871 .size_px()
3872 .bg(cx.theme().colors().border),
3873 )
3874 }),
3875 )
3876 })
3877 .when(self.horizontal_scrollbar.show_scrollbar, |this| {
3878 this.child(self.render_horizontal_scrollbar(h_scroll_offset, cx))
3879 })
3880 }
3881
3882 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
3883 Label::new(label.into()).color(color).single_line()
3884 }
3885
3886 fn list_item_height(&self) -> Rems {
3887 rems(1.75)
3888 }
3889
3890 fn render_list_header(
3891 &self,
3892 ix: usize,
3893 header: &GitHeaderEntry,
3894 _: bool,
3895 _: &Window,
3896 _: &Context<Self>,
3897 ) -> AnyElement {
3898 let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
3899
3900 h_flex()
3901 .id(id)
3902 .h(self.list_item_height())
3903 .w_full()
3904 .items_end()
3905 .px(rems(0.75)) // ~12px
3906 .pb(rems(0.3125)) // ~ 5px
3907 .child(
3908 Label::new(header.title())
3909 .color(Color::Muted)
3910 .size(LabelSize::Small)
3911 .line_height_style(LineHeightStyle::UiLabel)
3912 .single_line(),
3913 )
3914 .into_any_element()
3915 }
3916
3917 pub fn load_commit_details(
3918 &self,
3919 sha: String,
3920 cx: &mut Context<Self>,
3921 ) -> Task<anyhow::Result<CommitDetails>> {
3922 let Some(repo) = self.active_repository.clone() else {
3923 return Task::ready(Err(anyhow::anyhow!("no active repo")));
3924 };
3925 repo.update(cx, |repo, cx| {
3926 let show = repo.show(sha);
3927 cx.spawn(async move |_, _| show.await?)
3928 })
3929 }
3930
3931 fn deploy_entry_context_menu(
3932 &mut self,
3933 position: Point<Pixels>,
3934 ix: usize,
3935 window: &mut Window,
3936 cx: &mut Context<Self>,
3937 ) {
3938 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
3939 return;
3940 };
3941 let stage_title = if entry.status.staging().is_fully_staged() {
3942 "Unstage File"
3943 } else {
3944 "Stage File"
3945 };
3946 let restore_title = if entry.status.is_created() {
3947 "Trash File"
3948 } else {
3949 "Restore File"
3950 };
3951 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
3952 context_menu
3953 .context(self.focus_handle.clone())
3954 .action(stage_title, ToggleStaged.boxed_clone())
3955 .action(restore_title, git::RestoreFile::default().boxed_clone())
3956 .separator()
3957 .action("Open Diff", Confirm.boxed_clone())
3958 .action("Open File", SecondaryConfirm.boxed_clone())
3959 });
3960 self.selected_entry = Some(ix);
3961 self.set_context_menu(context_menu, position, window, cx);
3962 }
3963
3964 fn deploy_panel_context_menu(
3965 &mut self,
3966 position: Point<Pixels>,
3967 window: &mut Window,
3968 cx: &mut Context<Self>,
3969 ) {
3970 let context_menu = git_panel_context_menu(
3971 self.focus_handle.clone(),
3972 GitMenuState {
3973 has_tracked_changes: self.has_tracked_changes(),
3974 has_staged_changes: self.has_staged_changes(),
3975 has_unstaged_changes: self.has_unstaged_changes(),
3976 has_new_changes: self.new_count > 0,
3977 },
3978 window,
3979 cx,
3980 );
3981 self.set_context_menu(context_menu, position, window, cx);
3982 }
3983
3984 fn set_context_menu(
3985 &mut self,
3986 context_menu: Entity<ContextMenu>,
3987 position: Point<Pixels>,
3988 window: &Window,
3989 cx: &mut Context<Self>,
3990 ) {
3991 let subscription = cx.subscribe_in(
3992 &context_menu,
3993 window,
3994 |this, _, _: &DismissEvent, window, cx| {
3995 if this.context_menu.as_ref().is_some_and(|context_menu| {
3996 context_menu.0.focus_handle(cx).contains_focused(window, cx)
3997 }) {
3998 cx.focus_self(window);
3999 }
4000 this.context_menu.take();
4001 cx.notify();
4002 },
4003 );
4004 self.context_menu = Some((context_menu, position, subscription));
4005 cx.notify();
4006 }
4007
4008 fn render_entry(
4009 &self,
4010 ix: usize,
4011 entry: &GitStatusEntry,
4012 has_write_access: bool,
4013 window: &Window,
4014 cx: &Context<Self>,
4015 ) -> AnyElement {
4016 let display_name = entry.display_name();
4017
4018 let selected = self.selected_entry == Some(ix);
4019 let marked = self.marked_entries.contains(&ix);
4020 let status_style = GitPanelSettings::get_global(cx).status_style;
4021 let status = entry.status;
4022
4023 let has_conflict = status.is_conflicted();
4024 let is_modified = status.is_modified();
4025 let is_deleted = status.is_deleted();
4026
4027 let label_color = if status_style == StatusStyle::LabelColor {
4028 if has_conflict {
4029 Color::VersionControlConflict
4030 } else if is_modified {
4031 Color::VersionControlModified
4032 } else if is_deleted {
4033 // We don't want a bunch of red labels in the list
4034 Color::Disabled
4035 } else {
4036 Color::VersionControlAdded
4037 }
4038 } else {
4039 Color::Default
4040 };
4041
4042 let path_color = if status.is_deleted() {
4043 Color::Disabled
4044 } else {
4045 Color::Muted
4046 };
4047
4048 let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
4049 let checkbox_wrapper_id: ElementId =
4050 ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
4051 let checkbox_id: ElementId =
4052 ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
4053
4054 let entry_staging = self.entry_staging(entry);
4055 let mut is_staged: ToggleState = self.entry_staging(entry).as_bool().into();
4056 if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
4057 is_staged = ToggleState::Selected;
4058 }
4059
4060 let handle = cx.weak_entity();
4061
4062 let selected_bg_alpha = 0.08;
4063 let marked_bg_alpha = 0.12;
4064 let state_opacity_step = 0.04;
4065
4066 let base_bg = match (selected, marked) {
4067 (true, true) => cx
4068 .theme()
4069 .status()
4070 .info
4071 .alpha(selected_bg_alpha + marked_bg_alpha),
4072 (true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
4073 (false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
4074 _ => cx.theme().colors().ghost_element_background,
4075 };
4076
4077 let hover_bg = if selected {
4078 cx.theme()
4079 .status()
4080 .info
4081 .alpha(selected_bg_alpha + state_opacity_step)
4082 } else {
4083 cx.theme().colors().ghost_element_hover
4084 };
4085
4086 let active_bg = if selected {
4087 cx.theme()
4088 .status()
4089 .info
4090 .alpha(selected_bg_alpha + state_opacity_step * 2.0)
4091 } else {
4092 cx.theme().colors().ghost_element_active
4093 };
4094
4095 h_flex()
4096 .id(id)
4097 .h(self.list_item_height())
4098 .w_full()
4099 .items_center()
4100 .border_1()
4101 .when(selected && self.focus_handle.is_focused(window), |el| {
4102 el.border_color(cx.theme().colors().border_focused)
4103 })
4104 .px(rems(0.75)) // ~12px
4105 .overflow_hidden()
4106 .flex_none()
4107 .gap_1p5()
4108 .bg(base_bg)
4109 .hover(|this| this.bg(hover_bg))
4110 .active(|this| this.bg(active_bg))
4111 .on_click({
4112 cx.listener(move |this, event: &ClickEvent, window, cx| {
4113 this.selected_entry = Some(ix);
4114 cx.notify();
4115 if event.modifiers().secondary() {
4116 this.open_file(&Default::default(), window, cx)
4117 } else {
4118 this.open_diff(&Default::default(), window, cx);
4119 this.focus_handle.focus(window);
4120 }
4121 })
4122 })
4123 .on_mouse_down(
4124 MouseButton::Right,
4125 move |event: &MouseDownEvent, window, cx| {
4126 // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
4127 if event.button != MouseButton::Right {
4128 return;
4129 }
4130
4131 let Some(this) = handle.upgrade() else {
4132 return;
4133 };
4134 this.update(cx, |this, cx| {
4135 this.deploy_entry_context_menu(event.position, ix, window, cx);
4136 });
4137 cx.stop_propagation();
4138 },
4139 )
4140 .child(
4141 div()
4142 .id(checkbox_wrapper_id)
4143 .flex_none()
4144 .occlude()
4145 .cursor_pointer()
4146 .child(
4147 Checkbox::new(checkbox_id, is_staged)
4148 .disabled(!has_write_access)
4149 .fill()
4150 .elevation(ElevationIndex::Surface)
4151 .on_click_ext({
4152 let entry = entry.clone();
4153 let this = cx.weak_entity();
4154 move |_, click, window, cx| {
4155 this.update(cx, |this, cx| {
4156 if !has_write_access {
4157 return;
4158 }
4159 if click.modifiers().shift {
4160 this.stage_bulk(ix, cx);
4161 } else {
4162 this.toggle_staged_for_entry(
4163 &GitListEntry::Status(entry.clone()),
4164 window,
4165 cx,
4166 );
4167 }
4168 cx.stop_propagation();
4169 })
4170 .ok();
4171 }
4172 })
4173 .tooltip(move |window, cx| {
4174 let is_staged = entry_staging.is_fully_staged();
4175
4176 let action = if is_staged { "Unstage" } else { "Stage" };
4177 let tooltip_name = action.to_string();
4178
4179 Tooltip::for_action(tooltip_name, &ToggleStaged, window, cx)
4180 }),
4181 ),
4182 )
4183 .child(git_status_icon(status))
4184 .child(
4185 h_flex()
4186 .items_center()
4187 .flex_1()
4188 // .overflow_hidden()
4189 .when_some(entry.parent_dir(), |this, parent| {
4190 if !parent.is_empty() {
4191 this.child(
4192 self.entry_label(format!("{}/", parent), path_color)
4193 .when(status.is_deleted(), |this| this.strikethrough()),
4194 )
4195 } else {
4196 this
4197 }
4198 })
4199 .child(
4200 self.entry_label(display_name.clone(), label_color)
4201 .when(status.is_deleted(), |this| this.strikethrough()),
4202 ),
4203 )
4204 .into_any_element()
4205 }
4206
4207 fn has_write_access(&self, cx: &App) -> bool {
4208 !self.project.read(cx).is_read_only(cx)
4209 }
4210
4211 pub fn amend_pending(&self) -> bool {
4212 self.amend_pending
4213 }
4214
4215 pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
4216 self.amend_pending = value;
4217 self.serialize(cx);
4218 cx.notify();
4219 }
4220
4221 pub fn signoff_enabled(&self) -> bool {
4222 self.signoff_enabled
4223 }
4224
4225 pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
4226 self.signoff_enabled = value;
4227 self.serialize(cx);
4228 cx.notify();
4229 }
4230
4231 pub fn toggle_signoff_enabled(
4232 &mut self,
4233 _: &Signoff,
4234 _window: &mut Window,
4235 cx: &mut Context<Self>,
4236 ) {
4237 self.set_signoff_enabled(!self.signoff_enabled, cx);
4238 }
4239
4240 pub async fn load(
4241 workspace: WeakEntity<Workspace>,
4242 mut cx: AsyncWindowContext,
4243 ) -> anyhow::Result<Entity<Self>> {
4244 let serialized_panel = match workspace
4245 .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
4246 .ok()
4247 .flatten()
4248 {
4249 Some(serialization_key) => cx
4250 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
4251 .await
4252 .context("loading git panel")
4253 .log_err()
4254 .flatten()
4255 .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
4256 .transpose()
4257 .log_err()
4258 .flatten(),
4259 None => None,
4260 };
4261
4262 workspace.update_in(&mut cx, |workspace, window, cx| {
4263 let panel = GitPanel::new(workspace, window, cx);
4264
4265 if let Some(serialized_panel) = serialized_panel {
4266 panel.update(cx, |panel, cx| {
4267 panel.width = serialized_panel.width;
4268 panel.amend_pending = serialized_panel.amend_pending;
4269 panel.signoff_enabled = serialized_panel.signoff_enabled;
4270 cx.notify();
4271 })
4272 }
4273
4274 panel
4275 })
4276 }
4277
4278 fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
4279 let Some(op) = self.bulk_staging.as_ref() else {
4280 return;
4281 };
4282 let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
4283 return;
4284 };
4285 if let Some(entry) = self.entries.get(index)
4286 && let Some(entry) = entry.status_entry()
4287 {
4288 self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
4289 }
4290 if index < anchor_index {
4291 std::mem::swap(&mut index, &mut anchor_index);
4292 }
4293 let entries = self
4294 .entries
4295 .get(anchor_index..=index)
4296 .unwrap_or_default()
4297 .iter()
4298 .filter_map(|entry| entry.status_entry().cloned())
4299 .collect::<Vec<_>>();
4300 self.change_file_stage(true, entries, cx);
4301 }
4302
4303 fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
4304 let Some(repo) = self.active_repository.as_ref() else {
4305 return;
4306 };
4307 self.bulk_staging = Some(BulkStaging {
4308 repo_id: repo.read(cx).id,
4309 anchor: path,
4310 });
4311 }
4312
4313 pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
4314 self.set_amend_pending(!self.amend_pending, cx);
4315 if self.amend_pending {
4316 self.load_last_commit_message_if_empty(cx);
4317 }
4318 }
4319}
4320
4321fn current_language_model(cx: &Context<'_, GitPanel>) -> Option<Arc<dyn LanguageModel>> {
4322 let is_enabled = agent_settings::AgentSettings::get_global(cx).enabled
4323 && !DisableAiSettings::get_global(cx).disable_ai;
4324
4325 is_enabled
4326 .then(|| {
4327 let ConfiguredModel { provider, model } =
4328 LanguageModelRegistry::read_global(cx).commit_message_model()?;
4329
4330 provider.is_authenticated(cx).then(|| model)
4331 })
4332 .flatten()
4333}
4334
4335impl Render for GitPanel {
4336 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4337 let project = self.project.read(cx);
4338 let has_entries = self.entries.len() > 0;
4339 let room = self
4340 .workspace
4341 .upgrade()
4342 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
4343
4344 let has_write_access = self.has_write_access(cx);
4345
4346 let has_co_authors = room.map_or(false, |room| {
4347 self.load_local_committer(cx);
4348 let room = room.read(cx);
4349 room.remote_participants()
4350 .values()
4351 .any(|remote_participant| remote_participant.can_write())
4352 });
4353
4354 v_flex()
4355 .id("git_panel")
4356 .key_context(self.dispatch_context(window, cx))
4357 .track_focus(&self.focus_handle)
4358 .when(has_write_access && !project.is_read_only(cx), |this| {
4359 this.on_action(cx.listener(Self::toggle_staged_for_selected))
4360 .on_action(cx.listener(Self::stage_range))
4361 .on_action(cx.listener(GitPanel::commit))
4362 .on_action(cx.listener(GitPanel::amend))
4363 .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
4364 .on_action(cx.listener(Self::stage_all))
4365 .on_action(cx.listener(Self::unstage_all))
4366 .on_action(cx.listener(Self::stage_selected))
4367 .on_action(cx.listener(Self::unstage_selected))
4368 .on_action(cx.listener(Self::restore_tracked_files))
4369 .on_action(cx.listener(Self::revert_selected))
4370 .on_action(cx.listener(Self::clean_all))
4371 .on_action(cx.listener(Self::generate_commit_message_action))
4372 })
4373 .on_action(cx.listener(Self::select_first))
4374 .on_action(cx.listener(Self::select_next))
4375 .on_action(cx.listener(Self::select_previous))
4376 .on_action(cx.listener(Self::select_last))
4377 .on_action(cx.listener(Self::close_panel))
4378 .on_action(cx.listener(Self::open_diff))
4379 .on_action(cx.listener(Self::open_file))
4380 .on_action(cx.listener(Self::focus_changes_list))
4381 .on_action(cx.listener(Self::focus_editor))
4382 .on_action(cx.listener(Self::expand_commit_editor))
4383 .when(has_write_access && has_co_authors, |git_panel| {
4384 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
4385 })
4386 .on_hover(cx.listener(move |this, hovered, window, cx| {
4387 if *hovered {
4388 this.horizontal_scrollbar.show(cx);
4389 this.vertical_scrollbar.show(cx);
4390 cx.notify();
4391 } else if !this.focus_handle.contains_focused(window, cx) {
4392 this.hide_scrollbars(window, cx);
4393 }
4394 }))
4395 .size_full()
4396 .overflow_hidden()
4397 .bg(cx.theme().colors().panel_background)
4398 .child(
4399 v_flex()
4400 .size_full()
4401 .children(self.render_panel_header(window, cx))
4402 .map(|this| {
4403 if has_entries {
4404 this.child(self.render_entries(has_write_access, window, cx))
4405 } else {
4406 this.child(self.render_empty_state(cx).into_any_element())
4407 }
4408 })
4409 .children(self.render_footer(window, cx))
4410 .when(self.amend_pending, |this| {
4411 this.child(self.render_pending_amend(cx))
4412 })
4413 .when(!self.amend_pending, |this| {
4414 this.children(self.render_previous_commit(cx))
4415 })
4416 .into_any_element(),
4417 )
4418 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4419 deferred(
4420 anchored()
4421 .position(*position)
4422 .anchor(Corner::TopLeft)
4423 .child(menu.clone()),
4424 )
4425 .with_priority(1)
4426 }))
4427 }
4428}
4429
4430impl Focusable for GitPanel {
4431 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
4432 if self.entries.is_empty() {
4433 self.commit_editor.focus_handle(cx)
4434 } else {
4435 self.focus_handle.clone()
4436 }
4437 }
4438}
4439
4440impl EventEmitter<Event> for GitPanel {}
4441
4442impl EventEmitter<PanelEvent> for GitPanel {}
4443
4444pub(crate) struct GitPanelAddon {
4445 pub(crate) workspace: WeakEntity<Workspace>,
4446}
4447
4448impl editor::Addon for GitPanelAddon {
4449 fn to_any(&self) -> &dyn std::any::Any {
4450 self
4451 }
4452
4453 fn render_buffer_header_controls(
4454 &self,
4455 excerpt_info: &ExcerptInfo,
4456 window: &Window,
4457 cx: &App,
4458 ) -> Option<AnyElement> {
4459 let file = excerpt_info.buffer.file()?;
4460 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
4461
4462 git_panel
4463 .read(cx)
4464 .render_buffer_header_controls(&git_panel, &file, window, cx)
4465 }
4466}
4467
4468impl Panel for GitPanel {
4469 fn persistent_name() -> &'static str {
4470 "GitPanel"
4471 }
4472
4473 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4474 GitPanelSettings::get_global(cx).dock
4475 }
4476
4477 fn position_is_valid(&self, position: DockPosition) -> bool {
4478 matches!(position, DockPosition::Left | DockPosition::Right)
4479 }
4480
4481 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4482 settings::update_settings_file::<GitPanelSettings>(
4483 self.fs.clone(),
4484 cx,
4485 move |settings, _| settings.dock = Some(position),
4486 );
4487 }
4488
4489 fn size(&self, _: &Window, cx: &App) -> Pixels {
4490 self.width
4491 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
4492 }
4493
4494 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4495 self.width = size;
4496 self.serialize(cx);
4497 cx.notify();
4498 }
4499
4500 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
4501 Some(ui::IconName::GitBranchSmall).filter(|_| GitPanelSettings::get_global(cx).button)
4502 }
4503
4504 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4505 Some("Git Panel")
4506 }
4507
4508 fn toggle_action(&self) -> Box<dyn Action> {
4509 Box::new(ToggleFocus)
4510 }
4511
4512 fn activation_priority(&self) -> u32 {
4513 2
4514 }
4515}
4516
4517impl PanelHeader for GitPanel {}
4518
4519struct GitPanelMessageTooltip {
4520 commit_tooltip: Option<Entity<CommitTooltip>>,
4521}
4522
4523impl GitPanelMessageTooltip {
4524 fn new(
4525 git_panel: Entity<GitPanel>,
4526 sha: SharedString,
4527 repository: Entity<Repository>,
4528 window: &mut Window,
4529 cx: &mut App,
4530 ) -> Entity<Self> {
4531 cx.new(|cx| {
4532 cx.spawn_in(window, async move |this, cx| {
4533 let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
4534 (
4535 git_panel.load_commit_details(sha.to_string(), cx),
4536 git_panel.workspace.clone(),
4537 )
4538 })?;
4539 let details = details.await?;
4540
4541 let commit_details = crate::commit_tooltip::CommitDetails {
4542 sha: details.sha.clone(),
4543 author_name: details.author_name.clone(),
4544 author_email: details.author_email.clone(),
4545 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
4546 message: Some(ParsedCommitMessage {
4547 message: details.message.clone(),
4548 ..Default::default()
4549 }),
4550 };
4551
4552 this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
4553 this.commit_tooltip = Some(cx.new(move |cx| {
4554 CommitTooltip::new(commit_details, repository, workspace, cx)
4555 }));
4556 cx.notify();
4557 })
4558 })
4559 .detach();
4560
4561 Self {
4562 commit_tooltip: None,
4563 }
4564 })
4565 }
4566}
4567
4568impl Render for GitPanelMessageTooltip {
4569 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4570 if let Some(commit_tooltip) = &self.commit_tooltip {
4571 commit_tooltip.clone().into_any_element()
4572 } else {
4573 gpui::Empty.into_any_element()
4574 }
4575 }
4576}
4577
4578#[derive(IntoElement, RegisterComponent)]
4579pub struct PanelRepoFooter {
4580 active_repository: SharedString,
4581 branch: Option<Branch>,
4582 head_commit: Option<CommitDetails>,
4583
4584 // Getting a GitPanel in previews will be difficult.
4585 //
4586 // For now just take an option here, and we won't bind handlers to buttons in previews.
4587 git_panel: Option<Entity<GitPanel>>,
4588}
4589
4590impl PanelRepoFooter {
4591 pub fn new(
4592 active_repository: SharedString,
4593 branch: Option<Branch>,
4594 head_commit: Option<CommitDetails>,
4595 git_panel: Option<Entity<GitPanel>>,
4596 ) -> Self {
4597 Self {
4598 active_repository,
4599 branch,
4600 head_commit,
4601 git_panel,
4602 }
4603 }
4604
4605 pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
4606 Self {
4607 active_repository,
4608 branch,
4609 head_commit: None,
4610 git_panel: None,
4611 }
4612 }
4613}
4614
4615impl RenderOnce for PanelRepoFooter {
4616 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
4617 let project = self
4618 .git_panel
4619 .as_ref()
4620 .map(|panel| panel.read(cx).project.clone());
4621
4622 let repo = self
4623 .git_panel
4624 .as_ref()
4625 .and_then(|panel| panel.read(cx).active_repository.clone());
4626
4627 let single_repo = project
4628 .as_ref()
4629 .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
4630 .unwrap_or(true);
4631
4632 const MAX_BRANCH_LEN: usize = 16;
4633 const MAX_REPO_LEN: usize = 16;
4634 const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
4635 const MAX_SHORT_SHA_LEN: usize = 8;
4636
4637 let branch_name = self
4638 .branch
4639 .as_ref()
4640 .map(|branch| branch.name().to_owned())
4641 .or_else(|| {
4642 self.head_commit.as_ref().map(|commit| {
4643 commit
4644 .sha
4645 .chars()
4646 .take(MAX_SHORT_SHA_LEN)
4647 .collect::<String>()
4648 })
4649 })
4650 .unwrap_or_else(|| " (no branch)".to_owned());
4651 let show_separator = self.branch.is_some() || self.head_commit.is_some();
4652
4653 let active_repo_name = self.active_repository.clone();
4654
4655 let branch_actual_len = branch_name.len();
4656 let repo_actual_len = active_repo_name.len();
4657
4658 // ideally, show the whole branch and repo names but
4659 // when we can't, use a budget to allocate space between the two
4660 let (repo_display_len, branch_display_len) = if branch_actual_len + repo_actual_len
4661 <= LABEL_CHARACTER_BUDGET
4662 {
4663 (repo_actual_len, branch_actual_len)
4664 } else {
4665 if branch_actual_len <= MAX_BRANCH_LEN {
4666 let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
4667 (repo_space, branch_actual_len)
4668 } else if repo_actual_len <= MAX_REPO_LEN {
4669 let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
4670 (repo_actual_len, branch_space)
4671 } else {
4672 (MAX_REPO_LEN, MAX_BRANCH_LEN)
4673 }
4674 };
4675
4676 let truncated_repo_name = if repo_actual_len <= repo_display_len {
4677 active_repo_name.to_string()
4678 } else {
4679 util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
4680 };
4681
4682 let truncated_branch_name = if branch_actual_len <= branch_display_len {
4683 branch_name.to_string()
4684 } else {
4685 util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
4686 };
4687
4688 let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
4689 .style(ButtonStyle::Transparent)
4690 .size(ButtonSize::None)
4691 .label_size(LabelSize::Small)
4692 .color(Color::Muted);
4693
4694 let repo_selector = PopoverMenu::new("repository-switcher")
4695 .menu({
4696 let project = project.clone();
4697 move |window, cx| {
4698 let project = project.clone()?;
4699 Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
4700 }
4701 })
4702 .trigger_with_tooltip(
4703 repo_selector_trigger.disabled(single_repo).truncate(true),
4704 Tooltip::text("Switch Active Repository"),
4705 )
4706 .anchor(Corner::BottomLeft)
4707 .into_any_element();
4708
4709 let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
4710 .style(ButtonStyle::Transparent)
4711 .size(ButtonSize::None)
4712 .label_size(LabelSize::Small)
4713 .truncate(true)
4714 .tooltip(Tooltip::for_action_title(
4715 "Switch Branch",
4716 &zed_actions::git::Switch,
4717 ))
4718 .on_click(|_, window, cx| {
4719 window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
4720 });
4721
4722 let branch_selector = PopoverMenu::new("popover-button")
4723 .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
4724 .trigger_with_tooltip(
4725 branch_selector_button,
4726 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
4727 )
4728 .anchor(Corner::BottomLeft)
4729 .offset(gpui::Point {
4730 x: px(0.0),
4731 y: px(-2.0),
4732 });
4733
4734 h_flex()
4735 .w_full()
4736 .px_2()
4737 .h(px(36.))
4738 .items_center()
4739 .justify_between()
4740 .gap_1()
4741 .child(
4742 h_flex()
4743 .flex_1()
4744 .overflow_hidden()
4745 .items_center()
4746 .child(
4747 div().child(
4748 Icon::new(IconName::GitBranchSmall)
4749 .size(IconSize::Small)
4750 .color(if single_repo {
4751 Color::Disabled
4752 } else {
4753 Color::Muted
4754 }),
4755 ),
4756 )
4757 .child(repo_selector)
4758 .when(show_separator, |this| {
4759 this.child(
4760 div()
4761 .text_color(cx.theme().colors().text_muted)
4762 .text_sm()
4763 .child("/"),
4764 )
4765 })
4766 .child(branch_selector),
4767 )
4768 .children(if let Some(git_panel) = self.git_panel {
4769 git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
4770 } else {
4771 None
4772 })
4773 }
4774}
4775
4776impl Component for PanelRepoFooter {
4777 fn scope() -> ComponentScope {
4778 ComponentScope::VersionControl
4779 }
4780
4781 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
4782 let unknown_upstream = None;
4783 let no_remote_upstream = Some(UpstreamTracking::Gone);
4784 let ahead_of_upstream = Some(
4785 UpstreamTrackingStatus {
4786 ahead: 2,
4787 behind: 0,
4788 }
4789 .into(),
4790 );
4791 let behind_upstream = Some(
4792 UpstreamTrackingStatus {
4793 ahead: 0,
4794 behind: 2,
4795 }
4796 .into(),
4797 );
4798 let ahead_and_behind_upstream = Some(
4799 UpstreamTrackingStatus {
4800 ahead: 3,
4801 behind: 1,
4802 }
4803 .into(),
4804 );
4805
4806 let not_ahead_or_behind_upstream = Some(
4807 UpstreamTrackingStatus {
4808 ahead: 0,
4809 behind: 0,
4810 }
4811 .into(),
4812 );
4813
4814 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
4815 Branch {
4816 is_head: true,
4817 ref_name: "some-branch".into(),
4818 upstream: upstream.map(|tracking| Upstream {
4819 ref_name: "origin/some-branch".into(),
4820 tracking,
4821 }),
4822 most_recent_commit: Some(CommitSummary {
4823 sha: "abc123".into(),
4824 subject: "Modify stuff".into(),
4825 commit_timestamp: 1710932954,
4826 has_parent: true,
4827 }),
4828 }
4829 }
4830
4831 fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
4832 Branch {
4833 is_head: true,
4834 ref_name: branch_name.to_string().into(),
4835 upstream: upstream.map(|tracking| Upstream {
4836 ref_name: format!("zed/{}", branch_name).into(),
4837 tracking,
4838 }),
4839 most_recent_commit: Some(CommitSummary {
4840 sha: "abc123".into(),
4841 subject: "Modify stuff".into(),
4842 commit_timestamp: 1710932954,
4843 has_parent: true,
4844 }),
4845 }
4846 }
4847
4848 fn active_repository(id: usize) -> SharedString {
4849 format!("repo-{}", id).into()
4850 }
4851
4852 let example_width = px(340.);
4853 Some(
4854 v_flex()
4855 .gap_6()
4856 .w_full()
4857 .flex_none()
4858 .children(vec![
4859 example_group_with_title(
4860 "Action Button States",
4861 vec![
4862 single_example(
4863 "No Branch",
4864 div()
4865 .w(example_width)
4866 .overflow_hidden()
4867 .child(PanelRepoFooter::new_preview(
4868 active_repository(1).clone(),
4869 None,
4870 ))
4871 .into_any_element(),
4872 ),
4873 single_example(
4874 "Remote status unknown",
4875 div()
4876 .w(example_width)
4877 .overflow_hidden()
4878 .child(PanelRepoFooter::new_preview(
4879 active_repository(2).clone(),
4880 Some(branch(unknown_upstream)),
4881 ))
4882 .into_any_element(),
4883 ),
4884 single_example(
4885 "No Remote Upstream",
4886 div()
4887 .w(example_width)
4888 .overflow_hidden()
4889 .child(PanelRepoFooter::new_preview(
4890 active_repository(3).clone(),
4891 Some(branch(no_remote_upstream)),
4892 ))
4893 .into_any_element(),
4894 ),
4895 single_example(
4896 "Not Ahead or Behind",
4897 div()
4898 .w(example_width)
4899 .overflow_hidden()
4900 .child(PanelRepoFooter::new_preview(
4901 active_repository(4).clone(),
4902 Some(branch(not_ahead_or_behind_upstream)),
4903 ))
4904 .into_any_element(),
4905 ),
4906 single_example(
4907 "Behind remote",
4908 div()
4909 .w(example_width)
4910 .overflow_hidden()
4911 .child(PanelRepoFooter::new_preview(
4912 active_repository(5).clone(),
4913 Some(branch(behind_upstream)),
4914 ))
4915 .into_any_element(),
4916 ),
4917 single_example(
4918 "Ahead of remote",
4919 div()
4920 .w(example_width)
4921 .overflow_hidden()
4922 .child(PanelRepoFooter::new_preview(
4923 active_repository(6).clone(),
4924 Some(branch(ahead_of_upstream)),
4925 ))
4926 .into_any_element(),
4927 ),
4928 single_example(
4929 "Ahead and behind remote",
4930 div()
4931 .w(example_width)
4932 .overflow_hidden()
4933 .child(PanelRepoFooter::new_preview(
4934 active_repository(7).clone(),
4935 Some(branch(ahead_and_behind_upstream)),
4936 ))
4937 .into_any_element(),
4938 ),
4939 ],
4940 )
4941 .grow()
4942 .vertical(),
4943 ])
4944 .children(vec![
4945 example_group_with_title(
4946 "Labels",
4947 vec![
4948 single_example(
4949 "Short Branch & Repo",
4950 div()
4951 .w(example_width)
4952 .overflow_hidden()
4953 .child(PanelRepoFooter::new_preview(
4954 SharedString::from("zed"),
4955 Some(custom("main", behind_upstream)),
4956 ))
4957 .into_any_element(),
4958 ),
4959 single_example(
4960 "Long Branch",
4961 div()
4962 .w(example_width)
4963 .overflow_hidden()
4964 .child(PanelRepoFooter::new_preview(
4965 SharedString::from("zed"),
4966 Some(custom(
4967 "redesign-and-update-git-ui-list-entry-style",
4968 behind_upstream,
4969 )),
4970 ))
4971 .into_any_element(),
4972 ),
4973 single_example(
4974 "Long Repo",
4975 div()
4976 .w(example_width)
4977 .overflow_hidden()
4978 .child(PanelRepoFooter::new_preview(
4979 SharedString::from("zed-industries-community-examples"),
4980 Some(custom("gpui", ahead_of_upstream)),
4981 ))
4982 .into_any_element(),
4983 ),
4984 single_example(
4985 "Long Repo & Branch",
4986 div()
4987 .w(example_width)
4988 .overflow_hidden()
4989 .child(PanelRepoFooter::new_preview(
4990 SharedString::from("zed-industries-community-examples"),
4991 Some(custom(
4992 "redesign-and-update-git-ui-list-entry-style",
4993 behind_upstream,
4994 )),
4995 ))
4996 .into_any_element(),
4997 ),
4998 single_example(
4999 "Uppercase Repo",
5000 div()
5001 .w(example_width)
5002 .overflow_hidden()
5003 .child(PanelRepoFooter::new_preview(
5004 SharedString::from("LICENSES"),
5005 Some(custom("main", ahead_of_upstream)),
5006 ))
5007 .into_any_element(),
5008 ),
5009 single_example(
5010 "Uppercase Branch",
5011 div()
5012 .w(example_width)
5013 .overflow_hidden()
5014 .child(PanelRepoFooter::new_preview(
5015 SharedString::from("zed"),
5016 Some(custom("update-README", behind_upstream)),
5017 ))
5018 .into_any_element(),
5019 ),
5020 ],
5021 )
5022 .grow()
5023 .vertical(),
5024 ])
5025 .into_any_element(),
5026 )
5027 }
5028}
5029
5030#[cfg(test)]
5031mod tests {
5032 use git::status::{StatusCode, UnmergedStatus, UnmergedStatusCode};
5033 use gpui::{TestAppContext, VisualTestContext};
5034 use project::{FakeFs, WorktreeSettings};
5035 use serde_json::json;
5036 use settings::SettingsStore;
5037 use theme::LoadThemes;
5038 use util::path;
5039
5040 use super::*;
5041
5042 fn init_test(cx: &mut gpui::TestAppContext) {
5043 zlog::init_test();
5044
5045 cx.update(|cx| {
5046 let settings_store = SettingsStore::test(cx);
5047 cx.set_global(settings_store);
5048 AgentSettings::register(cx);
5049 WorktreeSettings::register(cx);
5050 workspace::init_settings(cx);
5051 theme::init(LoadThemes::JustBase, cx);
5052 language::init(cx);
5053 editor::init(cx);
5054 Project::init_settings(cx);
5055 crate::init(cx);
5056 });
5057 }
5058
5059 #[gpui::test]
5060 async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
5061 init_test(cx);
5062 let fs = FakeFs::new(cx.background_executor.clone());
5063 fs.insert_tree(
5064 "/root",
5065 json!({
5066 "zed": {
5067 ".git": {},
5068 "crates": {
5069 "gpui": {
5070 "gpui.rs": "fn main() {}"
5071 },
5072 "util": {
5073 "util.rs": "fn do_it() {}"
5074 }
5075 }
5076 },
5077 }),
5078 )
5079 .await;
5080
5081 fs.set_status_for_repo(
5082 Path::new(path!("/root/zed/.git")),
5083 &[
5084 (
5085 Path::new("crates/gpui/gpui.rs"),
5086 StatusCode::Modified.worktree(),
5087 ),
5088 (
5089 Path::new("crates/util/util.rs"),
5090 StatusCode::Modified.worktree(),
5091 ),
5092 ],
5093 );
5094
5095 let project =
5096 Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
5097 let workspace =
5098 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5099 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5100
5101 cx.read(|cx| {
5102 project
5103 .read(cx)
5104 .worktrees(cx)
5105 .nth(0)
5106 .unwrap()
5107 .read(cx)
5108 .as_local()
5109 .unwrap()
5110 .scan_complete()
5111 })
5112 .await;
5113
5114 cx.executor().run_until_parked();
5115
5116 let panel = workspace.update(cx, GitPanel::new).unwrap();
5117
5118 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5119 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5120 });
5121 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5122 handle.await;
5123
5124 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5125 pretty_assertions::assert_eq!(
5126 entries,
5127 [
5128 GitListEntry::Header(GitHeaderEntry {
5129 header: Section::Tracked
5130 }),
5131 GitListEntry::Status(GitStatusEntry {
5132 abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5133 repo_path: "crates/gpui/gpui.rs".into(),
5134 status: StatusCode::Modified.worktree(),
5135 staging: StageStatus::Unstaged,
5136 }),
5137 GitListEntry::Status(GitStatusEntry {
5138 abs_path: path!("/root/zed/crates/util/util.rs").into(),
5139 repo_path: "crates/util/util.rs".into(),
5140 status: StatusCode::Modified.worktree(),
5141 staging: StageStatus::Unstaged,
5142 },),
5143 ],
5144 );
5145
5146 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5147 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5148 });
5149 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5150 handle.await;
5151 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5152 pretty_assertions::assert_eq!(
5153 entries,
5154 [
5155 GitListEntry::Header(GitHeaderEntry {
5156 header: Section::Tracked
5157 }),
5158 GitListEntry::Status(GitStatusEntry {
5159 abs_path: path!("/root/zed/crates/gpui/gpui.rs").into(),
5160 repo_path: "crates/gpui/gpui.rs".into(),
5161 status: StatusCode::Modified.worktree(),
5162 staging: StageStatus::Unstaged,
5163 }),
5164 GitListEntry::Status(GitStatusEntry {
5165 abs_path: path!("/root/zed/crates/util/util.rs").into(),
5166 repo_path: "crates/util/util.rs".into(),
5167 status: StatusCode::Modified.worktree(),
5168 staging: StageStatus::Unstaged,
5169 },),
5170 ],
5171 );
5172 }
5173
5174 #[gpui::test]
5175 async fn test_bulk_staging(cx: &mut TestAppContext) {
5176 use GitListEntry::*;
5177
5178 init_test(cx);
5179 let fs = FakeFs::new(cx.background_executor.clone());
5180 fs.insert_tree(
5181 "/root",
5182 json!({
5183 "project": {
5184 ".git": {},
5185 "src": {
5186 "main.rs": "fn main() {}",
5187 "lib.rs": "pub fn hello() {}",
5188 "utils.rs": "pub fn util() {}"
5189 },
5190 "tests": {
5191 "test.rs": "fn test() {}"
5192 },
5193 "new_file.txt": "new content",
5194 "another_new.rs": "// new file",
5195 "conflict.txt": "conflicted content"
5196 }
5197 }),
5198 )
5199 .await;
5200
5201 fs.set_status_for_repo(
5202 Path::new(path!("/root/project/.git")),
5203 &[
5204 (Path::new("src/main.rs"), StatusCode::Modified.worktree()),
5205 (Path::new("src/lib.rs"), StatusCode::Modified.worktree()),
5206 (Path::new("tests/test.rs"), StatusCode::Modified.worktree()),
5207 (Path::new("new_file.txt"), FileStatus::Untracked),
5208 (Path::new("another_new.rs"), FileStatus::Untracked),
5209 (Path::new("src/utils.rs"), FileStatus::Untracked),
5210 (
5211 Path::new("conflict.txt"),
5212 UnmergedStatus {
5213 first_head: UnmergedStatusCode::Updated,
5214 second_head: UnmergedStatusCode::Updated,
5215 }
5216 .into(),
5217 ),
5218 ],
5219 );
5220
5221 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5222 let workspace =
5223 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5224 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5225
5226 cx.read(|cx| {
5227 project
5228 .read(cx)
5229 .worktrees(cx)
5230 .nth(0)
5231 .unwrap()
5232 .read(cx)
5233 .as_local()
5234 .unwrap()
5235 .scan_complete()
5236 })
5237 .await;
5238
5239 cx.executor().run_until_parked();
5240
5241 let panel = workspace.update(cx, GitPanel::new).unwrap();
5242
5243 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5244 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5245 });
5246 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5247 handle.await;
5248
5249 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5250 #[rustfmt::skip]
5251 pretty_assertions::assert_matches!(
5252 entries.as_slice(),
5253 &[
5254 Header(GitHeaderEntry { header: Section::Conflict }),
5255 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5256 Header(GitHeaderEntry { header: Section::Tracked }),
5257 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5258 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5259 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5260 Header(GitHeaderEntry { header: Section::New }),
5261 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5262 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5263 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5264 ],
5265 );
5266
5267 let second_status_entry = entries[3].clone();
5268 panel.update_in(cx, |panel, window, cx| {
5269 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
5270 });
5271
5272 panel.update_in(cx, |panel, window, cx| {
5273 panel.selected_entry = Some(7);
5274 panel.stage_range(&git::StageRange, window, cx);
5275 });
5276
5277 cx.read(|cx| {
5278 project
5279 .read(cx)
5280 .worktrees(cx)
5281 .nth(0)
5282 .unwrap()
5283 .read(cx)
5284 .as_local()
5285 .unwrap()
5286 .scan_complete()
5287 })
5288 .await;
5289
5290 cx.executor().run_until_parked();
5291
5292 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5293 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5294 });
5295 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5296 handle.await;
5297
5298 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5299 #[rustfmt::skip]
5300 pretty_assertions::assert_matches!(
5301 entries.as_slice(),
5302 &[
5303 Header(GitHeaderEntry { header: Section::Conflict }),
5304 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5305 Header(GitHeaderEntry { header: Section::Tracked }),
5306 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5307 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5308 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5309 Header(GitHeaderEntry { header: Section::New }),
5310 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5311 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5312 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5313 ],
5314 );
5315
5316 let third_status_entry = entries[4].clone();
5317 panel.update_in(cx, |panel, window, cx| {
5318 panel.toggle_staged_for_entry(&third_status_entry, window, cx);
5319 });
5320
5321 panel.update_in(cx, |panel, window, cx| {
5322 panel.selected_entry = Some(9);
5323 panel.stage_range(&git::StageRange, window, cx);
5324 });
5325
5326 cx.read(|cx| {
5327 project
5328 .read(cx)
5329 .worktrees(cx)
5330 .nth(0)
5331 .unwrap()
5332 .read(cx)
5333 .as_local()
5334 .unwrap()
5335 .scan_complete()
5336 })
5337 .await;
5338
5339 cx.executor().run_until_parked();
5340
5341 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5342 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5343 });
5344 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5345 handle.await;
5346
5347 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5348 #[rustfmt::skip]
5349 pretty_assertions::assert_matches!(
5350 entries.as_slice(),
5351 &[
5352 Header(GitHeaderEntry { header: Section::Conflict }),
5353 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5354 Header(GitHeaderEntry { header: Section::Tracked }),
5355 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5356 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5357 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5358 Header(GitHeaderEntry { header: Section::New }),
5359 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5360 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5361 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5362 ],
5363 );
5364 }
5365}