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