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