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