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 tooltip = if self.has_staged_changes() {
1686 "Commit staged changes"
1687 } else {
1688 "Commit changes to tracked files"
1689 };
1690 let title = if self.has_staged_changes() {
1691 "Commit"
1692 } else {
1693 "Commit All"
1694 };
1695 let editor_focus_handle = self.commit_editor.focus_handle(cx);
1696
1697 let commit_button = panel_filled_button(title)
1698 .tooltip(move |window, cx| {
1699 Tooltip::for_action_in(tooltip, &Commit, &editor_focus_handle, window, cx)
1700 })
1701 .disabled(!can_commit)
1702 .on_click({
1703 cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1704 });
1705
1706 let branch = self
1707 .active_repository
1708 .as_ref()
1709 .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
1710 .unwrap_or_else(|| "<no branch>".into());
1711
1712 let branch_selector = Button::new("branch-selector", branch)
1713 .color(Color::Muted)
1714 .style(ButtonStyle::Subtle)
1715 .icon(IconName::GitBranch)
1716 .icon_size(IconSize::Small)
1717 .icon_color(Color::Muted)
1718 .size(ButtonSize::Compact)
1719 .icon_position(IconPosition::Start)
1720 .tooltip(Tooltip::for_action_title(
1721 "Switch Branch",
1722 &zed_actions::git::Branch,
1723 ))
1724 .on_click(cx.listener(|_, _, window, cx| {
1725 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1726 }))
1727 .style(ButtonStyle::Transparent);
1728
1729 let footer_size = px(32.);
1730 let gap = px(16.0);
1731
1732 let max_height = window.line_height() * 6. + gap + footer_size;
1733
1734 panel_editor_container(window, cx)
1735 .id("commit-editor-container")
1736 .relative()
1737 .h(max_height)
1738 .w_full()
1739 .border_t_1()
1740 .border_color(cx.theme().colors().border)
1741 .bg(cx.theme().colors().editor_background)
1742 .cursor_text()
1743 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
1744 window.focus(&this.commit_editor.focus_handle(cx));
1745 }))
1746 .when(!self.modal_open, |el| {
1747 el.child(EditorElement::new(&self.commit_editor, panel_editor_style))
1748 .child(
1749 h_flex()
1750 .absolute()
1751 .bottom_0()
1752 .left_2()
1753 .h(footer_size)
1754 .flex_none()
1755 .child(branch_selector),
1756 )
1757 .child(
1758 h_flex()
1759 .absolute()
1760 .bottom_0()
1761 .right_2()
1762 .h(footer_size)
1763 .flex_none()
1764 .children(enable_coauthors)
1765 .child(commit_button),
1766 )
1767 })
1768 }
1769
1770 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1771 let active_repository = self.active_repository.as_ref()?;
1772 let branch = active_repository.read(cx).branch()?;
1773 let commit = branch.most_recent_commit.as_ref()?.clone();
1774
1775 if branch.upstream.as_ref().is_some_and(|upstream| {
1776 if let Some(tracking) = &upstream.tracking {
1777 tracking.ahead == 0
1778 } else {
1779 true
1780 }
1781 }) {
1782 return None;
1783 }
1784 let tooltip = if self.has_staged_changes() {
1785 "git reset HEAD^ --soft"
1786 } else {
1787 "git reset HEAD^"
1788 };
1789
1790 let this = cx.entity();
1791 Some(
1792 h_flex()
1793 .items_center()
1794 .py_1p5()
1795 .px(px(8.))
1796 .bg(cx.theme().colors().background)
1797 .border_t_1()
1798 .border_color(cx.theme().colors().border)
1799 .gap_1p5()
1800 .child(
1801 div()
1802 .flex_grow()
1803 .overflow_hidden()
1804 .max_w(relative(0.6))
1805 .h_full()
1806 .child(
1807 Label::new(commit.subject.clone())
1808 .size(LabelSize::Small)
1809 .text_ellipsis(),
1810 )
1811 .id("commit-msg-hover")
1812 .hoverable_tooltip(move |window, cx| {
1813 GitPanelMessageTooltip::new(
1814 this.clone(),
1815 commit.sha.clone(),
1816 window,
1817 cx,
1818 )
1819 .into()
1820 }),
1821 )
1822 .child(div().flex_1())
1823 .child(
1824 panel_filled_button("Uncommit")
1825 .icon(IconName::Undo)
1826 .icon_size(IconSize::Small)
1827 .icon_color(Color::Muted)
1828 .icon_position(IconPosition::Start)
1829 .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
1830 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
1831 ),
1832 )
1833 }
1834
1835 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1836 h_flex()
1837 .h_full()
1838 .flex_grow()
1839 .justify_center()
1840 .items_center()
1841 .child(
1842 v_flex()
1843 .gap_3()
1844 .child(if self.active_repository.is_some() {
1845 "No changes to commit"
1846 } else {
1847 "No Git repositories"
1848 })
1849 .text_ui_sm(cx)
1850 .mx_auto()
1851 .text_color(Color::Placeholder.color(cx)),
1852 )
1853 }
1854
1855 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1856 let scroll_bar_style = self.show_scrollbar(cx);
1857 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1858
1859 if !self.should_show_scrollbar(cx)
1860 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1861 {
1862 return None;
1863 }
1864
1865 Some(
1866 div()
1867 .id("git-panel-vertical-scroll")
1868 .occlude()
1869 .flex_none()
1870 .h_full()
1871 .cursor_default()
1872 .when(show_container, |this| this.pl_1().px_1p5())
1873 .when(!show_container, |this| {
1874 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1875 })
1876 .on_mouse_move(cx.listener(|_, _, _, cx| {
1877 cx.notify();
1878 cx.stop_propagation()
1879 }))
1880 .on_hover(|_, _, cx| {
1881 cx.stop_propagation();
1882 })
1883 .on_any_mouse_down(|_, _, cx| {
1884 cx.stop_propagation();
1885 })
1886 .on_mouse_up(
1887 MouseButton::Left,
1888 cx.listener(|this, _, window, cx| {
1889 if !this.scrollbar_state.is_dragging()
1890 && !this.focus_handle.contains_focused(window, cx)
1891 {
1892 this.hide_scrollbar(window, cx);
1893 cx.notify();
1894 }
1895
1896 cx.stop_propagation();
1897 }),
1898 )
1899 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1900 cx.notify();
1901 }))
1902 .children(Scrollbar::vertical(
1903 // percentage as f32..end_offset as f32,
1904 self.scrollbar_state.clone(),
1905 )),
1906 )
1907 }
1908
1909 pub fn render_buffer_header_controls(
1910 &self,
1911 entity: &Entity<Self>,
1912 file: &Arc<dyn File>,
1913 _: &Window,
1914 cx: &App,
1915 ) -> Option<AnyElement> {
1916 let repo = self.active_repository.as_ref()?.read(cx);
1917 let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
1918 let ix = self.entries_by_path.get(&repo_path)?;
1919 let entry = self.entries.get(*ix)?;
1920
1921 let is_staged = self.entry_is_staged(entry.status_entry()?);
1922
1923 let checkbox = Checkbox::new("stage-file", is_staged.into())
1924 .disabled(!self.has_write_access(cx))
1925 .fill()
1926 .elevation(ElevationIndex::Surface)
1927 .on_click({
1928 let entry = entry.clone();
1929 let git_panel = entity.downgrade();
1930 move |_, window, cx| {
1931 git_panel
1932 .update(cx, |this, cx| {
1933 this.toggle_staged_for_entry(&entry, window, cx);
1934 cx.stop_propagation();
1935 })
1936 .ok();
1937 }
1938 });
1939 Some(
1940 h_flex()
1941 .id("start-slot")
1942 .text_lg()
1943 .child(checkbox)
1944 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1945 // prevent the list item active state triggering when toggling checkbox
1946 cx.stop_propagation();
1947 })
1948 .into_any_element(),
1949 )
1950 }
1951
1952 fn render_entries(
1953 &self,
1954 has_write_access: bool,
1955 _: &Window,
1956 cx: &mut Context<Self>,
1957 ) -> impl IntoElement {
1958 let entry_count = self.entries.len();
1959
1960 v_flex()
1961 .size_full()
1962 .flex_grow()
1963 .overflow_hidden()
1964 .child(
1965 uniform_list(cx.entity().clone(), "entries", entry_count, {
1966 move |this, range, window, cx| {
1967 let mut items = Vec::with_capacity(range.end - range.start);
1968
1969 for ix in range {
1970 match &this.entries.get(ix) {
1971 Some(GitListEntry::GitStatusEntry(entry)) => {
1972 items.push(this.render_entry(
1973 ix,
1974 entry,
1975 has_write_access,
1976 window,
1977 cx,
1978 ));
1979 }
1980 Some(GitListEntry::Header(header)) => {
1981 items.push(this.render_list_header(
1982 ix,
1983 header,
1984 has_write_access,
1985 window,
1986 cx,
1987 ));
1988 }
1989 None => {}
1990 }
1991 }
1992
1993 items
1994 }
1995 })
1996 .size_full()
1997 .with_sizing_behavior(ListSizingBehavior::Infer)
1998 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1999 .track_scroll(self.scroll_handle.clone()),
2000 )
2001 .on_mouse_down(
2002 MouseButton::Right,
2003 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2004 this.deploy_panel_context_menu(event.position, window, cx)
2005 }),
2006 )
2007 .children(self.render_scrollbar(cx))
2008 }
2009
2010 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
2011 Label::new(label.into()).color(color).single_line()
2012 }
2013
2014 fn render_list_header(
2015 &self,
2016 ix: usize,
2017 header: &GitHeaderEntry,
2018 _: bool,
2019 _: &Window,
2020 _: &Context<Self>,
2021 ) -> AnyElement {
2022 div()
2023 .w_full()
2024 .child(
2025 ListItem::new(ix)
2026 .spacing(ListItemSpacing::Sparse)
2027 .disabled(true)
2028 .child(
2029 Label::new(header.title())
2030 .color(Color::Muted)
2031 .size(LabelSize::Small)
2032 .single_line(),
2033 ),
2034 )
2035 .into_any_element()
2036 }
2037
2038 fn load_commit_details(
2039 &self,
2040 sha: &str,
2041 cx: &mut Context<Self>,
2042 ) -> Task<Result<CommitDetails>> {
2043 let Some(repo) = self.active_repository.clone() else {
2044 return Task::ready(Err(anyhow::anyhow!("no active repo")));
2045 };
2046 repo.update(cx, |repo, cx| repo.show(sha, cx))
2047 }
2048
2049 fn deploy_entry_context_menu(
2050 &mut self,
2051 position: Point<Pixels>,
2052 ix: usize,
2053 window: &mut Window,
2054 cx: &mut Context<Self>,
2055 ) {
2056 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
2057 return;
2058 };
2059 let stage_title = if entry.status.is_staged() == Some(true) {
2060 "Unstage File"
2061 } else {
2062 "Stage File"
2063 };
2064 let revert_title = if entry.status.is_deleted() {
2065 "Restore file"
2066 } else if entry.status.is_created() {
2067 "Trash file"
2068 } else {
2069 "Discard changes"
2070 };
2071 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2072 context_menu
2073 .action(stage_title, ToggleStaged.boxed_clone())
2074 .action(revert_title, git::RestoreFile.boxed_clone())
2075 .separator()
2076 .action("Open Diff", Confirm.boxed_clone())
2077 .action("Open File", SecondaryConfirm.boxed_clone())
2078 });
2079 self.selected_entry = Some(ix);
2080 self.set_context_menu(context_menu, position, window, cx);
2081 }
2082
2083 fn deploy_panel_context_menu(
2084 &mut self,
2085 position: Point<Pixels>,
2086 window: &mut Window,
2087 cx: &mut Context<Self>,
2088 ) {
2089 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
2090 context_menu
2091 .action("Stage All", StageAll.boxed_clone())
2092 .action("Unstage All", UnstageAll.boxed_clone())
2093 .action("Open Diff", project_diff::Diff.boxed_clone())
2094 .separator()
2095 .action("Discard Tracked Changes", RestoreTrackedFiles.boxed_clone())
2096 .action("Trash Untracked Files", TrashUntrackedFiles.boxed_clone())
2097 });
2098 self.set_context_menu(context_menu, position, window, cx);
2099 }
2100
2101 fn set_context_menu(
2102 &mut self,
2103 context_menu: Entity<ContextMenu>,
2104 position: Point<Pixels>,
2105 window: &Window,
2106 cx: &mut Context<Self>,
2107 ) {
2108 let subscription = cx.subscribe_in(
2109 &context_menu,
2110 window,
2111 |this, _, _: &DismissEvent, window, cx| {
2112 if this.context_menu.as_ref().is_some_and(|context_menu| {
2113 context_menu.0.focus_handle(cx).contains_focused(window, cx)
2114 }) {
2115 cx.focus_self(window);
2116 }
2117 this.context_menu.take();
2118 cx.notify();
2119 },
2120 );
2121 self.context_menu = Some((context_menu, position, subscription));
2122 cx.notify();
2123 }
2124
2125 fn render_entry(
2126 &self,
2127 ix: usize,
2128 entry: &GitStatusEntry,
2129 has_write_access: bool,
2130 window: &Window,
2131 cx: &Context<Self>,
2132 ) -> AnyElement {
2133 let display_name = entry
2134 .repo_path
2135 .file_name()
2136 .map(|name| name.to_string_lossy().into_owned())
2137 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
2138
2139 let repo_path = entry.repo_path.clone();
2140 let selected = self.selected_entry == Some(ix);
2141 let status_style = GitPanelSettings::get_global(cx).status_style;
2142 let status = entry.status;
2143 let has_conflict = status.is_conflicted();
2144 let is_modified = status.is_modified();
2145 let is_deleted = status.is_deleted();
2146
2147 let label_color = if status_style == StatusStyle::LabelColor {
2148 if has_conflict {
2149 Color::Conflict
2150 } else if is_modified {
2151 Color::Modified
2152 } else if is_deleted {
2153 // We don't want a bunch of red labels in the list
2154 Color::Disabled
2155 } else {
2156 Color::Created
2157 }
2158 } else {
2159 Color::Default
2160 };
2161
2162 let path_color = if status.is_deleted() {
2163 Color::Disabled
2164 } else {
2165 Color::Muted
2166 };
2167
2168 let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
2169
2170 let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
2171
2172 if !self.has_staged_changes() && !self.has_conflicts() && !entry.status.is_created() {
2173 is_staged = ToggleState::Selected;
2174 }
2175
2176 let checkbox = Checkbox::new(id, is_staged)
2177 .disabled(!has_write_access)
2178 .fill()
2179 .placeholder(!self.has_staged_changes() && !self.has_conflicts())
2180 .elevation(ElevationIndex::Surface)
2181 .on_click({
2182 let entry = entry.clone();
2183 cx.listener(move |this, _, window, cx| {
2184 this.toggle_staged_for_entry(
2185 &GitListEntry::GitStatusEntry(entry.clone()),
2186 window,
2187 cx,
2188 );
2189 cx.stop_propagation();
2190 })
2191 });
2192
2193 let start_slot = h_flex()
2194 .id(("start-slot", ix))
2195 .gap(DynamicSpacing::Base04.rems(cx))
2196 .child(checkbox)
2197 .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
2198 .child(git_status_icon(status, cx))
2199 .on_mouse_down(MouseButton::Left, |_, _, cx| {
2200 // prevent the list item active state triggering when toggling checkbox
2201 cx.stop_propagation();
2202 });
2203
2204 div()
2205 .w_full()
2206 .child(
2207 ListItem::new(ix)
2208 .spacing(ListItemSpacing::Sparse)
2209 .start_slot(start_slot)
2210 .toggle_state(selected)
2211 .focused(selected && self.focus_handle(cx).is_focused(window))
2212 .disabled(!has_write_access)
2213 .on_click({
2214 cx.listener(move |this, event: &ClickEvent, window, cx| {
2215 this.selected_entry = Some(ix);
2216 cx.notify();
2217 if event.modifiers().secondary() {
2218 this.open_file(&Default::default(), window, cx)
2219 } else {
2220 this.open_diff(&Default::default(), window, cx);
2221 }
2222 })
2223 })
2224 .on_secondary_mouse_down(cx.listener(
2225 move |this, event: &MouseDownEvent, window, cx| {
2226 this.deploy_entry_context_menu(event.position, ix, window, cx);
2227 cx.stop_propagation();
2228 },
2229 ))
2230 .child(
2231 h_flex()
2232 .when_some(repo_path.parent(), |this, parent| {
2233 let parent_str = parent.to_string_lossy();
2234 if !parent_str.is_empty() {
2235 this.child(
2236 self.entry_label(format!("{}/", parent_str), path_color)
2237 .when(status.is_deleted(), |this| this.strikethrough()),
2238 )
2239 } else {
2240 this
2241 }
2242 })
2243 .child(
2244 self.entry_label(display_name.clone(), label_color)
2245 .when(status.is_deleted(), |this| this.strikethrough()),
2246 ),
2247 ),
2248 )
2249 .into_any_element()
2250 }
2251
2252 fn has_write_access(&self, cx: &App) -> bool {
2253 !self.project.read(cx).is_read_only(cx)
2254 }
2255}
2256
2257impl Render for GitPanel {
2258 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2259 let project = self.project.read(cx);
2260 let has_entries = self.entries.len() > 0;
2261 let room = self
2262 .workspace
2263 .upgrade()
2264 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
2265
2266 let has_write_access = self.has_write_access(cx);
2267
2268 let has_co_authors = room.map_or(false, |room| {
2269 room.read(cx)
2270 .remote_participants()
2271 .values()
2272 .any(|remote_participant| remote_participant.can_write())
2273 });
2274
2275 v_flex()
2276 .id("git_panel")
2277 .key_context(self.dispatch_context(window, cx))
2278 .track_focus(&self.focus_handle)
2279 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
2280 .when(has_write_access && !project.is_read_only(cx), |this| {
2281 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
2282 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
2283 }))
2284 .on_action(cx.listener(GitPanel::commit))
2285 })
2286 .on_action(cx.listener(Self::select_first))
2287 .on_action(cx.listener(Self::select_next))
2288 .on_action(cx.listener(Self::select_prev))
2289 .on_action(cx.listener(Self::select_last))
2290 .on_action(cx.listener(Self::close_panel))
2291 .on_action(cx.listener(Self::open_diff))
2292 .on_action(cx.listener(Self::open_file))
2293 .on_action(cx.listener(Self::revert_selected))
2294 .on_action(cx.listener(Self::focus_changes_list))
2295 .on_action(cx.listener(Self::focus_editor))
2296 .on_action(cx.listener(Self::toggle_staged_for_selected))
2297 .on_action(cx.listener(Self::stage_all))
2298 .on_action(cx.listener(Self::unstage_all))
2299 .on_action(cx.listener(Self::discard_tracked_changes))
2300 .on_action(cx.listener(Self::clean_all))
2301 .when(has_write_access && has_co_authors, |git_panel| {
2302 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
2303 })
2304 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
2305 .on_hover(cx.listener(|this, hovered, window, cx| {
2306 if *hovered {
2307 this.show_scrollbar = true;
2308 this.hide_scrollbar_task.take();
2309 cx.notify();
2310 } else if !this.focus_handle.contains_focused(window, cx) {
2311 this.hide_scrollbar(window, cx);
2312 }
2313 }))
2314 .size_full()
2315 .overflow_hidden()
2316 .bg(ElevationIndex::Surface.bg(cx))
2317 .child(if has_entries {
2318 v_flex()
2319 .size_full()
2320 .children(self.render_panel_header(window, cx))
2321 .child(self.render_entries(has_write_access, window, cx))
2322 .children(self.render_previous_commit(cx))
2323 .child(self.render_commit_editor(window, cx))
2324 .into_any_element()
2325 } else {
2326 self.render_empty_state(cx).into_any_element()
2327 })
2328 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2329 deferred(
2330 anchored()
2331 .position(*position)
2332 .anchor(gpui::Corner::TopLeft)
2333 .child(menu.clone()),
2334 )
2335 .with_priority(1)
2336 }))
2337 }
2338}
2339
2340impl Focusable for GitPanel {
2341 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2342 self.focus_handle.clone()
2343 }
2344}
2345
2346impl EventEmitter<Event> for GitPanel {}
2347
2348impl EventEmitter<PanelEvent> for GitPanel {}
2349
2350pub(crate) struct GitPanelAddon {
2351 pub(crate) workspace: WeakEntity<Workspace>,
2352}
2353
2354impl editor::Addon for GitPanelAddon {
2355 fn to_any(&self) -> &dyn std::any::Any {
2356 self
2357 }
2358
2359 fn render_buffer_header_controls(
2360 &self,
2361 excerpt_info: &ExcerptInfo,
2362 window: &Window,
2363 cx: &App,
2364 ) -> Option<AnyElement> {
2365 let file = excerpt_info.buffer.file()?;
2366 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
2367
2368 git_panel
2369 .read(cx)
2370 .render_buffer_header_controls(&git_panel, &file, window, cx)
2371 }
2372}
2373
2374impl Panel for GitPanel {
2375 fn persistent_name() -> &'static str {
2376 "GitPanel"
2377 }
2378
2379 fn position(&self, _: &Window, cx: &App) -> DockPosition {
2380 GitPanelSettings::get_global(cx).dock
2381 }
2382
2383 fn position_is_valid(&self, position: DockPosition) -> bool {
2384 matches!(position, DockPosition::Left | DockPosition::Right)
2385 }
2386
2387 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2388 settings::update_settings_file::<GitPanelSettings>(
2389 self.fs.clone(),
2390 cx,
2391 move |settings, _| settings.dock = Some(position),
2392 );
2393 }
2394
2395 fn size(&self, _: &Window, cx: &App) -> Pixels {
2396 self.width
2397 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2398 }
2399
2400 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2401 self.width = size;
2402 self.serialize(cx);
2403 cx.notify();
2404 }
2405
2406 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2407 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2408 }
2409
2410 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2411 Some("Git Panel")
2412 }
2413
2414 fn toggle_action(&self) -> Box<dyn Action> {
2415 Box::new(ToggleFocus)
2416 }
2417
2418 fn activation_priority(&self) -> u32 {
2419 2
2420 }
2421}
2422
2423impl PanelHeader for GitPanel {}
2424
2425struct GitPanelMessageTooltip {
2426 commit_tooltip: Option<Entity<CommitTooltip>>,
2427}
2428
2429impl GitPanelMessageTooltip {
2430 fn new(
2431 git_panel: Entity<GitPanel>,
2432 sha: SharedString,
2433 window: &mut Window,
2434 cx: &mut App,
2435 ) -> Entity<Self> {
2436 cx.new(|cx| {
2437 cx.spawn_in(window, |this, mut cx| async move {
2438 let details = git_panel
2439 .update(&mut cx, |git_panel, cx| {
2440 git_panel.load_commit_details(&sha, cx)
2441 })?
2442 .await?;
2443
2444 let commit_details = editor::commit_tooltip::CommitDetails {
2445 sha: details.sha.clone(),
2446 committer_name: details.committer_name.clone(),
2447 committer_email: details.committer_email.clone(),
2448 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2449 message: Some(editor::commit_tooltip::ParsedCommitMessage {
2450 message: details.message.clone(),
2451 ..Default::default()
2452 }),
2453 };
2454
2455 this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2456 this.commit_tooltip =
2457 Some(cx.new(move |cx| CommitTooltip::new(commit_details, window, cx)));
2458 cx.notify();
2459 })
2460 })
2461 .detach();
2462
2463 Self {
2464 commit_tooltip: None,
2465 }
2466 })
2467 }
2468}
2469
2470impl Render for GitPanelMessageTooltip {
2471 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2472 if let Some(commit_tooltip) = &self.commit_tooltip {
2473 commit_tooltip.clone().into_any_element()
2474 } else {
2475 gpui::Empty.into_any_element()
2476 }
2477 }
2478}