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