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