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