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