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