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