1use crate::git_panel_settings::StatusStyle;
2use crate::project_diff::Diff;
3use crate::repository_selector::RepositorySelectorPopoverMenu;
4use crate::{
5 git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
6};
7use crate::{picker_prompt, project_diff, ProjectDiff};
8use db::kvp::KEY_VALUE_STORE;
9use editor::commit_tooltip::CommitTooltip;
10use editor::{
11 scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
12 ShowScrollbar,
13};
14use git::repository::{Branch, CommitDetails, PushOptions, Remote, ResetMode, UpstreamTracking};
15use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
16use git::{Push, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll};
17use gpui::*;
18use itertools::Itertools;
19use language::{Buffer, File};
20use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
21use multi_buffer::ExcerptInfo;
22use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
23use project::{
24 git::{GitEvent, Repository},
25 Fs, Project, ProjectPath,
26};
27use serde::{Deserialize, Serialize};
28use settings::Settings as _;
29use std::cell::RefCell;
30use std::future::Future;
31use std::rc::Rc;
32use std::{collections::HashSet, sync::Arc, time::Duration, usize};
33use strum::{IntoEnumIterator, VariantNames};
34use time::OffsetDateTime;
35use ui::{
36 prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
37 ListItemSpacing, PopoverMenu, Scrollbar, ScrollbarState, Tooltip,
38};
39use util::{maybe, post_inc, ResultExt, TryFutureExt};
40use workspace::{
41 dock::{DockPosition, Panel, PanelEvent},
42 notifications::{DetachAndPromptErr, NotificationId},
43 Toast, Workspace,
44};
45
46actions!(
47 git_panel,
48 [
49 Close,
50 ToggleFocus,
51 OpenMenu,
52 FocusEditor,
53 FocusChanges,
54 ToggleFillCoAuthors,
55 ]
56);
57
58fn prompt<T>(msg: &str, detail: Option<&str>, window: &mut Window, cx: &mut App) -> Task<Result<T>>
59where
60 T: IntoEnumIterator + VariantNames + 'static,
61{
62 let rx = window.prompt(PromptLevel::Info, msg, detail, &T::VARIANTS, cx);
63 cx.spawn(|_| async move { Ok(T::iter().nth(rx.await?).unwrap()) })
64}
65
66#[derive(strum::EnumIter, strum::VariantNames)]
67#[strum(serialize_all = "title_case")]
68enum TrashCancel {
69 Trash,
70 Cancel,
71}
72
73const GIT_PANEL_KEY: &str = "GitPanel";
74
75const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
76
77pub fn init(cx: &mut App) {
78 cx.observe_new(
79 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
80 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
81 workspace.toggle_panel_focus::<GitPanel>(window, cx);
82 });
83
84 // workspace.register_action(|workspace, _: &Commit, window, cx| {
85 // workspace.open_panel::<GitPanel>(window, cx);
86 // if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
87 // git_panel
88 // .read(cx)
89 // .commit_editor
90 // .focus_handle(cx)
91 // .focus(window);
92 // }
93 // });
94 },
95 )
96 .detach();
97}
98
99#[derive(Debug, Clone)]
100pub enum Event {
101 Focus,
102}
103
104#[derive(Serialize, Deserialize)]
105struct SerializedGitPanel {
106 width: Option<Pixels>,
107}
108
109#[derive(Debug, PartialEq, Eq, Clone, Copy)]
110enum Section {
111 Conflict,
112 Tracked,
113 New,
114}
115
116#[derive(Debug, PartialEq, Eq, Clone)]
117struct GitHeaderEntry {
118 header: Section,
119}
120
121impl GitHeaderEntry {
122 pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
123 let this = &self.header;
124 let status = status_entry.status;
125 match this {
126 Section::Conflict => repo.has_conflict(&status_entry.repo_path),
127 Section::Tracked => !status.is_created(),
128 Section::New => status.is_created(),
129 }
130 }
131 pub fn title(&self) -> &'static str {
132 match self.header {
133 Section::Conflict => "Conflicts",
134 Section::Tracked => "Tracked",
135 Section::New => "Untracked",
136 }
137 }
138}
139
140#[derive(Debug, PartialEq, Eq, Clone)]
141enum GitListEntry {
142 GitStatusEntry(GitStatusEntry),
143 Header(GitHeaderEntry),
144}
145
146impl GitListEntry {
147 fn status_entry(&self) -> Option<&GitStatusEntry> {
148 match self {
149 GitListEntry::GitStatusEntry(entry) => Some(entry),
150 _ => None,
151 }
152 }
153}
154
155#[derive(Debug, PartialEq, Eq, Clone)]
156pub struct GitStatusEntry {
157 pub(crate) repo_path: RepoPath,
158 pub(crate) status: FileStatus,
159 pub(crate) is_staged: Option<bool>,
160}
161
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163enum TargetStatus {
164 Staged,
165 Unstaged,
166 Reverted,
167 Unchanged,
168}
169
170struct PendingOperation {
171 finished: bool,
172 target_status: TargetStatus,
173 repo_paths: HashSet<RepoPath>,
174 op_id: usize,
175}
176
177type RemoteOperations = Rc<RefCell<HashSet<u32>>>;
178
179pub struct GitPanel {
180 remote_operation_id: u32,
181 pending_remote_operations: RemoteOperations,
182 pub(crate) active_repository: Option<Entity<Repository>>,
183 commit_editor: Entity<Editor>,
184 suggested_commit_message: Option<String>,
185 conflicted_count: usize,
186 conflicted_staged_count: usize,
187 current_modifiers: Modifiers,
188 add_coauthors: bool,
189 entries: Vec<GitListEntry>,
190 focus_handle: FocusHandle,
191 fs: Arc<dyn Fs>,
192 hide_scrollbar_task: Option<Task<()>>,
193 new_count: usize,
194 new_staged_count: usize,
195 pending: Vec<PendingOperation>,
196 pending_commit: Option<Task<()>>,
197 pending_serialization: Task<Option<()>>,
198 pub(crate) project: Entity<Project>,
199 repository_selector: Entity<RepositorySelector>,
200 scroll_handle: UniformListScrollHandle,
201 scrollbar_state: ScrollbarState,
202 selected_entry: Option<usize>,
203 show_scrollbar: bool,
204 tracked_count: usize,
205 tracked_staged_count: usize,
206 update_visible_entries_task: Task<()>,
207 width: Option<Pixels>,
208 workspace: WeakEntity<Workspace>,
209 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
210 modal_open: bool,
211}
212
213struct RemoteOperationGuard {
214 id: u32,
215 pending_remote_operations: RemoteOperations,
216}
217
218impl Drop for RemoteOperationGuard {
219 fn drop(&mut self) {
220 self.pending_remote_operations.borrow_mut().remove(&self.id);
221 }
222}
223
224pub(crate) fn commit_message_editor(
225 commit_message_buffer: Entity<Buffer>,
226 project: Entity<Project>,
227 in_panel: bool,
228 window: &mut Window,
229 cx: &mut Context<'_, Editor>,
230) -> Editor {
231 let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
232 let max_lines = if in_panel { 6 } else { 18 };
233 let mut commit_editor = Editor::new(
234 EditorMode::AutoHeight { max_lines },
235 buffer,
236 None,
237 false,
238 window,
239 cx,
240 );
241 commit_editor.set_collaboration_hub(Box::new(project));
242 commit_editor.set_use_autoclose(false);
243 commit_editor.set_show_gutter(false, cx);
244 commit_editor.set_show_wrap_guides(false, cx);
245 commit_editor.set_show_indent_guides(false, cx);
246 commit_editor.set_placeholder_text("Enter commit message", cx);
247 commit_editor
248}
249
250impl GitPanel {
251 pub fn new(
252 workspace: &mut Workspace,
253 window: &mut Window,
254 cx: &mut Context<Workspace>,
255 ) -> Entity<Self> {
256 let fs = workspace.app_state().fs.clone();
257 let project = workspace.project().clone();
258 let git_store = project.read(cx).git_store().clone();
259 let active_repository = project.read(cx).active_repository(cx);
260 let workspace = cx.entity().downgrade();
261
262 cx.new(|cx| {
263 let focus_handle = cx.focus_handle();
264 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
265 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
266 this.hide_scrollbar(window, cx);
267 })
268 .detach();
269
270 // just to let us render a placeholder editor.
271 // Once the active git repo is set, this buffer will be replaced.
272 let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
273 let commit_editor = cx.new(|cx| {
274 commit_message_editor(temporary_buffer, project.clone(), true, window, cx)
275 });
276 commit_editor.update(cx, |editor, cx| {
277 editor.clear(window, cx);
278 });
279
280 let scroll_handle = UniformListScrollHandle::new();
281
282 cx.subscribe_in(
283 &git_store,
284 window,
285 move |this, git_store, event, window, cx| match event {
286 GitEvent::FileSystemUpdated => {
287 this.schedule_update(false, window, cx);
288 }
289 GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
290 this.active_repository = git_store.read(cx).active_repository();
291 this.schedule_update(true, window, cx);
292 }
293 },
294 )
295 .detach();
296
297 let scrollbar_state =
298 ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
299
300 let repository_selector =
301 cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
302
303 let mut git_panel = Self {
304 pending_remote_operations: Default::default(),
305 remote_operation_id: 0,
306 active_repository,
307 commit_editor,
308 suggested_commit_message: None,
309 conflicted_count: 0,
310 conflicted_staged_count: 0,
311 current_modifiers: window.modifiers(),
312 add_coauthors: true,
313 entries: Vec::new(),
314 focus_handle: cx.focus_handle(),
315 fs,
316 hide_scrollbar_task: None,
317 new_count: 0,
318 new_staged_count: 0,
319 pending: Vec::new(),
320 pending_commit: None,
321 pending_serialization: Task::ready(None),
322 project,
323 repository_selector,
324 scroll_handle,
325 scrollbar_state,
326 selected_entry: None,
327 show_scrollbar: false,
328 tracked_count: 0,
329 tracked_staged_count: 0,
330 update_visible_entries_task: Task::ready(()),
331 width: Some(px(360.)),
332 context_menu: None,
333 workspace,
334 modal_open: false,
335 };
336 git_panel.schedule_update(false, window, cx);
337 git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
338 git_panel
339 })
340 }
341
342 pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
343 fn binary_search<F>(mut low: usize, mut high: usize, is_target: F) -> Option<usize>
344 where
345 F: Fn(usize) -> std::cmp::Ordering,
346 {
347 while low < high {
348 let mid = low + (high - low) / 2;
349 match is_target(mid) {
350 std::cmp::Ordering::Equal => return Some(mid),
351 std::cmp::Ordering::Less => low = mid + 1,
352 std::cmp::Ordering::Greater => high = mid,
353 }
354 }
355 None
356 }
357 if self.conflicted_count > 0 {
358 let conflicted_start = 1;
359 if let Some(ix) = binary_search(
360 conflicted_start,
361 conflicted_start + self.conflicted_count,
362 |ix| {
363 self.entries[ix]
364 .status_entry()
365 .unwrap()
366 .repo_path
367 .cmp(&path)
368 },
369 ) {
370 return Some(ix);
371 }
372 }
373 if self.tracked_count > 0 {
374 let tracked_start = if self.conflicted_count > 0 {
375 1 + self.conflicted_count
376 } else {
377 0
378 } + 1;
379 if let Some(ix) =
380 binary_search(tracked_start, tracked_start + self.tracked_count, |ix| {
381 self.entries[ix]
382 .status_entry()
383 .unwrap()
384 .repo_path
385 .cmp(&path)
386 })
387 {
388 return Some(ix);
389 }
390 }
391 if self.new_count > 0 {
392 let untracked_start = if self.conflicted_count > 0 {
393 1 + self.conflicted_count
394 } else {
395 0
396 } + if self.tracked_count > 0 {
397 1 + self.tracked_count
398 } else {
399 0
400 } + 1;
401 if let Some(ix) =
402 binary_search(untracked_start, untracked_start + self.new_count, |ix| {
403 self.entries[ix]
404 .status_entry()
405 .unwrap()
406 .repo_path
407 .cmp(&path)
408 })
409 {
410 return Some(ix);
411 }
412 }
413 None
414 }
415
416 pub fn select_entry_by_path(
417 &mut self,
418 path: ProjectPath,
419 _: &mut Window,
420 cx: &mut Context<Self>,
421 ) {
422 let Some(git_repo) = self.active_repository.as_ref() else {
423 return;
424 };
425 let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
426 return;
427 };
428 let Some(ix) = self.entry_by_path(&repo_path) else {
429 return;
430 };
431 self.selected_entry = Some(ix);
432 cx.notify();
433 }
434
435 fn start_remote_operation(&mut self) -> RemoteOperationGuard {
436 let id = post_inc(&mut self.remote_operation_id);
437 self.pending_remote_operations.borrow_mut().insert(id);
438
439 RemoteOperationGuard {
440 id,
441 pending_remote_operations: self.pending_remote_operations.clone(),
442 }
443 }
444
445 fn serialize(&mut self, cx: &mut Context<Self>) {
446 let width = self.width;
447 self.pending_serialization = cx.background_spawn(
448 async move {
449 KEY_VALUE_STORE
450 .write_kvp(
451 GIT_PANEL_KEY.into(),
452 serde_json::to_string(&SerializedGitPanel { width })?,
453 )
454 .await?;
455 anyhow::Ok(())
456 }
457 .log_err(),
458 );
459 }
460
461 pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
462 self.modal_open = open;
463 cx.notify();
464 }
465
466 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
467 let mut dispatch_context = KeyContext::new_with_defaults();
468 dispatch_context.add("GitPanel");
469
470 if self.is_focused(window, cx) {
471 dispatch_context.add("menu");
472 dispatch_context.add("ChangesList");
473 }
474
475 if self.commit_editor.read(cx).is_focused(window) {
476 dispatch_context.add("CommitEditor");
477 }
478
479 dispatch_context
480 }
481
482 fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
483 window
484 .focused(cx)
485 .map_or(false, |focused| self.focus_handle == focused)
486 }
487
488 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
489 cx.emit(PanelEvent::Close);
490 }
491
492 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
493 if !self.focus_handle.contains_focused(window, cx) {
494 cx.emit(Event::Focus);
495 }
496 }
497
498 fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
499 GitPanelSettings::get_global(cx)
500 .scrollbar
501 .show
502 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
503 }
504
505 fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
506 let show = self.show_scrollbar(cx);
507 match show {
508 ShowScrollbar::Auto => true,
509 ShowScrollbar::System => true,
510 ShowScrollbar::Always => true,
511 ShowScrollbar::Never => false,
512 }
513 }
514
515 fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
516 let show = self.show_scrollbar(cx);
517 match show {
518 ShowScrollbar::Auto => true,
519 ShowScrollbar::System => cx
520 .try_global::<ScrollbarAutoHide>()
521 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
522 ShowScrollbar::Always => false,
523 ShowScrollbar::Never => true,
524 }
525 }
526
527 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
528 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
529 if !self.should_autohide_scrollbar(cx) {
530 return;
531 }
532 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
533 cx.background_executor()
534 .timer(SCROLLBAR_SHOW_INTERVAL)
535 .await;
536 panel
537 .update(&mut cx, |panel, cx| {
538 panel.show_scrollbar = false;
539 cx.notify();
540 })
541 .log_err();
542 }))
543 }
544
545 fn handle_modifiers_changed(
546 &mut self,
547 event: &ModifiersChangedEvent,
548 _: &mut Window,
549 cx: &mut Context<Self>,
550 ) {
551 self.current_modifiers = event.modifiers;
552 cx.notify();
553 }
554
555 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
556 if let Some(selected_entry) = self.selected_entry {
557 self.scroll_handle
558 .scroll_to_item(selected_entry, ScrollStrategy::Center);
559 }
560
561 cx.notify();
562 }
563
564 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
565 if self.entries.first().is_some() {
566 self.selected_entry = Some(1);
567 self.scroll_to_selected_entry(cx);
568 }
569 }
570
571 fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
572 let item_count = self.entries.len();
573 if item_count == 0 {
574 return;
575 }
576
577 if let Some(selected_entry) = self.selected_entry {
578 let new_selected_entry = if selected_entry > 0 {
579 selected_entry - 1
580 } else {
581 selected_entry
582 };
583
584 if matches!(
585 self.entries.get(new_selected_entry),
586 Some(GitListEntry::Header(..))
587 ) {
588 if new_selected_entry > 0 {
589 self.selected_entry = Some(new_selected_entry - 1)
590 }
591 } else {
592 self.selected_entry = Some(new_selected_entry);
593 }
594
595 self.scroll_to_selected_entry(cx);
596 }
597
598 cx.notify();
599 }
600
601 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
602 let item_count = self.entries.len();
603 if item_count == 0 {
604 return;
605 }
606
607 if let Some(selected_entry) = self.selected_entry {
608 let new_selected_entry = if selected_entry < item_count - 1 {
609 selected_entry + 1
610 } else {
611 selected_entry
612 };
613 if matches!(
614 self.entries.get(new_selected_entry),
615 Some(GitListEntry::Header(..))
616 ) {
617 self.selected_entry = Some(new_selected_entry + 1);
618 } else {
619 self.selected_entry = Some(new_selected_entry);
620 }
621
622 self.scroll_to_selected_entry(cx);
623 }
624
625 cx.notify();
626 }
627
628 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
629 if self.entries.last().is_some() {
630 self.selected_entry = Some(self.entries.len() - 1);
631 self.scroll_to_selected_entry(cx);
632 }
633 }
634
635 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
636 self.commit_editor.update(cx, |editor, cx| {
637 window.focus(&editor.focus_handle(cx));
638 });
639 cx.notify();
640 }
641
642 fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
643 let have_entries = self
644 .active_repository
645 .as_ref()
646 .map_or(false, |active_repository| {
647 active_repository.read(cx).entry_count() > 0
648 });
649 if have_entries && self.selected_entry.is_none() {
650 self.selected_entry = Some(1);
651 self.scroll_to_selected_entry(cx);
652 cx.notify();
653 }
654 }
655
656 fn focus_changes_list(
657 &mut self,
658 _: &FocusChanges,
659 window: &mut Window,
660 cx: &mut Context<Self>,
661 ) {
662 self.select_first_entry_if_none(cx);
663
664 cx.focus_self(window);
665 cx.notify();
666 }
667
668 fn get_selected_entry(&self) -> Option<&GitListEntry> {
669 self.selected_entry.and_then(|i| self.entries.get(i))
670 }
671
672 fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
673 maybe!({
674 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
675
676 self.workspace
677 .update(cx, |workspace, cx| {
678 ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
679 })
680 .ok()
681 });
682 }
683
684 fn open_file(
685 &mut self,
686 _: &menu::SecondaryConfirm,
687 window: &mut Window,
688 cx: &mut Context<Self>,
689 ) {
690 maybe!({
691 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
692 let active_repo = self.active_repository.as_ref()?;
693 let path = active_repo
694 .read(cx)
695 .repo_path_to_project_path(&entry.repo_path)?;
696 if entry.status.is_deleted() {
697 return None;
698 }
699
700 self.workspace
701 .update(cx, |workspace, cx| {
702 workspace
703 .open_path_preview(path, None, false, false, window, cx)
704 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
705 Some(format!("{e}"))
706 });
707 })
708 .ok()
709 });
710 }
711
712 fn revert_selected(
713 &mut self,
714 _: &git::RestoreFile,
715 window: &mut Window,
716 cx: &mut Context<Self>,
717 ) {
718 maybe!({
719 let list_entry = self.entries.get(self.selected_entry?)?.clone();
720 let entry = list_entry.status_entry()?;
721 self.revert_entry(&entry, window, cx);
722 Some(())
723 });
724 }
725
726 fn revert_entry(
727 &mut self,
728 entry: &GitStatusEntry,
729 window: &mut Window,
730 cx: &mut Context<Self>,
731 ) {
732 maybe!({
733 let active_repo = self.active_repository.clone()?;
734 let path = active_repo
735 .read(cx)
736 .repo_path_to_project_path(&entry.repo_path)?;
737 let workspace = self.workspace.clone();
738
739 if entry.status.is_staged() != Some(false) {
740 self.perform_stage(false, vec![entry.repo_path.clone()], cx);
741 }
742 let filename = path.path.file_name()?.to_string_lossy();
743
744 if !entry.status.is_created() {
745 self.perform_checkout(vec![entry.repo_path.clone()], cx);
746 } else {
747 let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
748 cx.spawn_in(window, |_, mut cx| async move {
749 match prompt.await? {
750 TrashCancel::Trash => {}
751 TrashCancel::Cancel => return Ok(()),
752 }
753 let task = workspace.update(&mut cx, |workspace, cx| {
754 workspace
755 .project()
756 .update(cx, |project, cx| project.delete_file(path, true, cx))
757 })?;
758 if let Some(task) = task {
759 task.await?;
760 }
761 Ok(())
762 })
763 .detach_and_prompt_err(
764 "Failed to trash file",
765 window,
766 cx,
767 |e, _, _| Some(format!("{e}")),
768 );
769 }
770 Some(())
771 });
772 }
773
774 fn perform_checkout(&mut self, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
775 let workspace = self.workspace.clone();
776 let Some(active_repository) = self.active_repository.clone() else {
777 return;
778 };
779
780 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
781 self.pending.push(PendingOperation {
782 op_id,
783 target_status: TargetStatus::Reverted,
784 repo_paths: repo_paths.iter().cloned().collect(),
785 finished: false,
786 });
787 self.update_visible_entries(cx);
788 let task = cx.spawn(|_, mut cx| async move {
789 let tasks: Vec<_> = workspace.update(&mut cx, |workspace, cx| {
790 workspace.project().update(cx, |project, cx| {
791 repo_paths
792 .iter()
793 .filter_map(|repo_path| {
794 let path = active_repository
795 .read(cx)
796 .repo_path_to_project_path(&repo_path)?;
797 Some(project.open_buffer(path, cx))
798 })
799 .collect()
800 })
801 })?;
802
803 let buffers = futures::future::join_all(tasks).await;
804
805 active_repository
806 .update(&mut cx, |repo, _| repo.checkout_files("HEAD", repo_paths))?
807 .await??;
808
809 let tasks: Vec<_> = cx.update(|cx| {
810 buffers
811 .iter()
812 .filter_map(|buffer| {
813 buffer.as_ref().ok()?.update(cx, |buffer, cx| {
814 buffer.is_dirty().then(|| buffer.reload(cx))
815 })
816 })
817 .collect()
818 })?;
819
820 futures::future::join_all(tasks).await;
821
822 Ok(())
823 });
824
825 cx.spawn(|this, mut cx| async move {
826 let result = task.await;
827
828 this.update(&mut cx, |this, cx| {
829 for pending in this.pending.iter_mut() {
830 if pending.op_id == op_id {
831 pending.finished = true;
832 if result.is_err() {
833 pending.target_status = TargetStatus::Unchanged;
834 this.update_visible_entries(cx);
835 }
836 break;
837 }
838 }
839 result
840 .map_err(|e| {
841 this.show_err_toast(e, cx);
842 })
843 .ok();
844 })
845 .ok();
846 })
847 .detach();
848 }
849
850 fn discard_tracked_changes(
851 &mut self,
852 _: &RestoreTrackedFiles,
853 window: &mut Window,
854 cx: &mut Context<Self>,
855 ) {
856 let entries = self
857 .entries
858 .iter()
859 .filter_map(|entry| entry.status_entry().cloned())
860 .filter(|status_entry| !status_entry.status.is_created())
861 .collect::<Vec<_>>();
862
863 match entries.len() {
864 0 => return,
865 1 => return self.revert_entry(&entries[0], window, cx),
866 _ => {}
867 }
868 let details = entries
869 .iter()
870 .filter_map(|entry| entry.repo_path.0.file_name())
871 .map(|filename| filename.to_string_lossy())
872 .join("\n");
873
874 #[derive(strum::EnumIter, strum::VariantNames)]
875 #[strum(serialize_all = "title_case")]
876 enum DiscardCancel {
877 DiscardTrackedChanges,
878 Cancel,
879 }
880 let prompt = prompt(
881 "Discard changes to these files?",
882 Some(&details),
883 window,
884 cx,
885 );
886 cx.spawn(|this, mut cx| async move {
887 match prompt.await {
888 Ok(DiscardCancel::DiscardTrackedChanges) => {
889 this.update(&mut cx, |this, cx| {
890 let repo_paths = entries.into_iter().map(|entry| entry.repo_path).collect();
891 this.perform_checkout(repo_paths, cx);
892 })
893 .ok();
894 }
895 _ => {
896 return;
897 }
898 }
899 })
900 .detach();
901 }
902
903 fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
904 let workspace = self.workspace.clone();
905 let Some(active_repo) = self.active_repository.clone() else {
906 return;
907 };
908 let to_delete = self
909 .entries
910 .iter()
911 .filter_map(|entry| entry.status_entry())
912 .filter(|status_entry| status_entry.status.is_created())
913 .cloned()
914 .collect::<Vec<_>>();
915
916 match to_delete.len() {
917 0 => return,
918 1 => return self.revert_entry(&to_delete[0], window, cx),
919 _ => {}
920 };
921
922 let details = to_delete
923 .iter()
924 .map(|entry| {
925 entry
926 .repo_path
927 .0
928 .file_name()
929 .map(|f| f.to_string_lossy())
930 .unwrap_or_default()
931 })
932 .join("\n");
933
934 let prompt = prompt("Trash these files?", Some(&details), window, cx);
935 cx.spawn_in(window, |this, mut cx| async move {
936 match prompt.await? {
937 TrashCancel::Trash => {}
938 TrashCancel::Cancel => return Ok(()),
939 }
940 let tasks = workspace.update(&mut cx, |workspace, cx| {
941 to_delete
942 .iter()
943 .filter_map(|entry| {
944 workspace.project().update(cx, |project, cx| {
945 let project_path = active_repo
946 .read(cx)
947 .repo_path_to_project_path(&entry.repo_path)?;
948 project.delete_file(project_path, true, cx)
949 })
950 })
951 .collect::<Vec<_>>()
952 })?;
953 let to_unstage = to_delete
954 .into_iter()
955 .filter_map(|entry| {
956 if entry.status.is_staged() != Some(false) {
957 Some(entry.repo_path.clone())
958 } else {
959 None
960 }
961 })
962 .collect();
963 this.update(&mut cx, |this, cx| {
964 this.perform_stage(false, to_unstage, cx)
965 })?;
966 for task in tasks {
967 task.await?;
968 }
969 Ok(())
970 })
971 .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
972 Some(format!("{e}"))
973 });
974 }
975
976 fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
977 let repo_paths = self
978 .entries
979 .iter()
980 .filter_map(|entry| entry.status_entry())
981 .filter(|status_entry| status_entry.is_staged != Some(true))
982 .map(|status_entry| status_entry.repo_path.clone())
983 .collect::<Vec<_>>();
984 self.perform_stage(true, repo_paths, cx);
985 }
986
987 fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
988 let repo_paths = self
989 .entries
990 .iter()
991 .filter_map(|entry| entry.status_entry())
992 .filter(|status_entry| status_entry.is_staged != Some(false))
993 .map(|status_entry| status_entry.repo_path.clone())
994 .collect::<Vec<_>>();
995 self.perform_stage(false, repo_paths, cx);
996 }
997
998 fn toggle_staged_for_entry(
999 &mut self,
1000 entry: &GitListEntry,
1001 _window: &mut Window,
1002 cx: &mut Context<Self>,
1003 ) {
1004 let Some(active_repository) = self.active_repository.as_ref() else {
1005 return;
1006 };
1007 let (stage, repo_paths) = match entry {
1008 GitListEntry::GitStatusEntry(status_entry) => {
1009 if status_entry.status.is_staged().unwrap_or(false) {
1010 (false, vec![status_entry.repo_path.clone()])
1011 } else {
1012 (true, vec![status_entry.repo_path.clone()])
1013 }
1014 }
1015 GitListEntry::Header(section) => {
1016 let goal_staged_state = !self.header_state(section.header).selected();
1017 let repository = active_repository.read(cx);
1018 let entries = self
1019 .entries
1020 .iter()
1021 .filter_map(|entry| entry.status_entry())
1022 .filter(|status_entry| {
1023 section.contains(&status_entry, repository)
1024 && status_entry.is_staged != Some(goal_staged_state)
1025 })
1026 .map(|status_entry| status_entry.repo_path.clone())
1027 .collect::<Vec<_>>();
1028
1029 (goal_staged_state, entries)
1030 }
1031 };
1032 self.perform_stage(stage, repo_paths, cx);
1033 }
1034
1035 fn perform_stage(&mut self, stage: bool, repo_paths: Vec<RepoPath>, cx: &mut Context<Self>) {
1036 let Some(active_repository) = self.active_repository.clone() else {
1037 return;
1038 };
1039 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
1040 self.pending.push(PendingOperation {
1041 op_id,
1042 target_status: if stage {
1043 TargetStatus::Staged
1044 } else {
1045 TargetStatus::Unstaged
1046 },
1047 repo_paths: repo_paths.iter().cloned().collect(),
1048 finished: false,
1049 });
1050 let repo_paths = repo_paths.clone();
1051 let repository = active_repository.read(cx);
1052 self.update_counts(repository);
1053 cx.notify();
1054
1055 cx.spawn({
1056 |this, mut cx| async move {
1057 let result = cx
1058 .update(|cx| {
1059 if stage {
1060 active_repository
1061 .update(cx, |repo, cx| repo.stage_entries(repo_paths.clone(), cx))
1062 } else {
1063 active_repository
1064 .update(cx, |repo, cx| repo.unstage_entries(repo_paths.clone(), cx))
1065 }
1066 })?
1067 .await;
1068
1069 this.update(&mut cx, |this, cx| {
1070 for pending in this.pending.iter_mut() {
1071 if pending.op_id == op_id {
1072 pending.finished = true
1073 }
1074 }
1075 result
1076 .map_err(|e| {
1077 this.show_err_toast(e, cx);
1078 })
1079 .ok();
1080 cx.notify();
1081 })
1082 }
1083 })
1084 .detach();
1085 }
1086
1087 pub fn total_staged_count(&self) -> usize {
1088 self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1089 }
1090
1091 pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1092 self.commit_editor
1093 .read(cx)
1094 .buffer()
1095 .read(cx)
1096 .as_singleton()
1097 .unwrap()
1098 .clone()
1099 }
1100
1101 fn toggle_staged_for_selected(
1102 &mut self,
1103 _: &git::ToggleStaged,
1104 window: &mut Window,
1105 cx: &mut Context<Self>,
1106 ) {
1107 if let Some(selected_entry) = self.get_selected_entry().cloned() {
1108 self.toggle_staged_for_entry(&selected_entry, window, cx);
1109 }
1110 }
1111
1112 fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
1113 if self
1114 .commit_editor
1115 .focus_handle(cx)
1116 .contains_focused(window, cx)
1117 {
1118 self.commit_changes(window, cx)
1119 }
1120 cx.propagate();
1121 }
1122
1123 pub(crate) fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1124 let Some(active_repository) = self.active_repository.clone() else {
1125 return;
1126 };
1127 let error_spawn = |message, window: &mut Window, cx: &mut App| {
1128 let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
1129 cx.spawn(|_| async move {
1130 prompt.await.ok();
1131 })
1132 .detach();
1133 };
1134
1135 if self.has_unstaged_conflicts() {
1136 error_spawn(
1137 "There are still conflicts. You must stage these before committing",
1138 window,
1139 cx,
1140 );
1141 return;
1142 }
1143
1144 let mut message = self.commit_editor.read(cx).text(cx);
1145 if message.trim().is_empty() {
1146 self.commit_editor.read(cx).focus_handle(cx).focus(window);
1147 return;
1148 }
1149 if self.add_coauthors {
1150 self.fill_co_authors(&mut message, cx);
1151 }
1152
1153 let task = if self.has_staged_changes() {
1154 // Repository serializes all git operations, so we can just send a commit immediately
1155 let commit_task = active_repository.read(cx).commit(message.into(), None);
1156 cx.background_spawn(async move { commit_task.await? })
1157 } else {
1158 let changed_files = self
1159 .entries
1160 .iter()
1161 .filter_map(|entry| entry.status_entry())
1162 .filter(|status_entry| !status_entry.status.is_created())
1163 .map(|status_entry| status_entry.repo_path.clone())
1164 .collect::<Vec<_>>();
1165
1166 if changed_files.is_empty() {
1167 error_spawn("No changes to commit", window, cx);
1168 return;
1169 }
1170
1171 let stage_task =
1172 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
1173 cx.spawn(|_, mut cx| async move {
1174 stage_task.await?;
1175 let commit_task = active_repository
1176 .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
1177 commit_task.await?
1178 })
1179 };
1180 let task = cx.spawn_in(window, |this, mut cx| async move {
1181 let result = task.await;
1182 this.update_in(&mut cx, |this, window, cx| {
1183 this.pending_commit.take();
1184 match result {
1185 Ok(()) => {
1186 this.commit_editor
1187 .update(cx, |editor, cx| editor.clear(window, cx));
1188 }
1189 Err(e) => this.show_err_toast(e, cx),
1190 }
1191 })
1192 .ok();
1193 });
1194
1195 self.pending_commit = Some(task);
1196 }
1197
1198 fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1199 let Some(repo) = self.active_repository.clone() else {
1200 return;
1201 };
1202
1203 // TODO: Use git merge-base to find the upstream and main branch split
1204 let confirmation = Task::ready(true);
1205 // let confirmation = if self.commit_editor.read(cx).is_empty(cx) {
1206 // Task::ready(true)
1207 // } else {
1208 // let prompt = window.prompt(
1209 // PromptLevel::Warning,
1210 // "Uncomitting will replace the current commit message with the previous commit's message",
1211 // None,
1212 // &["Ok", "Cancel"],
1213 // cx,
1214 // );
1215 // cx.spawn(|_, _| async move { prompt.await.is_ok_and(|i| i == 0) })
1216 // };
1217
1218 let prior_head = self.load_commit_details("HEAD", cx);
1219
1220 let task = cx.spawn_in(window, |this, mut cx| async move {
1221 let result = maybe!(async {
1222 if !confirmation.await {
1223 Ok(None)
1224 } else {
1225 let prior_head = prior_head.await?;
1226
1227 repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
1228 .await??;
1229
1230 Ok(Some(prior_head))
1231 }
1232 })
1233 .await;
1234
1235 this.update_in(&mut cx, |this, window, cx| {
1236 this.pending_commit.take();
1237 match result {
1238 Ok(None) => {}
1239 Ok(Some(prior_commit)) => {
1240 this.commit_editor.update(cx, |editor, cx| {
1241 editor.set_text(prior_commit.message, window, cx)
1242 });
1243 }
1244 Err(e) => this.show_err_toast(e, cx),
1245 }
1246 })
1247 .ok();
1248 });
1249
1250 self.pending_commit = Some(task);
1251 }
1252
1253 /// Suggests a commit message based on the changed files and their statuses
1254 pub fn suggest_commit_message(&self) -> Option<String> {
1255 let entries = self
1256 .entries
1257 .iter()
1258 .filter_map(|entry| {
1259 if let GitListEntry::GitStatusEntry(status_entry) = entry {
1260 Some(status_entry)
1261 } else {
1262 None
1263 }
1264 })
1265 .collect::<Vec<&GitStatusEntry>>();
1266
1267 if entries.is_empty() {
1268 None
1269 } else if entries.len() == 1 {
1270 let entry = &entries[0];
1271 let file_name = entry
1272 .repo_path
1273 .file_name()
1274 .unwrap_or_default()
1275 .to_string_lossy();
1276
1277 if entry.status.is_deleted() {
1278 Some(format!("Delete {}", file_name))
1279 } else if entry.status.is_created() {
1280 Some(format!("Create {}", file_name))
1281 } else if entry.status.is_modified() {
1282 Some(format!("Update {}", file_name))
1283 } else {
1284 None
1285 }
1286 } else {
1287 None
1288 }
1289 }
1290
1291 fn update_editor_placeholder(&mut self, cx: &mut Context<Self>) {
1292 let suggested_commit_message = self.suggest_commit_message();
1293 self.suggested_commit_message = suggested_commit_message.clone();
1294
1295 if let Some(suggested_commit_message) = suggested_commit_message {
1296 self.commit_editor.update(cx, |editor, cx| {
1297 editor.set_placeholder_text(Arc::from(suggested_commit_message), cx)
1298 });
1299 }
1300
1301 cx.notify();
1302 }
1303
1304 fn fetch(&mut self, _: &git::Fetch, _window: &mut Window, cx: &mut Context<Self>) {
1305 let Some(repo) = self.active_repository.clone() else {
1306 return;
1307 };
1308 let guard = self.start_remote_operation();
1309 let fetch = repo.read(cx).fetch();
1310 cx.spawn(|_, _| async move {
1311 fetch.await??;
1312 drop(guard);
1313 anyhow::Ok(())
1314 })
1315 .detach_and_log_err(cx);
1316 }
1317
1318 fn pull(&mut self, _: &git::Pull, window: &mut Window, cx: &mut Context<Self>) {
1319 let guard = self.start_remote_operation();
1320 let remote = self.get_current_remote(window, cx);
1321 cx.spawn(move |this, mut cx| async move {
1322 let remote = remote.await?;
1323
1324 this.update(&mut cx, |this, cx| {
1325 let Some(repo) = this.active_repository.clone() else {
1326 return Err(anyhow::anyhow!("No active repository"));
1327 };
1328
1329 let Some(branch) = repo.read(cx).current_branch() else {
1330 return Err(anyhow::anyhow!("No active branch"));
1331 };
1332
1333 Ok(repo.read(cx).pull(branch.name.clone(), remote.name))
1334 })??
1335 .await??;
1336
1337 drop(guard);
1338 anyhow::Ok(())
1339 })
1340 .detach_and_log_err(cx);
1341 }
1342
1343 fn push(&mut self, action: &git::Push, window: &mut Window, cx: &mut Context<Self>) {
1344 let guard = self.start_remote_operation();
1345 let options = action.options;
1346 let remote = self.get_current_remote(window, cx);
1347 cx.spawn(move |this, mut cx| async move {
1348 let remote = remote.await?;
1349
1350 this.update(&mut cx, |this, cx| {
1351 let Some(repo) = this.active_repository.clone() else {
1352 return Err(anyhow::anyhow!("No active repository"));
1353 };
1354
1355 let Some(branch) = repo.read(cx).current_branch() else {
1356 return Err(anyhow::anyhow!("No active branch"));
1357 };
1358
1359 Ok(repo
1360 .read(cx)
1361 .push(branch.name.clone(), remote.name, options))
1362 })??
1363 .await??;
1364
1365 drop(guard);
1366 anyhow::Ok(())
1367 })
1368 .detach_and_log_err(cx);
1369 }
1370
1371 fn get_current_remote(
1372 &mut self,
1373 window: &mut Window,
1374 cx: &mut Context<Self>,
1375 ) -> impl Future<Output = Result<Remote>> {
1376 let repo = self.active_repository.clone();
1377 let workspace = self.workspace.clone();
1378 let mut cx = window.to_async(cx);
1379
1380 async move {
1381 let Some(repo) = repo else {
1382 return Err(anyhow::anyhow!("No active repository"));
1383 };
1384
1385 let mut current_remotes: Vec<Remote> = repo
1386 .update(&mut cx, |repo, cx| {
1387 let Some(current_branch) = repo.current_branch() else {
1388 return Err(anyhow::anyhow!("No active branch"));
1389 };
1390
1391 Ok(repo.get_remotes(Some(current_branch.name.to_string()), cx))
1392 })??
1393 .await?;
1394
1395 if current_remotes.len() == 0 {
1396 return Err(anyhow::anyhow!("No active remote"));
1397 } else if current_remotes.len() == 1 {
1398 return Ok(current_remotes.pop().unwrap());
1399 } else {
1400 let current_remotes: Vec<_> = current_remotes
1401 .into_iter()
1402 .map(|remotes| remotes.name)
1403 .collect();
1404 let selection = cx
1405 .update(|window, cx| {
1406 picker_prompt::prompt(
1407 "Pick which remote to push to",
1408 current_remotes.clone(),
1409 workspace,
1410 window,
1411 cx,
1412 )
1413 })?
1414 .await?;
1415
1416 return Ok(Remote {
1417 name: current_remotes[selection].clone(),
1418 });
1419 }
1420 }
1421 }
1422
1423 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
1424 let mut new_co_authors = Vec::new();
1425 let project = self.project.read(cx);
1426
1427 let Some(room) = self
1428 .workspace
1429 .upgrade()
1430 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1431 else {
1432 return Vec::default();
1433 };
1434
1435 let room = room.read(cx);
1436
1437 for (peer_id, collaborator) in project.collaborators() {
1438 if collaborator.is_host {
1439 continue;
1440 }
1441
1442 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
1443 continue;
1444 };
1445 if participant.can_write() && participant.user.email.is_some() {
1446 let email = participant.user.email.clone().unwrap();
1447
1448 new_co_authors.push((
1449 participant
1450 .user
1451 .name
1452 .clone()
1453 .unwrap_or_else(|| participant.user.github_login.clone()),
1454 email,
1455 ))
1456 }
1457 }
1458 if !project.is_local() && !project.is_read_only(cx) {
1459 if let Some(user) = room.local_participant_user(cx) {
1460 if let Some(email) = user.email.clone() {
1461 new_co_authors.push((
1462 user.name
1463 .clone()
1464 .unwrap_or_else(|| user.github_login.clone()),
1465 email.clone(),
1466 ))
1467 }
1468 }
1469 }
1470 new_co_authors
1471 }
1472
1473 fn toggle_fill_co_authors(
1474 &mut self,
1475 _: &ToggleFillCoAuthors,
1476 _: &mut Window,
1477 cx: &mut Context<Self>,
1478 ) {
1479 self.add_coauthors = !self.add_coauthors;
1480 cx.notify();
1481 }
1482
1483 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
1484 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
1485
1486 let existing_text = message.to_ascii_lowercase();
1487 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
1488 let mut ends_with_co_authors = false;
1489 let existing_co_authors = existing_text
1490 .lines()
1491 .filter_map(|line| {
1492 let line = line.trim();
1493 if line.starts_with(&lowercase_co_author_prefix) {
1494 ends_with_co_authors = true;
1495 Some(line)
1496 } else {
1497 ends_with_co_authors = false;
1498 None
1499 }
1500 })
1501 .collect::<HashSet<_>>();
1502
1503 let new_co_authors = self
1504 .potential_co_authors(cx)
1505 .into_iter()
1506 .filter(|(_, email)| {
1507 !existing_co_authors
1508 .iter()
1509 .any(|existing| existing.contains(email.as_str()))
1510 })
1511 .collect::<Vec<_>>();
1512
1513 if new_co_authors.is_empty() {
1514 return;
1515 }
1516
1517 if !ends_with_co_authors {
1518 message.push('\n');
1519 }
1520 for (name, email) in new_co_authors {
1521 message.push('\n');
1522 message.push_str(CO_AUTHOR_PREFIX);
1523 message.push_str(&name);
1524 message.push_str(" <");
1525 message.push_str(&email);
1526 message.push('>');
1527 }
1528 message.push('\n');
1529 }
1530
1531 fn schedule_update(
1532 &mut self,
1533 clear_pending: bool,
1534 window: &mut Window,
1535 cx: &mut Context<Self>,
1536 ) {
1537 let handle = cx.entity().downgrade();
1538 self.reopen_commit_buffer(window, cx);
1539 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1540 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1541 if let Some(git_panel) = handle.upgrade() {
1542 git_panel
1543 .update_in(&mut cx, |git_panel, _, cx| {
1544 if clear_pending {
1545 git_panel.clear_pending();
1546 }
1547 git_panel.update_visible_entries(cx);
1548 git_panel.update_editor_placeholder(cx);
1549 })
1550 .ok();
1551 }
1552 });
1553 }
1554
1555 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1556 let Some(active_repo) = self.active_repository.as_ref() else {
1557 return;
1558 };
1559 let load_buffer = active_repo.update(cx, |active_repo, cx| {
1560 let project = self.project.read(cx);
1561 active_repo.open_commit_buffer(
1562 Some(project.languages().clone()),
1563 project.buffer_store().clone(),
1564 cx,
1565 )
1566 });
1567
1568 cx.spawn_in(window, |git_panel, mut cx| async move {
1569 let buffer = load_buffer.await?;
1570 git_panel.update_in(&mut cx, |git_panel, window, cx| {
1571 if git_panel
1572 .commit_editor
1573 .read(cx)
1574 .buffer()
1575 .read(cx)
1576 .as_singleton()
1577 .as_ref()
1578 != Some(&buffer)
1579 {
1580 git_panel.commit_editor = cx.new(|cx| {
1581 commit_message_editor(buffer, git_panel.project.clone(), true, window, cx)
1582 });
1583 }
1584 })
1585 })
1586 .detach_and_log_err(cx);
1587 }
1588
1589 fn clear_pending(&mut self) {
1590 self.pending.retain(|v| !v.finished)
1591 }
1592
1593 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1594 self.entries.clear();
1595 let mut changed_entries = Vec::new();
1596 let mut new_entries = Vec::new();
1597 let mut conflict_entries = Vec::new();
1598
1599 let Some(repo) = self.active_repository.as_ref() else {
1600 // Just clear entries if no repository is active.
1601 cx.notify();
1602 return;
1603 };
1604
1605 // First pass - collect all paths
1606 let repo = repo.read(cx);
1607
1608 // Second pass - create entries with proper depth calculation
1609 for entry in repo.status() {
1610 let is_conflict = repo.has_conflict(&entry.repo_path);
1611 let is_new = entry.status.is_created();
1612 let is_staged = entry.status.is_staged();
1613
1614 if self.pending.iter().any(|pending| {
1615 pending.target_status == TargetStatus::Reverted
1616 && !pending.finished
1617 && pending.repo_paths.contains(&entry.repo_path)
1618 }) {
1619 continue;
1620 }
1621
1622 let entry = GitStatusEntry {
1623 repo_path: entry.repo_path.clone(),
1624 status: entry.status,
1625 is_staged,
1626 };
1627
1628 if is_conflict {
1629 conflict_entries.push(entry);
1630 } else if is_new {
1631 new_entries.push(entry);
1632 } else {
1633 changed_entries.push(entry);
1634 }
1635 }
1636
1637 if conflict_entries.len() > 0 {
1638 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1639 header: Section::Conflict,
1640 }));
1641 self.entries.extend(
1642 conflict_entries
1643 .into_iter()
1644 .map(GitListEntry::GitStatusEntry),
1645 );
1646 }
1647
1648 if changed_entries.len() > 0 {
1649 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1650 header: Section::Tracked,
1651 }));
1652 self.entries.extend(
1653 changed_entries
1654 .into_iter()
1655 .map(GitListEntry::GitStatusEntry),
1656 );
1657 }
1658 if new_entries.len() > 0 {
1659 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1660 header: Section::New,
1661 }));
1662 self.entries
1663 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1664 }
1665
1666 self.update_counts(repo);
1667
1668 self.select_first_entry_if_none(cx);
1669
1670 cx.notify();
1671 }
1672
1673 fn header_state(&self, header_type: Section) -> ToggleState {
1674 let (staged_count, count) = match header_type {
1675 Section::New => (self.new_staged_count, self.new_count),
1676 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1677 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1678 };
1679 if staged_count == 0 {
1680 ToggleState::Unselected
1681 } else if count == staged_count {
1682 ToggleState::Selected
1683 } else {
1684 ToggleState::Indeterminate
1685 }
1686 }
1687
1688 fn update_counts(&mut self, repo: &Repository) {
1689 self.conflicted_count = 0;
1690 self.conflicted_staged_count = 0;
1691 self.new_count = 0;
1692 self.tracked_count = 0;
1693 self.new_staged_count = 0;
1694 self.tracked_staged_count = 0;
1695 for entry in &self.entries {
1696 let Some(status_entry) = entry.status_entry() else {
1697 continue;
1698 };
1699 if repo.has_conflict(&status_entry.repo_path) {
1700 self.conflicted_count += 1;
1701 if self.entry_is_staged(status_entry) != Some(false) {
1702 self.conflicted_staged_count += 1;
1703 }
1704 } else if status_entry.status.is_created() {
1705 self.new_count += 1;
1706 if self.entry_is_staged(status_entry) != Some(false) {
1707 self.new_staged_count += 1;
1708 }
1709 } else {
1710 self.tracked_count += 1;
1711 if self.entry_is_staged(status_entry) != Some(false) {
1712 self.tracked_staged_count += 1;
1713 }
1714 }
1715 }
1716 }
1717
1718 fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1719 for pending in self.pending.iter().rev() {
1720 if pending.repo_paths.contains(&entry.repo_path) {
1721 match pending.target_status {
1722 TargetStatus::Staged => return Some(true),
1723 TargetStatus::Unstaged => return Some(false),
1724 TargetStatus::Reverted => continue,
1725 TargetStatus::Unchanged => continue,
1726 }
1727 }
1728 }
1729 entry.is_staged
1730 }
1731
1732 pub(crate) fn has_staged_changes(&self) -> bool {
1733 self.tracked_staged_count > 0
1734 || self.new_staged_count > 0
1735 || self.conflicted_staged_count > 0
1736 }
1737
1738 pub(crate) fn has_unstaged_changes(&self) -> bool {
1739 self.tracked_count > self.tracked_staged_count
1740 || self.new_count > self.new_staged_count
1741 || self.conflicted_count > self.conflicted_staged_count
1742 }
1743
1744 fn has_conflicts(&self) -> bool {
1745 self.conflicted_count > 0
1746 }
1747
1748 fn has_tracked_changes(&self) -> bool {
1749 self.tracked_count > 0
1750 }
1751
1752 pub fn has_unstaged_conflicts(&self) -> bool {
1753 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1754 }
1755
1756 fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1757 let Some(workspace) = self.workspace.upgrade() else {
1758 return;
1759 };
1760 let notif_id = NotificationId::Named("git-operation-error".into());
1761
1762 let message = e.to_string();
1763 workspace.update(cx, |workspace, cx| {
1764 let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1765 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1766 });
1767 workspace.show_toast(toast, cx);
1768 });
1769 }
1770
1771 pub fn panel_button(
1772 &self,
1773 id: impl Into<SharedString>,
1774 label: impl Into<SharedString>,
1775 ) -> Button {
1776 let id = id.into().clone();
1777 let label = label.into().clone();
1778
1779 Button::new(id, label)
1780 .label_size(LabelSize::Small)
1781 .layer(ElevationIndex::ElevatedSurface)
1782 .size(ButtonSize::Compact)
1783 .style(ButtonStyle::Filled)
1784 }
1785
1786 pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1787 Checkbox::container_size(cx).to_pixels(window.rem_size())
1788 }
1789
1790 pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1791 h_flex()
1792 .items_center()
1793 .h(px(8.))
1794 .child(Divider::horizontal_dashed().color(DividerColor::Border))
1795 }
1796
1797 pub fn render_panel_header(
1798 &self,
1799 window: &mut Window,
1800 cx: &mut Context<Self>,
1801 ) -> Option<impl IntoElement> {
1802 let all_repositories = self
1803 .project
1804 .read(cx)
1805 .git_store()
1806 .read(cx)
1807 .all_repositories();
1808
1809 let has_repo_above = all_repositories.iter().any(|repo| {
1810 repo.read(cx)
1811 .repository_entry
1812 .work_directory
1813 .is_above_project()
1814 });
1815
1816 let has_visible_repo = all_repositories.len() > 0 || has_repo_above;
1817
1818 if has_visible_repo {
1819 Some(
1820 self.panel_header_container(window, cx)
1821 .child(
1822 Label::new("Repository")
1823 .size(LabelSize::Small)
1824 .color(Color::Muted),
1825 )
1826 .child(self.render_repository_selector(cx))
1827 .child(div().flex_grow()) // spacer
1828 .child(
1829 div()
1830 .h_flex()
1831 .gap_1()
1832 .children(self.render_spinner(cx))
1833 .children(self.render_sync_button(cx))
1834 .children(self.render_pull_button(cx))
1835 .child(
1836 Button::new("diff", "+/-")
1837 .tooltip(Tooltip::for_action_title("Open diff", &Diff))
1838 .on_click(|_, _, cx| {
1839 cx.defer(|cx| {
1840 cx.dispatch_action(&Diff);
1841 })
1842 }),
1843 )
1844 .child(self.render_overflow_menu()),
1845 ),
1846 )
1847 } else {
1848 None
1849 }
1850 }
1851
1852 pub fn render_spinner(&self, _cx: &mut Context<Self>) -> Option<impl IntoElement> {
1853 (!self.pending_remote_operations.borrow().is_empty()).then(|| {
1854 Icon::new(IconName::ArrowCircle)
1855 .size(IconSize::XSmall)
1856 .color(Color::Info)
1857 .with_animation(
1858 "arrow-circle",
1859 Animation::new(Duration::from_secs(2)).repeat(),
1860 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1861 )
1862 .into_any_element()
1863 })
1864 }
1865
1866 pub fn render_overflow_menu(&self) -> impl IntoElement {
1867 PopoverMenu::new("overflow-menu")
1868 .trigger(IconButton::new("overflow-menu-trigger", IconName::Ellipsis))
1869 .menu(move |window, cx| Some(Self::panel_context_menu(window, cx)))
1870 .anchor(Corner::TopRight)
1871 }
1872
1873 pub fn render_sync_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1874 let active_repository = self.project.read(cx).active_repository(cx);
1875 active_repository.as_ref().map(|_| {
1876 panel_filled_button("Fetch")
1877 .icon(IconName::ArrowCircle)
1878 .icon_size(IconSize::Small)
1879 .icon_color(Color::Muted)
1880 .icon_position(IconPosition::Start)
1881 .tooltip(Tooltip::for_action_title("git fetch", &git::Fetch))
1882 .on_click(
1883 cx.listener(move |this, _, window, cx| this.fetch(&git::Fetch, window, cx)),
1884 )
1885 .into_any_element()
1886 })
1887 }
1888
1889 pub fn render_pull_button(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1890 let active_repository = self.project.read(cx).active_repository(cx);
1891 active_repository
1892 .as_ref()
1893 .and_then(|repo| repo.read(cx).current_branch())
1894 .and_then(|branch| {
1895 branch.upstream.as_ref().map(|upstream| {
1896 let status = &upstream.tracking;
1897
1898 let disabled = status.is_gone();
1899
1900 panel_filled_button(match status {
1901 git::repository::UpstreamTracking::Tracked(status) if status.behind > 0 => {
1902 format!("Pull ({})", status.behind)
1903 }
1904 _ => "Pull".to_string(),
1905 })
1906 .icon(IconName::ArrowDown)
1907 .icon_size(IconSize::Small)
1908 .icon_color(Color::Muted)
1909 .icon_position(IconPosition::Start)
1910 .disabled(status.is_gone())
1911 .tooltip(move |window, cx| {
1912 if disabled {
1913 Tooltip::simple("Upstream is gone", cx)
1914 } else {
1915 // TODO: Add <origin> and <branch> argument substitutions to this
1916 Tooltip::for_action("git pull", &git::Pull, window, cx)
1917 }
1918 })
1919 .on_click(
1920 cx.listener(move |this, _, window, cx| this.pull(&git::Pull, window, cx)),
1921 )
1922 .into_any_element()
1923 })
1924 })
1925 }
1926
1927 pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1928 let active_repository = self.project.read(cx).active_repository(cx);
1929 let repository_display_name = active_repository
1930 .as_ref()
1931 .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1932 .unwrap_or_default();
1933
1934 RepositorySelectorPopoverMenu::new(
1935 self.repository_selector.clone(),
1936 ButtonLike::new("active-repository")
1937 .style(ButtonStyle::Subtle)
1938 .child(Label::new(repository_display_name).size(LabelSize::Small)),
1939 Tooltip::text("Select a repository"),
1940 )
1941 }
1942
1943 pub fn can_commit(&self) -> bool {
1944 (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
1945 }
1946
1947 pub fn can_stage_all(&self) -> bool {
1948 self.has_unstaged_changes()
1949 }
1950
1951 pub fn can_unstage_all(&self) -> bool {
1952 self.has_staged_changes()
1953 }
1954
1955 pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
1956 let potential_co_authors = self.potential_co_authors(cx);
1957 if potential_co_authors.is_empty() {
1958 None
1959 } else {
1960 Some(
1961 IconButton::new("co-authors", IconName::Person)
1962 .icon_color(Color::Disabled)
1963 .selected_icon_color(Color::Selected)
1964 .toggle_state(self.add_coauthors)
1965 .tooltip(move |_, cx| {
1966 let title = format!(
1967 "Add co-authored-by:{}{}",
1968 if potential_co_authors.len() == 1 {
1969 ""
1970 } else {
1971 "\n"
1972 },
1973 potential_co_authors
1974 .iter()
1975 .map(|(name, email)| format!(" {} <{}>", name, email))
1976 .join("\n")
1977 );
1978 Tooltip::simple(title, cx)
1979 })
1980 .on_click(cx.listener(|this, _, _, cx| {
1981 this.add_coauthors = !this.add_coauthors;
1982 cx.notify();
1983 }))
1984 .into_any_element(),
1985 )
1986 }
1987 }
1988
1989 pub fn render_commit_editor(
1990 &self,
1991 window: &mut Window,
1992 cx: &mut Context<Self>,
1993 ) -> impl IntoElement {
1994 let editor = self.commit_editor.clone();
1995 let can_commit = self.can_commit()
1996 && self.pending_commit.is_none()
1997 && !editor.read(cx).is_empty(cx)
1998 && self.has_write_access(cx);
1999
2000 let panel_editor_style = panel_editor_style(true, window, cx);
2001 let enable_coauthors = self.render_co_authors(cx);
2002
2003 let tooltip = if self.has_staged_changes() {
2004 "git commit"
2005 } else {
2006 "git commit --all"
2007 };
2008 let title = if self.has_staged_changes() {
2009 "Commit"
2010 } else {
2011 "Commit Tracked"
2012 };
2013 let editor_focus_handle = self.commit_editor.focus_handle(cx);
2014
2015 let commit_button = panel_filled_button(title)
2016 .tooltip(move |window, cx| {
2017 Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
2018 })
2019 .disabled(!can_commit)
2020 .on_click({
2021 cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
2022 });
2023
2024 let branch = self
2025 .active_repository
2026 .as_ref()
2027 .and_then(|repo| repo.read(cx).current_branch().map(|b| b.name.clone()))
2028 .unwrap_or_else(|| "<no branch>".into());
2029
2030 let branch_selector = Button::new("branch-selector", branch)
2031 .color(Color::Muted)
2032 .style(ButtonStyle::Subtle)
2033 .icon(IconName::GitBranch)
2034 .icon_size(IconSize::Small)
2035 .icon_color(Color::Muted)
2036 .size(ButtonSize::Compact)
2037 .icon_position(IconPosition::Start)
2038 .tooltip(Tooltip::for_action_title(
2039 "Switch Branch",
2040 &zed_actions::git::Branch,
2041 ))
2042 .on_click(cx.listener(|_, _, window, cx| {
2043 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
2044 }))
2045 .style(ButtonStyle::Transparent);
2046
2047 let footer_size = px(32.);
2048 let gap = px(16.0);
2049
2050 let max_height = window.line_height() * 6. + gap + footer_size;
2051
2052 panel_editor_container(window, cx)
2053 .id("commit-editor-container")
2054 .relative()
2055 .h(max_height)
2056 .w_full()
2057 .border_t_1()
2058 .border_color(cx.theme().colors().border)
2059 .bg(cx.theme().colors().editor_background)
2060 .cursor_text()
2061 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
2062 window.focus(&this.commit_editor.focus_handle(cx));
2063 }))
2064 .when(!self.modal_open, |el| {
2065 el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
2066 .child(
2067 h_flex()
2068 .absolute()
2069 .bottom_0()
2070 .left_2()
2071 .h(footer_size)
2072 .flex_none()
2073 .child(branch_selector),
2074 )
2075 .child(
2076 h_flex()
2077 .absolute()
2078 .bottom_0()
2079 .right_2()
2080 .h(footer_size)
2081 .flex_none()
2082 .children(enable_coauthors)
2083 .child(commit_button),
2084 )
2085 })
2086 }
2087
2088 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
2089 let active_repository = self.active_repository.as_ref()?;
2090 let branch = active_repository.read(cx).current_branch()?;
2091 let commit = branch.most_recent_commit.as_ref()?.clone();
2092
2093 let this = cx.entity();
2094 Some(
2095 h_flex()
2096 .items_center()
2097 .py_1p5()
2098 .px(px(8.))
2099 .bg(cx.theme().colors().background)
2100 .border_t_1()
2101 .border_color(cx.theme().colors().border)
2102 .gap_1p5()
2103 .child(
2104 div()
2105 .flex_grow()
2106 .overflow_hidden()
2107 .max_w(relative(0.6))
2108 .h_full()
2109 .child(
2110 Label::new(commit.subject.clone())
2111 .size(LabelSize::Small)
2112 .text_ellipsis(),
2113 )
2114 .id("commit-msg-hover")
2115 .hoverable_tooltip(move |window, cx| {
2116 GitPanelMessageTooltip::new(
2117 this.clone(),
2118 commit.sha.clone(),
2119 window,
2120 cx,
2121 )
2122 .into()
2123 }),
2124 )
2125 .child(div().flex_1())
2126 .child(
2127 panel_filled_button("Uncommit")
2128 .icon(IconName::Undo)
2129 .icon_size(IconSize::Small)
2130 .icon_color(Color::Muted)
2131 .icon_position(IconPosition::Start)
2132 .tooltip(Tooltip::for_action_title(
2133 if self.has_staged_changes() {
2134 "git reset HEAD^ --soft"
2135 } else {
2136 "git reset HEAD^"
2137 },
2138 &git::Uncommit,
2139 ))
2140 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
2141 )
2142 .child(self.render_push_button(branch, cx)),
2143 )
2144 }
2145
2146 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
2147 h_flex()
2148 .h_full()
2149 .flex_grow()
2150 .justify_center()
2151 .items_center()
2152 .child(
2153 v_flex()
2154 .gap_3()
2155 .child(if self.active_repository.is_some() {
2156 "No changes to commit"
2157 } else {
2158 "No Git repositories"
2159 })
2160 .text_ui_sm(cx)
2161 .mx_auto()
2162 .text_color(Color::Placeholder.color(cx)),
2163 )
2164 }
2165
2166 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
2167 let scroll_bar_style = self.show_scrollbar(cx);
2168 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
2169
2170 if !self.should_show_scrollbar(cx)
2171 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
2172 {
2173 return None;
2174 }
2175
2176 Some(
2177 div()
2178 .id("git-panel-vertical-scroll")
2179 .occlude()
2180 .flex_none()
2181 .h_full()
2182 .cursor_default()
2183 .when(show_container, |this| this.pl_1().px_1p5())
2184 .when(!show_container, |this| {
2185 this.absolute().right_1().top_1().bottom_1().w(px(12.))
2186 })
2187 .on_mouse_move(cx.listener(|_, _, _, cx| {
2188 cx.notify();
2189 cx.stop_propagation()
2190 }))
2191 .on_hover(|_, _, cx| {
2192 cx.stop_propagation();
2193 })
2194 .on_any_mouse_down(|_, _, cx| {
2195 cx.stop_propagation();
2196 })
2197 .on_mouse_up(
2198 MouseButton::Left,
2199 cx.listener(|this, _, window, cx| {
2200 if !this.scrollbar_state.is_dragging()
2201 && !this.focus_handle.contains_focused(window, cx)
2202 {
2203 this.hide_scrollbar(window, cx);
2204 cx.notify();
2205 }
2206
2207 cx.stop_propagation();
2208 }),
2209 )
2210 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
2211 cx.notify();
2212 }))
2213 .children(Scrollbar::vertical(
2214 // percentage as f32..end_offset as f32,
2215 self.scrollbar_state.clone(),
2216 )),
2217 )
2218 }
2219
2220 pub fn render_buffer_header_controls(
2221 &self,
2222 entity: &Entity<Self>,
2223 file: &Arc<dyn File>,
2224 _: &Window,
2225 cx: &App,
2226 ) -> Option<AnyElement> {
2227 let repo = self.active_repository.as_ref()?.read(cx);
2228 let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
2229 let ix = self.entry_by_path(&repo_path)?;
2230 let entry = self.entries.get(ix)?;
2231
2232 let is_staged = self.entry_is_staged(entry.status_entry()?);
2233
2234 let checkbox = Checkbox::new("stage-file", is_staged.into())
2235 .disabled(!self.has_write_access(cx))
2236 .fill()
2237 .elevation(ElevationIndex::Surface)
2238 .on_click({
2239 let entry = entry.clone();
2240 let git_panel = entity.downgrade();
2241 move |_, window, cx| {
2242 git_panel
2243 .update(cx, |this, cx| {
2244 this.toggle_staged_for_entry(&entry, window, cx);
2245 cx.stop_propagation();
2246 })
2247 .ok();
2248 }
2249 });
2250 Some(
2251 h_flex()
2252 .id("start-slot")
2253 .text_lg()
2254 .child(checkbox)
2255 .on_mouse_down(MouseButton::Left, |_, _, cx| {
2256 // prevent the list item active state triggering when toggling checkbox
2257 cx.stop_propagation();
2258 })
2259 .into_any_element(),
2260 )
2261 }
2262
2263 fn render_entries(
2264 &self,
2265 has_write_access: bool,
2266 _: &Window,
2267 cx: &mut Context<Self>,
2268 ) -> impl IntoElement {
2269 let entry_count = self.entries.len();
2270
2271 v_flex()
2272 .size_full()
2273 .flex_grow()
2274 .overflow_hidden()
2275 .child(
2276 uniform_list(cx.entity().clone(), "entries", entry_count, {
2277 move |this, range, window, cx| {
2278 let mut items = Vec::with_capacity(range.end - range.start);
2279
2280 for ix in range {
2281 match &this.entries.get(ix) {
2282 Some(GitListEntry::GitStatusEntry(entry)) => {
2283 items.push(this.render_entry(
2284 ix,
2285 entry,
2286 has_write_access,
2287 window,
2288 cx,
2289 ));
2290 }
2291 Some(GitListEntry::Header(header)) => {
2292 items.push(this.render_list_header(
2293 ix,
2294 header,
2295 has_write_access,
2296 window,
2297 cx,
2298 ));
2299 }
2300 None => {}
2301 }
2302 }
2303
2304 items
2305 }
2306 })
2307 .size_full()
2308 .with_sizing_behavior(ListSizingBehavior::Infer)
2309 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2310 .track_scroll(self.scroll_handle.clone()),
2311 )
2312 .on_mouse_down(
2313 MouseButton::Right,
2314 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2315 this.deploy_panel_context_menu(event.position, window, cx)
2316 }),
2317 )
2318 .children(self.render_scrollbar(cx))
2319 }
2320
2321 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2322 Label::new(label.into()).color(color).single_line()
2323 }
2324
2325 fn render_list_header(
2326 &self,
2327 ix: usize,
2328 header: &GitHeaderEntry,
2329 _: bool,
2330 _: &Window,
2331 _: &Context<Self>,
2332 ) -> AnyElement {
2333 div()
2334 .w_full()
2335 .child(
2336 ListItem::new(ix)
2337 .spacing(ListItemSpacing::Sparse)
2338 .disabled(true)
2339 .child(
2340 Label::new(header.title())
2341 .color(Color::Muted)
2342 .size(LabelSize::Small)
2343 .single_line(),
2344 ),
2345 )
2346 .into_any_element()
2347 }
2348
2349 fn load_commit_details(
2350 &self,
2351 sha: &str,
2352 cx: &mut Context<Self>,
2353 ) -> Task<Result<CommitDetails>> {
2354 let Some(repo) = self.active_repository.clone() else {
2355 return Task::ready(Err(anyhow::anyhow!("no active repo")));
2356 };
2357 repo.update(cx, |repo, cx| repo.show(sha, cx))
2358 }
2359
2360 fn deploy_entry_context_menu(
2361 &mut self,
2362 position: Point<Pixels>,
2363 ix: usize,
2364 window: &mut Window,
2365 cx: &mut Context<Self>,
2366 ) {
2367 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2368 return;
2369 };
2370 let stage_title = if entry.status.is_staged() == Some(true) {
2371 "Unstage File"
2372 } else {
2373 "Stage File"
2374 };
2375 let revert_title = if entry.status.is_deleted() {
2376 "Restore file"
2377 } else if entry.status.is_created() {
2378 "Trash file"
2379 } else {
2380 "Discard changes"
2381 };
2382 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2383 context_menu
2384 .action(stage_title, ToggleStaged.boxed_clone())
2385 .action(revert_title, git::RestoreFile.boxed_clone())
2386 .separator()
2387 .action("Open Diff", Confirm.boxed_clone())
2388 .action("Open File", SecondaryConfirm.boxed_clone())
2389 });
2390 self.selected_entry = Some(ix);
2391 self.set_context_menu(context_menu, position, window, cx);
2392 }
2393
2394 fn panel_context_menu(window: &mut Window, cx: &mut App) -> Entity<ContextMenu> {
2395 ContextMenu::build(window, cx, |context_menu, _, _| {
2396 context_menu
2397 .action("Stage All", StageAll.boxed_clone())
2398 .action("Unstage All", UnstageAll.boxed_clone())
2399 .separator()
2400 .action("Open Diff", project_diff::Diff.boxed_clone())
2401 .separator()
2402 .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
2403 .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2404 })
2405 }
2406
2407 fn deploy_panel_context_menu(
2408 &mut self,
2409 position: Point<Pixels>,
2410 window: &mut Window,
2411 cx: &mut Context<Self>,
2412 ) {
2413 let context_menu = Self::panel_context_menu(window, cx);
2414 self.set_context_menu(context_menu, position, window, cx);
2415 }
2416
2417 fn set_context_menu(
2418 &mut self,
2419 context_menu: Entity<ContextMenu>,
2420 position: Point<Pixels>,
2421 window: &Window,
2422 cx: &mut Context<Self>,
2423 ) {
2424 let subscription = cx.subscribe_in(
2425 &context_menu,
2426 window,
2427 |this, _, _: &DismissEvent, window, cx| {
2428 if this.context_menu.as_ref().is_some_and(|context_menu| {
2429 context_menu.0.focus_handle(cx).contains_focused(window, cx)
2430 }) {
2431 cx.focus_self(window);
2432 }
2433 this.context_menu.take();
2434 cx.notify();
2435 },
2436 );
2437 self.context_menu = Some((context_menu, position, subscription));
2438 cx.notify();
2439 }
2440
2441 fn render_entry(
2442 &self,
2443 ix: usize,
2444 entry: &GitStatusEntry,
2445 has_write_access: bool,
2446 window: &Window,
2447 cx: &Context<Self>,
2448 ) -> AnyElement {
2449 let display_name = entry
2450 .repo_path
2451 .file_name()
2452 .map(|name| name.to_string_lossy().into_owned())
2453 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2454
2455 let repo_path = entry.repo_path.clone();
2456 let selected = self.selected_entry == Some(ix);
2457 let status_style = GitPanelSettings::get_global(cx).status_style;
2458 let status = entry.status;
2459 let has_conflict = status.is_conflicted();
2460 let is_modified = status.is_modified();
2461 let is_deleted = status.is_deleted();
2462
2463 let label_color = if status_style == StatusStyle::LabelColor {
2464 if has_conflict {
2465 Color::Conflict
2466 } else if is_modified {
2467 Color::Modified
2468 } else if is_deleted {
2469 // We don't want a bunch of red labels in the list
2470 Color::Disabled
2471 } else {
2472 Color::Created
2473 }
2474 } else {
2475 Color::Default
2476 };
2477
2478 let path_color = if status.is_deleted() {
2479 Color::Disabled
2480 } else {
2481 Color::Muted
2482 };
2483
2484 let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2485
2486 let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2487
2488 if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2489 is_staged = ToggleState::Selected;
2490 }
2491
2492 let checkbox = Checkbox::new(id, is_staged)
2493 .disabled(!has_write_access)
2494 .fill()
2495 .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2496 .elevation(ElevationIndex::Surface)
2497 .on_click({
2498 let entry = entry.clone();
2499 cx.listener(move |this, _, window, cx| {
2500 this.toggle_staged_for_entry(
2501 &GitListEntry::GitStatusEntry(entry.clone()),
2502 window,
2503 cx,
2504 );
2505 cx.stop_propagation();
2506 })
2507 });
2508
2509 let start_slot =
2510 h_flex()
2511 .id(("start-slot", ix))
2512 .gap(DynamicSpacing::Base04.rems(cx))
2513 .child(checkbox.tooltip(|window, cx| {
2514 Tooltip::for_action("Stage File", &ToggleStaged, window, cx)
2515 }))
2516 .child(git_status_icon(status, cx))
2517 .on_mouse_down(MouseButton::Left, |_, _, cx| {
2518 // prevent the list item active state triggering when toggling checkbox
2519 cx.stop_propagation();
2520 });
2521
2522 div()
2523 .w_full()
2524 .child(
2525 ListItem::new(ix)
2526 .spacing(ListItemSpacing::Sparse)
2527 .start_slot(start_slot)
2528 .toggle_state(selected)
2529 .focused(selected && self.focus_handle(cx).is_focused(window))
2530 .disabled(!has_write_access)
2531 .on_click({
2532 cx.listener(move |this, event: &ClickEvent, window, cx| {
2533 this.selected_entry = Some(ix);
2534 cx.notify();
2535 if event.modifiers().secondary() {
2536 this.open_file(&Default::default(), window, cx)
2537 } else {
2538 this.open_diff(&Default::default(), window, cx);
2539 }
2540 })
2541 })
2542 .on_secondary_mouse_down(cx.listener(
2543 move |this, event: &MouseDownEvent, window, cx| {
2544 this.deploy_entry_context_menu(event.position, ix, window, cx);
2545 cx.stop_propagation();
2546 },
2547 ))
2548 .child(
2549 h_flex()
2550 .when_some(repo_path.parent(), |this, parent| {
2551 let parent_str = parent.to_string_lossy();
2552 if !parent_str.is_empty() {
2553 this.child(
2554 self.entry_label(format!("{}/", parent_str), path_color)
2555 .when(status.is_deleted(), |this| this.strikethrough()),
2556 )
2557 } else {
2558 this
2559 }
2560 })
2561 .child(
2562 self.entry_label(display_name.clone(), label_color)
2563 .when(status.is_deleted(), |this| this.strikethrough()),
2564 ),
2565 ),
2566 )
2567 .into_any_element()
2568 }
2569
2570 fn render_push_button(&self, branch: &Branch, cx: &Context<Self>) -> AnyElement {
2571 let mut disabled = false;
2572
2573 // TODO: Add <origin> and <branch> argument substitutions to this
2574 let button: SharedString;
2575 let tooltip: SharedString;
2576 let action: Option<Push>;
2577 if let Some(upstream) = &branch.upstream {
2578 match upstream.tracking {
2579 UpstreamTracking::Gone => {
2580 button = "Republish".into();
2581 tooltip = "git push --set-upstream".into();
2582 action = Some(git::Push {
2583 options: Some(PushOptions::SetUpstream),
2584 });
2585 }
2586 UpstreamTracking::Tracked(tracking) => {
2587 if tracking.behind > 0 {
2588 disabled = true;
2589 button = "Push".into();
2590 tooltip = "Upstream is ahead of local branch".into();
2591 action = None;
2592 } else if tracking.ahead > 0 {
2593 button = format!("Push ({})", tracking.ahead).into();
2594 tooltip = "git push".into();
2595 action = Some(git::Push { options: None });
2596 } else {
2597 disabled = true;
2598 button = "Push".into();
2599 tooltip = "Upstream matches local branch".into();
2600 action = None;
2601 }
2602 }
2603 }
2604 } else {
2605 button = "Publish".into();
2606 tooltip = "git push --set-upstream".into();
2607 action = Some(git::Push {
2608 options: Some(PushOptions::SetUpstream),
2609 });
2610 };
2611
2612 panel_filled_button(button)
2613 .icon(IconName::ArrowUp)
2614 .icon_size(IconSize::Small)
2615 .icon_color(Color::Muted)
2616 .icon_position(IconPosition::Start)
2617 .disabled(disabled)
2618 .when_some(action, |this, action| {
2619 this.on_click(
2620 cx.listener(move |this, _, window, cx| this.push(&action, window, cx)),
2621 )
2622 })
2623 .tooltip(move |window, cx| {
2624 if let Some(action) = action.as_ref() {
2625 Tooltip::for_action(tooltip.clone(), action, window, cx)
2626 } else {
2627 Tooltip::simple(tooltip.clone(), cx)
2628 }
2629 })
2630 .into_any_element()
2631 }
2632
2633 fn has_write_access(&self, cx: &App) -> bool {
2634 !self.project.read(cx).is_read_only(cx)
2635 }
2636}
2637
2638impl Render for GitPanel {
2639 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2640 let project = self.project.read(cx);
2641 let has_entries = self.entries.len() > 0;
2642 let room = self
2643 .workspace
2644 .upgrade()
2645 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2646
2647 let has_write_access = self.has_write_access(cx);
2648
2649 let has_co_authors = room.map_or(false, |room| {
2650 room.read(cx)
2651 .remote_participants()
2652 .values()
2653 .any(|remote_participant| remote_participant.can_write())
2654 });
2655
2656 v_flex()
2657 .id("git_panel")
2658 .key_context(self.dispatch_context(window, cx))
2659 .track_focus(&self.focus_handle)
2660 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2661 .when(has_write_access && !project.is_read_only(cx), |this| {
2662 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2663 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2664 }))
2665 .on_action(cx.listener(GitPanel::commit))
2666 })
2667 .on_action(cx.listener(Self::select_first))
2668 .on_action(cx.listener(Self::select_next))
2669 .on_action(cx.listener(Self::select_prev))
2670 .on_action(cx.listener(Self::select_last))
2671 .on_action(cx.listener(Self::close_panel))
2672 .on_action(cx.listener(Self::open_diff))
2673 .on_action(cx.listener(Self::open_file))
2674 .on_action(cx.listener(Self::revert_selected))
2675 .on_action(cx.listener(Self::focus_changes_list))
2676 .on_action(cx.listener(Self::focus_editor))
2677 .on_action(cx.listener(Self::toggle_staged_for_selected))
2678 .on_action(cx.listener(Self::stage_all))
2679 .on_action(cx.listener(Self::unstage_all))
2680 .on_action(cx.listener(Self::discard_tracked_changes))
2681 .on_action(cx.listener(Self::clean_all))
2682 .on_action(cx.listener(Self::fetch))
2683 .on_action(cx.listener(Self::pull))
2684 .on_action(cx.listener(Self::push))
2685 .when(has_write_access && has_co_authors, |git_panel| {
2686 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2687 })
2688 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2689 .on_hover(cx.listener(|this, hovered, window, cx| {
2690 if *hovered {
2691 this.show_scrollbar = true;
2692 this.hide_scrollbar_task.take();
2693 cx.notify();
2694 } else if !this.focus_handle.contains_focused(window, cx) {
2695 this.hide_scrollbar(window, cx);
2696 }
2697 }))
2698 .size_full()
2699 .overflow_hidden()
2700 .bg(ElevationIndex::Surface.bg(cx))
2701 .child(
2702 v_flex()
2703 .size_full()
2704 .children(self.render_panel_header(window, cx))
2705 .map(|this| {
2706 if has_entries {
2707 this.child(self.render_entries(has_write_access, window, cx))
2708 } else {
2709 this.child(self.render_empty_state(cx).into_any_element())
2710 }
2711 })
2712 .children(self.render_previous_commit(cx))
2713 .child(self.render_commit_editor(window, cx))
2714 .into_any_element(),
2715 )
2716 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2717 deferred(
2718 anchored()
2719 .position(*position)
2720 .anchor(gpui::Corner::TopLeft)
2721 .child(menu.clone()),
2722 )
2723 .with_priority(1)
2724 }))
2725 }
2726}
2727
2728impl Focusable for GitPanel {
2729 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2730 self.focus_handle.clone()
2731 }
2732}
2733
2734impl EventEmitter<Event> for GitPanel {}
2735
2736impl EventEmitter<PanelEvent> for GitPanel {}
2737
2738pub(crate) struct GitPanelAddon {
2739 pub(crate) workspace: WeakEntity<Workspace>,
2740}
2741
2742impl editor::Addon for GitPanelAddon {
2743 fn to_any(&self) -> &dyn std::any::Any {
2744 self
2745 }
2746
2747 fn render_buffer_header_controls(
2748 &self,
2749 excerpt_info: &ExcerptInfo,
2750 window: &Window,
2751 cx: &App,
2752 ) -> Option<AnyElement> {
2753 let file = excerpt_info.buffer.file()?;
2754 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2755
2756 git_panel
2757 .read(cx)
2758 .render_buffer_header_controls(&git_panel, &file, window, cx)
2759 }
2760}
2761
2762impl Panel for GitPanel {
2763 fn persistent_name() -> &'static str {
2764 "GitPanel"
2765 }
2766
2767 fn position(&self, _: &Window, cx: &App) -> DockPosition {
2768 GitPanelSettings::get_global(cx).dock
2769 }
2770
2771 fn position_is_valid(&self, position: DockPosition) -> bool {
2772 matches!(position, DockPosition::Left | DockPosition::Right)
2773 }
2774
2775 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2776 settings::update_settings_file::<GitPanelSettings>(
2777 self.fs.clone(),
2778 cx,
2779 move |settings, _| settings.dock = Some(position),
2780 );
2781 }
2782
2783 fn size(&self, _: &Window, cx: &App) -> Pixels {
2784 self.width
2785 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2786 }
2787
2788 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2789 self.width = size;
2790 self.serialize(cx);
2791 cx.notify();
2792 }
2793
2794 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2795 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2796 }
2797
2798 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2799 Some("Git Panel")
2800 }
2801
2802 fn toggle_action(&self) -> Box<dyn Action> {
2803 Box::new(ToggleFocus)
2804 }
2805
2806 fn activation_priority(&self) -> u32 {
2807 2
2808 }
2809}
2810
2811impl PanelHeader for GitPanel {}
2812
2813struct GitPanelMessageTooltip {
2814 commit_tooltip: Option<Entity<CommitTooltip>>,
2815}
2816
2817impl GitPanelMessageTooltip {
2818 fn new(
2819 git_panel: Entity<GitPanel>,
2820 sha: SharedString,
2821 window: &mut Window,
2822 cx: &mut App,
2823 ) -> Entity<Self> {
2824 cx.new(|cx| {
2825 cx.spawn_in(window, |this, mut cx| async move {
2826 let details = git_panel
2827 .update(&mut cx, |git_panel, cx| {
2828 git_panel.load_commit_details(&sha, cx)
2829 })?
2830 .await?;
2831
2832 let commit_details = editor::commit_tooltip::CommitDetails {
2833 sha: details.sha.clone(),
2834 committer_name: details.committer_name.clone(),
2835 committer_email: details.committer_email.clone(),
2836 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2837 message: Some(editor::commit_tooltip::ParsedCommitMessage {
2838 message: details.message.clone(),
2839 ..Default::default()
2840 }),
2841 };
2842
2843 this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2844 this.commit_tooltip =
2845 Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2846 cx.notify();
2847 })
2848 })
2849 .detach();
2850
2851 Self {
2852 commit_tooltip: None,
2853 }
2854 })
2855 }
2856}
2857
2858impl Render for GitPanelMessageTooltip {
2859 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2860 if let Some(commit_tooltip) = &self.commit_tooltip {
2861 commit_tooltip.clone().into_any_element()
2862 } else {
2863 gpui::Empty.into_any_element()
2864 }
2865 }
2866}