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