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