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