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