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