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