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