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