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