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