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