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