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