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