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