1use crate::git_panel_settings::StatusStyle;
2use crate::repository_selector::RepositorySelectorPopoverMenu;
3use crate::ProjectDiff;
4use crate::{
5 git_panel_settings::GitPanelSettings, git_status_icon, repository_selector::RepositorySelector,
6};
7use anyhow::{Context as _, Result};
8use db::kvp::KEY_VALUE_STORE;
9use editor::actions::MoveToEnd;
10use editor::scroll::ScrollbarAutoHide;
11use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
12use futures::channel::mpsc;
13use futures::{SinkExt, StreamExt as _};
14use git::repository::RepoPath;
15use git::status::FileStatus;
16use git::{
17 CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll, COMMIT_MESSAGE,
18};
19use gpui::*;
20use language::{Buffer, BufferId};
21use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
22use project::git::{GitRepo, RepositoryHandle};
23use project::{CreateOptions, Fs, Project, ProjectPath};
24use rpc::proto;
25use serde::{Deserialize, Serialize};
26use settings::Settings as _;
27use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
28use theme::ThemeSettings;
29use ui::{
30 prelude::*, ButtonLike, Checkbox, Divider, DividerColor, ElevationIndex, ListItem,
31 ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
32};
33use util::{ResultExt, TryFutureExt};
34use workspace::notifications::{DetachAndPromptErr, NotificationId};
35use workspace::Toast;
36use workspace::{
37 dock::{DockPosition, Panel, PanelEvent},
38 Item, Workspace,
39};
40
41actions!(
42 git_panel,
43 [
44 Close,
45 ToggleFocus,
46 OpenMenu,
47 OpenSelected,
48 FocusEditor,
49 FocusChanges,
50 FillCoAuthors,
51 ]
52);
53
54const GIT_PANEL_KEY: &str = "GitPanel";
55
56const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
57
58pub fn init(cx: &mut App) {
59 cx.observe_new(
60 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
61 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
62 workspace.toggle_panel_focus::<GitPanel>(window, cx);
63 });
64 },
65 )
66 .detach();
67}
68
69#[derive(Debug, Clone)]
70pub enum Event {
71 Focus,
72 OpenedEntry { path: ProjectPath },
73}
74
75#[derive(Serialize, Deserialize)]
76struct SerializedGitPanel {
77 width: Option<Pixels>,
78}
79
80#[derive(Debug, PartialEq, Eq, Clone)]
81pub struct GitListEntry {
82 depth: usize,
83 display_name: String,
84 repo_path: RepoPath,
85 status: FileStatus,
86 is_staged: Option<bool>,
87}
88
89pub struct GitPanel {
90 current_modifiers: Modifiers,
91 focus_handle: FocusHandle,
92 fs: Arc<dyn Fs>,
93 hide_scrollbar_task: Option<Task<()>>,
94 pending_serialization: Task<Option<()>>,
95 workspace: WeakEntity<Workspace>,
96 project: Entity<Project>,
97 active_repository: Option<RepositoryHandle>,
98 scroll_handle: UniformListScrollHandle,
99 scrollbar_state: ScrollbarState,
100 selected_entry: Option<usize>,
101 show_scrollbar: bool,
102 update_visible_entries_task: Task<()>,
103 repository_selector: Entity<RepositorySelector>,
104 commit_editor: Entity<Editor>,
105 visible_entries: Vec<GitListEntry>,
106 all_staged: Option<bool>,
107 width: Option<Pixels>,
108 err_sender: mpsc::Sender<anyhow::Error>,
109 commit_task: Task<()>,
110 commit_pending: bool,
111}
112
113fn commit_message_buffer(
114 project: &Entity<Project>,
115 active_repository: &RepositoryHandle,
116 cx: &mut App,
117) -> Task<Result<Entity<Buffer>>> {
118 match &active_repository.git_repo {
119 GitRepo::Local(repo) => {
120 let commit_message_file = repo.dot_git_dir().join(*COMMIT_MESSAGE);
121 let fs = project.read(cx).fs().clone();
122 let project = project.downgrade();
123 cx.spawn(|mut cx| async move {
124 fs.create_file(
125 &commit_message_file,
126 CreateOptions {
127 overwrite: false,
128 ignore_if_exists: true,
129 },
130 )
131 .await
132 .with_context(|| format!("creating commit message file {commit_message_file:?}"))?;
133 let buffer = project
134 .update(&mut cx, |project, cx| {
135 project.open_local_buffer(&commit_message_file, cx)
136 })?
137 .await
138 .with_context(|| {
139 format!("opening commit message buffer at {commit_message_file:?}",)
140 })?;
141 Ok(buffer)
142 })
143 }
144 GitRepo::Remote {
145 project_id,
146 client,
147 worktree_id,
148 work_directory_id,
149 } => {
150 let request = client.request(proto::OpenCommitMessageBuffer {
151 project_id: project_id.0,
152 worktree_id: worktree_id.to_proto(),
153 work_directory_id: work_directory_id.to_proto(),
154 });
155 let project = project.downgrade();
156 cx.spawn(|mut cx| async move {
157 let response = request.await.context("requesting to open commit buffer")?;
158 let buffer_id = BufferId::new(response.buffer_id)?;
159 let buffer = project
160 .update(&mut cx, {
161 |project, cx| project.wait_for_remote_buffer(buffer_id, cx)
162 })?
163 .await?;
164 Ok(buffer)
165 })
166 }
167 }
168}
169
170fn commit_message_editor(
171 commit_message_buffer: Option<Entity<Buffer>>,
172 window: &mut Window,
173 cx: &mut Context<'_, Editor>,
174) -> Editor {
175 let theme = ThemeSettings::get_global(cx);
176
177 let mut text_style = window.text_style();
178 let refinement = TextStyleRefinement {
179 font_family: Some(theme.buffer_font.family.clone()),
180 font_features: Some(FontFeatures::disable_ligatures()),
181 font_size: Some(px(12.).into()),
182 color: Some(cx.theme().colors().editor_foreground),
183 background_color: Some(gpui::transparent_black()),
184 ..Default::default()
185 };
186 text_style.refine(&refinement);
187
188 let mut commit_editor = if let Some(commit_message_buffer) = commit_message_buffer {
189 let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
190 Editor::new(
191 EditorMode::AutoHeight { max_lines: 10 },
192 buffer,
193 None,
194 false,
195 window,
196 cx,
197 )
198 } else {
199 Editor::auto_height(10, window, cx)
200 };
201 commit_editor.set_use_autoclose(false);
202 commit_editor.set_show_gutter(false, cx);
203 commit_editor.set_show_wrap_guides(false, cx);
204 commit_editor.set_show_indent_guides(false, cx);
205 commit_editor.set_text_style_refinement(refinement);
206 commit_editor.set_placeholder_text("Enter commit message", cx);
207 commit_editor
208}
209
210impl GitPanel {
211 pub fn new(
212 workspace: &mut Workspace,
213 window: &mut Window,
214 commit_message_buffer: Option<Entity<Buffer>>,
215 cx: &mut Context<Workspace>,
216 ) -> Entity<Self> {
217 let fs = workspace.app_state().fs.clone();
218 let project = workspace.project().clone();
219 let git_state = project.read(cx).git_state().clone();
220 let active_repository = project.read(cx).active_repository(cx);
221 let (err_sender, mut err_receiver) = mpsc::channel(1);
222 let workspace = cx.entity().downgrade();
223
224 let git_panel = cx.new(|cx| {
225 let focus_handle = cx.focus_handle();
226 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
227 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
228 this.hide_scrollbar(window, cx);
229 })
230 .detach();
231
232 let commit_editor =
233 cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx));
234 commit_editor.update(cx, |editor, cx| {
235 editor.clear(window, cx);
236 });
237
238 let scroll_handle = UniformListScrollHandle::new();
239
240 cx.subscribe_in(
241 &git_state,
242 window,
243 move |this, git_state, event, window, cx| match event {
244 project::git::Event::RepositoriesUpdated => {
245 this.active_repository = git_state.read(cx).active_repository();
246 this.schedule_update(window, cx);
247 }
248 },
249 )
250 .detach();
251
252 let repository_selector =
253 cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
254
255 let mut git_panel = Self {
256 focus_handle: cx.focus_handle(),
257 pending_serialization: Task::ready(None),
258 visible_entries: Vec::new(),
259 all_staged: None,
260 current_modifiers: window.modifiers(),
261 width: Some(px(360.)),
262 scrollbar_state: ScrollbarState::new(scroll_handle.clone())
263 .parent_model(&cx.entity()),
264 repository_selector,
265 selected_entry: None,
266 show_scrollbar: false,
267 hide_scrollbar_task: None,
268 update_visible_entries_task: Task::ready(()),
269 commit_task: Task::ready(()),
270 commit_pending: false,
271 active_repository,
272 scroll_handle,
273 fs,
274 commit_editor,
275 project,
276 err_sender,
277 workspace,
278 };
279 git_panel.schedule_update(window, cx);
280 git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
281 git_panel
282 });
283
284 let handle = git_panel.downgrade();
285 cx.spawn(|_, mut cx| async move {
286 while let Some(e) = err_receiver.next().await {
287 let Some(this) = handle.upgrade() else {
288 break;
289 };
290 if this
291 .update(&mut cx, |this, cx| {
292 this.show_err_toast("git operation error", e, cx);
293 })
294 .is_err()
295 {
296 break;
297 }
298 }
299 })
300 .detach();
301
302 cx.subscribe_in(
303 &git_panel,
304 window,
305 move |workspace, _, event: &Event, window, cx| match event.clone() {
306 Event::OpenedEntry { path } => {
307 workspace
308 .open_path_preview(path, None, false, false, window, cx)
309 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
310 Some(format!("{e}"))
311 });
312 }
313 Event::Focus => { /* TODO */ }
314 },
315 )
316 .detach();
317
318 git_panel
319 }
320
321 pub fn set_focused_path(&mut self, path: ProjectPath, _: &mut Window, cx: &mut Context<Self>) {
322 let Some(git_repo) = self.active_repository.as_ref() else {
323 return;
324 };
325 let Some(repo_path) = git_repo.project_path_to_repo_path(&path) else {
326 return;
327 };
328 let Ok(ix) = self
329 .visible_entries
330 .binary_search_by_key(&&repo_path, |entry| &entry.repo_path)
331 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_executor().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.visible_entries.first().is_some() {
479 self.selected_entry = Some(0);
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.visible_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 self.selected_entry = Some(new_selected_entry);
498
499 self.scroll_to_selected_entry(cx);
500 }
501
502 cx.notify();
503 }
504
505 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
506 let item_count = self.visible_entries.len();
507 if item_count == 0 {
508 return;
509 }
510
511 if let Some(selected_entry) = self.selected_entry {
512 let new_selected_entry = if selected_entry < item_count - 1 {
513 selected_entry + 1
514 } else {
515 selected_entry
516 };
517
518 self.selected_entry = Some(new_selected_entry);
519
520 self.scroll_to_selected_entry(cx);
521 }
522
523 cx.notify();
524 }
525
526 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
527 if self.visible_entries.last().is_some() {
528 self.selected_entry = Some(self.visible_entries.len() - 1);
529 self.scroll_to_selected_entry(cx);
530 }
531 }
532
533 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
534 self.commit_editor.update(cx, |editor, cx| {
535 window.focus(&editor.focus_handle(cx));
536 });
537 cx.notify();
538 }
539
540 fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
541 let have_entries = self
542 .active_repository
543 .as_ref()
544 .map_or(false, |active_repository| {
545 active_repository.entry_count() > 0
546 });
547 if have_entries && self.selected_entry.is_none() {
548 self.selected_entry = Some(0);
549 self.scroll_to_selected_entry(cx);
550 cx.notify();
551 }
552 }
553
554 fn focus_changes_list(
555 &mut self,
556 _: &FocusChanges,
557 window: &mut Window,
558 cx: &mut Context<Self>,
559 ) {
560 self.select_first_entry_if_none(cx);
561
562 cx.focus_self(window);
563 cx.notify();
564 }
565
566 fn get_selected_entry(&self) -> Option<&GitListEntry> {
567 self.selected_entry
568 .and_then(|i| self.visible_entries.get(i))
569 }
570
571 fn open_selected(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
572 if let Some(entry) = self
573 .selected_entry
574 .and_then(|i| self.visible_entries.get(i))
575 {
576 self.open_entry(entry, cx);
577 }
578 }
579
580 fn toggle_staged_for_entry(
581 &mut self,
582 entry: &GitListEntry,
583 _window: &mut Window,
584 cx: &mut Context<Self>,
585 ) {
586 let Some(active_repository) = self.active_repository.as_ref() else {
587 return;
588 };
589 let result = if entry.status.is_staged().unwrap_or(false) {
590 active_repository
591 .unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
592 } else {
593 active_repository.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
594 };
595 if let Err(e) = result {
596 self.show_err_toast("toggle staged error", e, cx);
597 }
598 cx.notify();
599 }
600
601 fn toggle_staged_for_selected(
602 &mut self,
603 _: &git::ToggleStaged,
604 window: &mut Window,
605 cx: &mut Context<Self>,
606 ) {
607 if let Some(selected_entry) = self.get_selected_entry().cloned() {
608 self.toggle_staged_for_entry(&selected_entry, window, cx);
609 }
610 }
611
612 fn open_entry(&self, entry: &GitListEntry, cx: &mut Context<Self>) {
613 let Some(active_repository) = self.active_repository.as_ref() else {
614 return;
615 };
616 let Some(path) = active_repository.repo_path_to_project_path(&entry.repo_path) else {
617 return;
618 };
619 let path_exists = self.project.update(cx, |project, cx| {
620 project.entry_for_path(&path, cx).is_some()
621 });
622 if !path_exists {
623 return;
624 }
625 // TODO maybe move all of this into project?
626 cx.emit(Event::OpenedEntry { path });
627 }
628
629 fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context<Self>) {
630 let Some(active_repository) = self.active_repository.as_ref() else {
631 return;
632 };
633 for entry in &mut self.visible_entries {
634 entry.is_staged = Some(true);
635 }
636 self.all_staged = Some(true);
637
638 if let Err(e) = active_repository.stage_all(self.err_sender.clone()) {
639 self.show_err_toast("stage all error", e, cx);
640 };
641 }
642
643 fn unstage_all(&mut self, _: &git::UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
644 let Some(active_repository) = self.active_repository.as_ref() else {
645 return;
646 };
647 for entry in &mut self.visible_entries {
648 entry.is_staged = Some(false);
649 }
650 self.all_staged = Some(false);
651 if let Err(e) = active_repository.unstage_all(self.err_sender.clone()) {
652 self.show_err_toast("unstage all error", e, cx);
653 };
654 }
655
656 fn discard_all(&mut self, _: &git::RevertAll, _window: &mut Window, _cx: &mut Context<Self>) {
657 // TODO: Implement discard all
658 println!("Discard all triggered");
659 }
660
661 /// Commit all staged changes
662 fn commit_changes(
663 &mut self,
664 _: &git::CommitChanges,
665 name_and_email: Option<(SharedString, SharedString)>,
666 window: &mut Window,
667 cx: &mut Context<Self>,
668 ) {
669 let Some(active_repository) = self.active_repository.clone() else {
670 return;
671 };
672 if !active_repository.can_commit(false) {
673 return;
674 }
675 if self.commit_editor.read(cx).is_empty(cx) {
676 return;
677 }
678 self.commit_pending = true;
679 let save_task = self.commit_editor.update(cx, |editor, cx| {
680 editor.save(false, self.project.clone(), window, cx)
681 });
682 let mut err_sender = self.err_sender.clone();
683 let commit_editor = self.commit_editor.clone();
684 self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
685 match save_task.await {
686 Ok(()) => {
687 if let Some(Ok(())) = cx
688 .update(|_, cx| {
689 active_repository.commit(name_and_email, err_sender.clone(), cx)
690 })
691 .ok()
692 {
693 cx.update(|window, cx| {
694 commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
695 })
696 .ok();
697 }
698 }
699 Err(e) => {
700 err_sender.send(e).await.ok();
701 }
702 }
703 git_panel
704 .update(&mut cx, |git_panel, _| {
705 git_panel.commit_pending = false;
706 })
707 .ok();
708 });
709 }
710
711 /// Commit all changes, regardless of whether they are staged or not
712 fn commit_all_changes(
713 &mut self,
714 _: &git::CommitAllChanges,
715 name_and_email: Option<(SharedString, SharedString)>,
716 window: &mut Window,
717 cx: &mut Context<Self>,
718 ) {
719 let Some(active_repository) = self.active_repository.clone() else {
720 return;
721 };
722 if !active_repository.can_commit(true) {
723 return;
724 }
725 if self.commit_editor.read(cx).is_empty(cx) {
726 return;
727 }
728 self.commit_pending = true;
729 let save_task = self.commit_editor.update(cx, |editor, cx| {
730 editor.save(false, self.project.clone(), window, cx)
731 });
732 let mut err_sender = self.err_sender.clone();
733 let commit_editor = self.commit_editor.clone();
734 self.commit_task = cx.spawn_in(window, |git_panel, mut cx| async move {
735 match save_task.await {
736 Ok(()) => {
737 if let Some(Ok(())) = cx
738 .update(|_, cx| {
739 active_repository.commit_all(name_and_email, err_sender.clone(), cx)
740 })
741 .ok()
742 {
743 cx.update(|window, cx| {
744 commit_editor.update(cx, |editor, cx| editor.clear(window, cx));
745 })
746 .ok();
747 }
748 }
749 Err(e) => {
750 err_sender.send(e).await.ok();
751 }
752 }
753 git_panel
754 .update(&mut cx, |git_panel, _| {
755 git_panel.commit_pending = false;
756 })
757 .ok();
758 });
759 }
760
761 fn fill_co_authors(&mut self, _: &FillCoAuthors, window: &mut Window, cx: &mut Context<Self>) {
762 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
763
764 let Some(room) = self
765 .workspace
766 .upgrade()
767 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
768 else {
769 return;
770 };
771
772 let mut existing_text = self.commit_editor.read(cx).text(cx);
773 existing_text.make_ascii_lowercase();
774 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
775 let mut ends_with_co_authors = false;
776 let existing_co_authors = existing_text
777 .lines()
778 .filter_map(|line| {
779 let line = line.trim();
780 if line.starts_with(&lowercase_co_author_prefix) {
781 ends_with_co_authors = true;
782 Some(line)
783 } else {
784 ends_with_co_authors = false;
785 None
786 }
787 })
788 .collect::<HashSet<_>>();
789
790 let new_co_authors = room
791 .read(cx)
792 .remote_participants()
793 .values()
794 .filter(|participant| participant.can_write())
795 .map(|participant| participant.user.as_ref())
796 .filter_map(|user| {
797 let email = user.email.as_deref()?;
798 let name = user.name.as_deref().unwrap_or(&user.github_login);
799 Some(format!("{CO_AUTHOR_PREFIX}{name} <{email}>"))
800 })
801 .filter(|co_author| {
802 !existing_co_authors.contains(co_author.to_ascii_lowercase().as_str())
803 })
804 .collect::<Vec<_>>();
805 if new_co_authors.is_empty() {
806 return;
807 }
808
809 self.commit_editor.update(cx, |editor, cx| {
810 let editor_end = editor.buffer().read(cx).read(cx).len();
811 let mut edit = String::new();
812 if !ends_with_co_authors {
813 edit.push('\n');
814 }
815 for co_author in new_co_authors {
816 edit.push('\n');
817 edit.push_str(&co_author);
818 }
819
820 editor.edit(Some((editor_end..editor_end, edit)), cx);
821 editor.move_to_end(&MoveToEnd, window, cx);
822 editor.focus_handle(cx).focus(window);
823 });
824 }
825
826 fn for_each_visible_entry(
827 &self,
828 range: Range<usize>,
829 cx: &mut Context<Self>,
830 mut callback: impl FnMut(usize, GitListEntry, &mut Context<Self>),
831 ) {
832 let visible_entries = &self.visible_entries;
833
834 for (ix, entry) in visible_entries
835 .iter()
836 .enumerate()
837 .skip(range.start)
838 .take(range.end - range.start)
839 {
840 let status = entry.status;
841 let filename = entry
842 .repo_path
843 .file_name()
844 .map(|name| name.to_string_lossy().into_owned())
845 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
846
847 let details = GitListEntry {
848 repo_path: entry.repo_path.clone(),
849 status,
850 depth: 0,
851 display_name: filename,
852 is_staged: entry.is_staged,
853 };
854
855 callback(ix, details, cx);
856 }
857 }
858
859 fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
860 let project = self.project.clone();
861 let handle = cx.entity().downgrade();
862 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
863 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
864 if let Some(git_panel) = handle.upgrade() {
865 let Ok(commit_message_buffer) = git_panel.update_in(&mut cx, |git_panel, _, cx| {
866 git_panel
867 .active_repository
868 .as_ref()
869 .map(|active_repository| {
870 commit_message_buffer(&project, active_repository, cx)
871 })
872 }) else {
873 return;
874 };
875 let commit_message_buffer = match commit_message_buffer {
876 Some(commit_message_buffer) => match commit_message_buffer
877 .await
878 .context("opening commit buffer on repo update")
879 .log_err()
880 {
881 Some(buffer) => Some(buffer),
882 None => return,
883 },
884 None => None,
885 };
886
887 git_panel
888 .update_in(&mut cx, |git_panel, window, cx| {
889 git_panel.update_visible_entries(cx);
890 git_panel.commit_editor =
891 cx.new(|cx| commit_message_editor(commit_message_buffer, window, cx));
892 })
893 .ok();
894 }
895 });
896 }
897
898 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
899 self.visible_entries.clear();
900
901 let Some(repo) = self.active_repository.as_ref() else {
902 // Just clear entries if no repository is active.
903 cx.notify();
904 return;
905 };
906
907 // First pass - collect all paths
908 let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
909
910 // Second pass - create entries with proper depth calculation
911 let mut all_staged = None;
912 for (ix, entry) in repo.status().enumerate() {
913 let (depth, difference) =
914 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
915 let is_staged = entry.status.is_staged();
916 all_staged = if ix == 0 {
917 is_staged
918 } else {
919 match (all_staged, is_staged) {
920 (None, _) | (_, None) => None,
921 (Some(a), Some(b)) => (a == b).then_some(a),
922 }
923 };
924
925 let display_name = if difference > 1 {
926 // Show partial path for deeply nested files
927 entry
928 .repo_path
929 .as_ref()
930 .iter()
931 .skip(entry.repo_path.components().count() - difference)
932 .collect::<PathBuf>()
933 .to_string_lossy()
934 .into_owned()
935 } else {
936 // Just show filename
937 entry
938 .repo_path
939 .file_name()
940 .map(|name| name.to_string_lossy().into_owned())
941 .unwrap_or_default()
942 };
943
944 let entry = GitListEntry {
945 depth,
946 display_name,
947 repo_path: entry.repo_path.clone(),
948 status: entry.status,
949 is_staged,
950 };
951
952 self.visible_entries.push(entry);
953 }
954 self.all_staged = all_staged;
955
956 // Sort entries by path to maintain consistent order
957 self.visible_entries
958 .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
959
960 self.select_first_entry_if_none(cx);
961
962 cx.notify();
963 }
964
965 fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut Context<Self>) {
966 let Some(workspace) = self.workspace.upgrade() else {
967 return;
968 };
969 let notif_id = NotificationId::Named(id.into());
970 let message = e.to_string();
971 workspace.update(cx, |workspace, cx| {
972 let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
973 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
974 });
975 workspace.show_toast(toast, cx);
976 });
977 }
978}
979
980// GitPanel –– Render
981impl GitPanel {
982 pub fn panel_button(
983 &self,
984 id: impl Into<SharedString>,
985 label: impl Into<SharedString>,
986 ) -> Button {
987 let id = id.into().clone();
988 let label = label.into().clone();
989
990 Button::new(id, label)
991 .label_size(LabelSize::Small)
992 .layer(ElevationIndex::ElevatedSurface)
993 .size(ButtonSize::Compact)
994 .style(ButtonStyle::Filled)
995 }
996
997 pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
998 h_flex()
999 .items_center()
1000 .h(px(8.))
1001 .child(Divider::horizontal_dashed().color(DividerColor::Border))
1002 }
1003
1004 pub fn render_panel_header(
1005 &self,
1006 _window: &mut Window,
1007 cx: &mut Context<Self>,
1008 ) -> impl IntoElement {
1009 let focus_handle = self.focus_handle(cx).clone();
1010 let all_repositories = self
1011 .project
1012 .read(cx)
1013 .git_state()
1014 .read(cx)
1015 .all_repositories();
1016 let entry_count = self
1017 .active_repository
1018 .as_ref()
1019 .map_or(0, RepositoryHandle::entry_count);
1020
1021 let changes_string = match entry_count {
1022 0 => "No changes".to_string(),
1023 1 => "1 change".to_string(),
1024 n => format!("{} changes", n),
1025 };
1026
1027 h_flex()
1028 .h(px(32.))
1029 .items_center()
1030 .px_2()
1031 .bg(ElevationIndex::Surface.bg(cx))
1032 .child(h_flex().gap_2().child(if all_repositories.len() <= 1 {
1033 div()
1034 .id("changes-label")
1035 .text_buffer(cx)
1036 .text_ui_sm(cx)
1037 .child(
1038 Label::new(changes_string)
1039 .single_line()
1040 .size(LabelSize::Small),
1041 )
1042 .into_any_element()
1043 } else {
1044 self.render_repository_selector(cx).into_any_element()
1045 }))
1046 .child(div().flex_grow())
1047 .child(h_flex().gap_2().child(if self.all_staged.unwrap_or(false) {
1048 self.panel_button("unstage-all", "Unstage All")
1049 .tooltip({
1050 let focus_handle = focus_handle.clone();
1051 move |window, cx| {
1052 Tooltip::for_action_in(
1053 "Unstage all changes",
1054 &UnstageAll,
1055 &focus_handle,
1056 window,
1057 cx,
1058 )
1059 }
1060 })
1061 .on_click(cx.listener(move |this, _, window, cx| {
1062 this.unstage_all(&UnstageAll, window, cx)
1063 }))
1064 } else {
1065 self.panel_button("stage-all", "Stage All")
1066 .tooltip({
1067 let focus_handle = focus_handle.clone();
1068 move |window, cx| {
1069 Tooltip::for_action_in(
1070 "Stage all changes",
1071 &StageAll,
1072 &focus_handle,
1073 window,
1074 cx,
1075 )
1076 }
1077 })
1078 .on_click(
1079 cx.listener(move |this, _, window, cx| {
1080 this.stage_all(&StageAll, window, cx)
1081 }),
1082 )
1083 }))
1084 }
1085
1086 pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1087 let active_repository = self.project.read(cx).active_repository(cx);
1088 let repository_display_name = active_repository
1089 .as_ref()
1090 .map(|repo| repo.display_name(self.project.read(cx), cx))
1091 .unwrap_or_default();
1092
1093 let entry_count = self.visible_entries.len();
1094
1095 RepositorySelectorPopoverMenu::new(
1096 self.repository_selector.clone(),
1097 ButtonLike::new("active-repository")
1098 .style(ButtonStyle::Subtle)
1099 .child(
1100 h_flex().w_full().gap_0p5().child(
1101 div()
1102 .overflow_x_hidden()
1103 .flex_grow()
1104 .whitespace_nowrap()
1105 .child(
1106 h_flex()
1107 .gap_1()
1108 .child(
1109 Label::new(repository_display_name).size(LabelSize::Small),
1110 )
1111 .when(entry_count > 0, |flex| {
1112 flex.child(
1113 Label::new(format!("({})", entry_count))
1114 .size(LabelSize::Small)
1115 .color(Color::Muted),
1116 )
1117 })
1118 .into_any_element(),
1119 ),
1120 ),
1121 ),
1122 )
1123 }
1124
1125 pub fn render_commit_editor(
1126 &self,
1127 name_and_email: Option<(SharedString, SharedString)>,
1128 can_commit: bool,
1129 cx: &Context<Self>,
1130 ) -> impl IntoElement {
1131 let editor = self.commit_editor.clone();
1132 let can_commit = can_commit && !editor.read(cx).is_empty(cx);
1133 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
1134 let (can_commit, can_commit_all) =
1135 self.active_repository
1136 .as_ref()
1137 .map_or((false, false), |active_repository| {
1138 (
1139 can_commit && active_repository.can_commit(false),
1140 can_commit && active_repository.can_commit(true),
1141 )
1142 });
1143
1144 let focus_handle_1 = self.focus_handle(cx).clone();
1145 let focus_handle_2 = self.focus_handle(cx).clone();
1146
1147 let commit_staged_button = self
1148 .panel_button("commit-staged-changes", "Commit")
1149 .tooltip(move |window, cx| {
1150 let focus_handle = focus_handle_1.clone();
1151 Tooltip::for_action_in(
1152 "Commit all staged changes",
1153 &CommitChanges,
1154 &focus_handle,
1155 window,
1156 cx,
1157 )
1158 })
1159 .disabled(!can_commit)
1160 .on_click({
1161 let name_and_email = name_and_email.clone();
1162 cx.listener(move |this, _: &ClickEvent, window, cx| {
1163 this.commit_changes(&CommitChanges, name_and_email.clone(), window, cx)
1164 })
1165 });
1166
1167 let commit_all_button = self
1168 .panel_button("commit-all-changes", "Commit All")
1169 .tooltip(move |window, cx| {
1170 let focus_handle = focus_handle_2.clone();
1171 Tooltip::for_action_in(
1172 "Commit all changes, including unstaged changes",
1173 &CommitAllChanges,
1174 &focus_handle,
1175 window,
1176 cx,
1177 )
1178 })
1179 .disabled(!can_commit_all)
1180 .on_click({
1181 let name_and_email = name_and_email.clone();
1182 cx.listener(move |this, _: &ClickEvent, window, cx| {
1183 this.commit_all_changes(&CommitAllChanges, name_and_email.clone(), window, cx)
1184 })
1185 });
1186
1187 div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
1188 v_flex()
1189 .id("commit-editor-container")
1190 .relative()
1191 .h_full()
1192 .py_2p5()
1193 .px_3()
1194 .bg(cx.theme().colors().editor_background)
1195 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1196 window.focus(&editor_focus_handle);
1197 }))
1198 .child(self.commit_editor.clone())
1199 .child(
1200 h_flex()
1201 .absolute()
1202 .bottom_2p5()
1203 .right_3()
1204 .gap_1p5()
1205 .child(div().gap_1().flex_grow())
1206 .child(commit_all_button)
1207 .child(commit_staged_button),
1208 ),
1209 )
1210 }
1211
1212 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1213 h_flex()
1214 .h_full()
1215 .flex_1()
1216 .justify_center()
1217 .items_center()
1218 .child(
1219 v_flex()
1220 .gap_3()
1221 .child("No changes to commit")
1222 .text_ui_sm(cx)
1223 .mx_auto()
1224 .text_color(Color::Placeholder.color(cx)),
1225 )
1226 }
1227
1228 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1229 let scroll_bar_style = self.show_scrollbar(cx);
1230 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1231
1232 if !self.should_show_scrollbar(cx)
1233 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1234 {
1235 return None;
1236 }
1237
1238 Some(
1239 div()
1240 .id("git-panel-vertical-scroll")
1241 .occlude()
1242 .flex_none()
1243 .h_full()
1244 .cursor_default()
1245 .when(show_container, |this| this.pl_1().px_1p5())
1246 .when(!show_container, |this| {
1247 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1248 })
1249 .on_mouse_move(cx.listener(|_, _, _, cx| {
1250 cx.notify();
1251 cx.stop_propagation()
1252 }))
1253 .on_hover(|_, _, cx| {
1254 cx.stop_propagation();
1255 })
1256 .on_any_mouse_down(|_, _, cx| {
1257 cx.stop_propagation();
1258 })
1259 .on_mouse_up(
1260 MouseButton::Left,
1261 cx.listener(|this, _, window, cx| {
1262 if !this.scrollbar_state.is_dragging()
1263 && !this.focus_handle.contains_focused(window, cx)
1264 {
1265 this.hide_scrollbar(window, cx);
1266 cx.notify();
1267 }
1268
1269 cx.stop_propagation();
1270 }),
1271 )
1272 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1273 cx.notify();
1274 }))
1275 .children(Scrollbar::vertical(
1276 // percentage as f32..end_offset as f32,
1277 self.scrollbar_state.clone(),
1278 )),
1279 )
1280 }
1281
1282 fn render_entries(&self, has_write_access: bool, cx: &mut Context<Self>) -> impl IntoElement {
1283 let entry_count = self.visible_entries.len();
1284
1285 v_flex()
1286 .size_full()
1287 .overflow_hidden()
1288 .child(
1289 uniform_list(cx.entity().clone(), "entries", entry_count, {
1290 move |git_panel, range, _window, cx| {
1291 let mut items = Vec::with_capacity(range.end - range.start);
1292 git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
1293 items.push(git_panel.render_entry(ix, details, has_write_access, cx));
1294 });
1295 items
1296 }
1297 })
1298 .size_full()
1299 .with_sizing_behavior(ListSizingBehavior::Infer)
1300 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1301 .track_scroll(self.scroll_handle.clone()),
1302 )
1303 .children(self.render_scrollbar(cx))
1304 }
1305
1306 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
1307 Label::new(label.into()).color(color).single_line()
1308 }
1309
1310 fn render_entry(
1311 &self,
1312 ix: usize,
1313 entry_details: GitListEntry,
1314 has_write_access: bool,
1315 cx: &Context<Self>,
1316 ) -> impl IntoElement {
1317 let repo_path = entry_details.repo_path.clone();
1318 let selected = self.selected_entry == Some(ix);
1319 let status_style = GitPanelSettings::get_global(cx).status_style;
1320 let status = entry_details.status;
1321 let has_conflict = status.is_conflicted();
1322 let is_modified = status.is_modified();
1323 let is_deleted = status.is_deleted();
1324
1325 let label_color = if status_style == StatusStyle::LabelColor {
1326 if has_conflict {
1327 Color::Conflict
1328 } else if is_modified {
1329 Color::Modified
1330 } else if is_deleted {
1331 // We don't want a bunch of red labels in the list
1332 Color::Disabled
1333 } else {
1334 Color::Created
1335 }
1336 } else {
1337 Color::Default
1338 };
1339
1340 let path_color = if status.is_deleted() {
1341 Color::Disabled
1342 } else {
1343 Color::Muted
1344 };
1345
1346 let id: ElementId = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1347
1348 let checkbox = Checkbox::new(
1349 id,
1350 entry_details
1351 .is_staged
1352 .map_or(ToggleState::Indeterminate, ToggleState::from),
1353 )
1354 .disabled(!has_write_access)
1355 .fill()
1356 .elevation(ElevationIndex::Surface)
1357 .on_click({
1358 let handle = cx.entity().downgrade();
1359 let repo_path = repo_path.clone();
1360 move |toggle, _window, cx| {
1361 let Some(this) = handle.upgrade() else {
1362 return;
1363 };
1364 this.update(cx, |this, cx| {
1365 this.visible_entries[ix].is_staged = match *toggle {
1366 ToggleState::Selected => Some(true),
1367 ToggleState::Unselected => Some(false),
1368 ToggleState::Indeterminate => None,
1369 };
1370 let repo_path = repo_path.clone();
1371 let Some(active_repository) = this.active_repository.as_ref() else {
1372 return;
1373 };
1374 let result = match toggle {
1375 ToggleState::Selected | ToggleState::Indeterminate => active_repository
1376 .stage_entries(vec![repo_path], this.err_sender.clone()),
1377 ToggleState::Unselected => active_repository
1378 .unstage_entries(vec![repo_path], this.err_sender.clone()),
1379 };
1380 if let Err(e) = result {
1381 this.show_err_toast("toggle staged error", e, cx);
1382 }
1383 });
1384 }
1385 });
1386
1387 let start_slot = h_flex()
1388 .gap(DynamicSpacing::Base04.rems(cx))
1389 .child(checkbox)
1390 .child(git_status_icon(status, cx));
1391
1392 let id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1393
1394 div().w_full().px_0p5().child(
1395 ListItem::new(id)
1396 .spacing(ListItemSpacing::Sparse)
1397 .start_slot(start_slot)
1398 .toggle_state(selected)
1399 .disabled(!has_write_access)
1400 .on_click({
1401 let repo_path = entry_details.repo_path.clone();
1402 cx.listener(move |this, _, window, cx| {
1403 this.selected_entry = Some(ix);
1404 window.dispatch_action(Box::new(OpenSelected), cx);
1405 cx.notify();
1406 let Some(workspace) = this.workspace.upgrade() else {
1407 return;
1408 };
1409 let Some(git_repo) = this.active_repository.as_ref() else {
1410 return;
1411 };
1412 let Some(path) = git_repo.repo_path_to_project_path(&repo_path).and_then(
1413 |project_path| this.project.read(cx).absolute_path(&project_path, cx),
1414 ) else {
1415 return;
1416 };
1417 workspace.update(cx, |workspace, cx| {
1418 ProjectDiff::deploy_at(workspace, Some(path.into()), window, cx);
1419 })
1420 })
1421 })
1422 .child(
1423 h_flex()
1424 .when_some(repo_path.parent(), |this, parent| {
1425 let parent_str = parent.to_string_lossy();
1426 if !parent_str.is_empty() {
1427 this.child(
1428 self.entry_label(format!("{}/", parent_str), path_color)
1429 .when(status.is_deleted(), |this| this.strikethrough(true)),
1430 )
1431 } else {
1432 this
1433 }
1434 })
1435 .child(
1436 self.entry_label(entry_details.display_name.clone(), label_color)
1437 .when(status.is_deleted(), |this| this.strikethrough(true)),
1438 ),
1439 ),
1440 )
1441 }
1442}
1443
1444impl Render for GitPanel {
1445 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1446 let project = self.project.read(cx);
1447 let has_entries = self
1448 .active_repository
1449 .as_ref()
1450 .map_or(false, |active_repository| {
1451 active_repository.entry_count() > 0
1452 });
1453 let room = self
1454 .workspace
1455 .upgrade()
1456 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1457
1458 let has_write_access = room
1459 .as_ref()
1460 .map_or(true, |room| room.read(cx).local_participant().can_write());
1461 let (can_commit, name_and_email) = match &room {
1462 Some(room) => {
1463 if project.is_via_collab() {
1464 if has_write_access {
1465 let name_and_email =
1466 room.read(cx).local_participant_user(cx).and_then(|user| {
1467 let email = SharedString::from(user.email.clone()?);
1468 let name = user
1469 .name
1470 .clone()
1471 .map(SharedString::from)
1472 .unwrap_or(SharedString::from(user.github_login.clone()));
1473 Some((name, email))
1474 });
1475 (name_and_email.is_some(), name_and_email)
1476 } else {
1477 (false, None)
1478 }
1479 } else {
1480 (has_write_access, None)
1481 }
1482 }
1483 None => (has_write_access, None),
1484 };
1485 let can_commit = !self.commit_pending && can_commit;
1486
1487 let has_co_authors = can_commit
1488 && has_write_access
1489 && room.map_or(false, |room| {
1490 room.read(cx)
1491 .remote_participants()
1492 .values()
1493 .any(|remote_participant| remote_participant.can_write())
1494 });
1495
1496 v_flex()
1497 .id("git_panel")
1498 .key_context(self.dispatch_context(window, cx))
1499 .track_focus(&self.focus_handle)
1500 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1501 .when(has_write_access && !project.is_read_only(cx), |this| {
1502 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1503 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1504 }))
1505 .on_action(
1506 cx.listener(|this, &StageAll, window, cx| {
1507 this.stage_all(&StageAll, window, cx)
1508 }),
1509 )
1510 .on_action(cx.listener(|this, &UnstageAll, window, cx| {
1511 this.unstage_all(&UnstageAll, window, cx)
1512 }))
1513 .on_action(cx.listener(|this, &RevertAll, window, cx| {
1514 this.discard_all(&RevertAll, window, cx)
1515 }))
1516 .when(can_commit, |git_panel| {
1517 git_panel
1518 .on_action({
1519 let name_and_email = name_and_email.clone();
1520 cx.listener(move |git_panel, &CommitChanges, window, cx| {
1521 git_panel.commit_changes(
1522 &CommitChanges,
1523 name_and_email.clone(),
1524 window,
1525 cx,
1526 )
1527 })
1528 })
1529 .on_action({
1530 let name_and_email = name_and_email.clone();
1531 cx.listener(move |git_panel, &CommitAllChanges, window, cx| {
1532 git_panel.commit_all_changes(
1533 &CommitAllChanges,
1534 name_and_email.clone(),
1535 window,
1536 cx,
1537 )
1538 })
1539 })
1540 })
1541 })
1542 .when(self.is_focused(window, cx), |this| {
1543 this.on_action(cx.listener(Self::select_first))
1544 .on_action(cx.listener(Self::select_next))
1545 .on_action(cx.listener(Self::select_prev))
1546 .on_action(cx.listener(Self::select_last))
1547 .on_action(cx.listener(Self::close_panel))
1548 })
1549 .on_action(cx.listener(Self::open_selected))
1550 .on_action(cx.listener(Self::focus_changes_list))
1551 .on_action(cx.listener(Self::focus_editor))
1552 .on_action(cx.listener(Self::toggle_staged_for_selected))
1553 .when(has_co_authors, |git_panel| {
1554 git_panel.on_action(cx.listener(Self::fill_co_authors))
1555 })
1556 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1557 .on_hover(cx.listener(|this, hovered, window, cx| {
1558 if *hovered {
1559 this.show_scrollbar = true;
1560 this.hide_scrollbar_task.take();
1561 cx.notify();
1562 } else if !this.focus_handle.contains_focused(window, cx) {
1563 this.hide_scrollbar(window, cx);
1564 }
1565 }))
1566 .size_full()
1567 .overflow_hidden()
1568 .py_1()
1569 .bg(ElevationIndex::Surface.bg(cx))
1570 .child(self.render_panel_header(window, cx))
1571 .child(self.render_divider(cx))
1572 .child(if has_entries {
1573 self.render_entries(has_write_access, cx).into_any_element()
1574 } else {
1575 self.render_empty_state(cx).into_any_element()
1576 })
1577 .child(self.render_divider(cx))
1578 .child(self.render_commit_editor(name_and_email, can_commit, cx))
1579 }
1580}
1581
1582impl Focusable for GitPanel {
1583 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1584 self.focus_handle.clone()
1585 }
1586}
1587
1588impl EventEmitter<Event> for GitPanel {}
1589
1590impl EventEmitter<PanelEvent> for GitPanel {}
1591
1592impl Panel for GitPanel {
1593 fn persistent_name() -> &'static str {
1594 "GitPanel"
1595 }
1596
1597 fn position(&self, _: &Window, cx: &App) -> DockPosition {
1598 GitPanelSettings::get_global(cx).dock
1599 }
1600
1601 fn position_is_valid(&self, position: DockPosition) -> bool {
1602 matches!(position, DockPosition::Left | DockPosition::Right)
1603 }
1604
1605 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1606 settings::update_settings_file::<GitPanelSettings>(
1607 self.fs.clone(),
1608 cx,
1609 move |settings, _| settings.dock = Some(position),
1610 );
1611 }
1612
1613 fn size(&self, _: &Window, cx: &App) -> Pixels {
1614 self.width
1615 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1616 }
1617
1618 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1619 self.width = size;
1620 self.serialize(cx);
1621 cx.notify();
1622 }
1623
1624 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
1625 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1626 }
1627
1628 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
1629 Some("Git Panel")
1630 }
1631
1632 fn toggle_action(&self) -> Box<dyn Action> {
1633 Box::new(ToggleFocus)
1634 }
1635
1636 fn activation_priority(&self) -> u32 {
1637 2
1638 }
1639}