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