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