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 collections::HashMap;
8use db::kvp::KEY_VALUE_STORE;
9use editor::commit_tooltip::CommitTooltip;
10use editor::{
11 scroll::ScrollbarAutoHide, Editor, EditorElement, EditorMode, EditorSettings, MultiBuffer,
12 ShowScrollbar,
13};
14use git::repository::{CommitDetails, ResetMode};
15use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged};
16use gpui::*;
17use itertools::Itertools;
18use language::{markdown, Buffer, File, ParsedMarkdown};
19use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
20use multi_buffer::ExcerptInfo;
21use panel::{panel_editor_container, panel_editor_style, panel_filled_button, PanelHeader};
22use project::{
23 git::{GitEvent, Repository},
24 Fs, Project, ProjectPath,
25};
26use serde::{Deserialize, Serialize};
27use settings::Settings as _;
28use std::{collections::HashSet, path::PathBuf, sync::Arc, time::Duration, usize};
29use time::OffsetDateTime;
30use ui::{
31 prelude::*, ButtonLike, Checkbox, ContextMenu, Divider, DividerColor, ElevationIndex, ListItem,
32 ListItemSpacing, Scrollbar, ScrollbarState, Tooltip,
33};
34use util::{maybe, ResultExt, TryFutureExt};
35use workspace::SaveIntent;
36use workspace::{
37 dock::{DockPosition, Panel, PanelEvent},
38 notifications::{DetachAndPromptErr, NotificationId},
39 Toast, Workspace,
40};
41
42actions!(
43 git_panel,
44 [
45 Close,
46 ToggleFocus,
47 OpenMenu,
48 FocusEditor,
49 FocusChanges,
50 ToggleFillCoAuthors,
51 ]
52);
53
54const GIT_PANEL_KEY: &str = "GitPanel";
55
56const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
57
58pub fn init(cx: &mut App) {
59 cx.observe_new(
60 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
61 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
62 workspace.toggle_panel_focus::<GitPanel>(window, cx);
63 });
64
65 workspace.register_action(|workspace, _: &Commit, window, cx| {
66 workspace.open_panel::<GitPanel>(window, cx);
67 if let Some(git_panel) = workspace.panel::<GitPanel>(cx) {
68 git_panel
69 .read(cx)
70 .commit_editor
71 .focus_handle(cx)
72 .focus(window);
73 }
74 });
75 },
76 )
77 .detach();
78}
79
80#[derive(Debug, Clone)]
81pub enum Event {
82 Focus,
83}
84
85#[derive(Serialize, Deserialize)]
86struct SerializedGitPanel {
87 width: Option<Pixels>,
88}
89
90#[derive(Debug, PartialEq, Eq, Clone, Copy)]
91enum Section {
92 Conflict,
93 Tracked,
94 New,
95}
96
97#[derive(Debug, PartialEq, Eq, Clone)]
98struct GitHeaderEntry {
99 header: Section,
100}
101
102impl GitHeaderEntry {
103 pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
104 let this = &self.header;
105 let status = status_entry.status;
106 match this {
107 Section::Conflict => repo.has_conflict(&status_entry.repo_path),
108 Section::Tracked => !status.is_created(),
109 Section::New => status.is_created(),
110 }
111 }
112 pub fn title(&self) -> &'static str {
113 match self.header {
114 Section::Conflict => "Conflicts",
115 Section::Tracked => "Changes",
116 Section::New => "New",
117 }
118 }
119}
120
121#[derive(Debug, PartialEq, Eq, Clone)]
122enum GitListEntry {
123 GitStatusEntry(GitStatusEntry),
124 Header(GitHeaderEntry),
125}
126
127impl GitListEntry {
128 fn status_entry(&self) -> Option<&GitStatusEntry> {
129 match self {
130 GitListEntry::GitStatusEntry(entry) => Some(entry),
131 _ => None,
132 }
133 }
134}
135
136#[derive(Debug, PartialEq, Eq, Clone)]
137pub struct GitStatusEntry {
138 pub(crate) depth: usize,
139 pub(crate) display_name: String,
140 pub(crate) repo_path: RepoPath,
141 pub(crate) status: FileStatus,
142 pub(crate) is_staged: Option<bool>,
143}
144
145struct PendingOperation {
146 finished: bool,
147 will_become_staged: bool,
148 repo_paths: HashSet<RepoPath>,
149 op_id: usize,
150}
151
152pub struct GitPanel {
153 active_repository: Option<Entity<Repository>>,
154 commit_editor: Entity<Editor>,
155 conflicted_count: usize,
156 conflicted_staged_count: usize,
157 current_modifiers: Modifiers,
158 add_coauthors: bool,
159 entries: Vec<GitListEntry>,
160 entries_by_path: collections::HashMap<RepoPath, usize>,
161 focus_handle: FocusHandle,
162 fs: Arc<dyn Fs>,
163 hide_scrollbar_task: Option<Task<()>>,
164 new_count: usize,
165 new_staged_count: usize,
166 pending: Vec<PendingOperation>,
167 pending_commit: Option<Task<()>>,
168 pending_serialization: Task<Option<()>>,
169 project: Entity<Project>,
170 repository_selector: Entity<RepositorySelector>,
171 scroll_handle: UniformListScrollHandle,
172 scrollbar_state: ScrollbarState,
173 selected_entry: Option<usize>,
174 show_scrollbar: bool,
175 tracked_count: usize,
176 tracked_staged_count: usize,
177 update_visible_entries_task: Task<()>,
178 width: Option<Pixels>,
179 workspace: WeakEntity<Workspace>,
180 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
181}
182
183fn commit_message_editor(
184 commit_message_buffer: Entity<Buffer>,
185 project: Entity<Project>,
186 window: &mut Window,
187 cx: &mut Context<'_, Editor>,
188) -> Editor {
189 let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
190 let mut commit_editor = Editor::new(
191 EditorMode::AutoHeight { max_lines: 6 },
192 buffer,
193 None,
194 false,
195 window,
196 cx,
197 );
198 commit_editor.set_collaboration_hub(Box::new(project));
199 commit_editor.set_use_autoclose(false);
200 commit_editor.set_show_gutter(false, cx);
201 commit_editor.set_show_wrap_guides(false, cx);
202 commit_editor.set_show_indent_guides(false, cx);
203 commit_editor.set_placeholder_text("Enter commit message", cx);
204 commit_editor
205}
206
207impl GitPanel {
208 pub fn new(
209 workspace: &mut Workspace,
210 window: &mut Window,
211 cx: &mut Context<Workspace>,
212 ) -> Entity<Self> {
213 let fs = workspace.app_state().fs.clone();
214 let project = workspace.project().clone();
215 let git_store = project.read(cx).git_store().clone();
216 let active_repository = project.read(cx).active_repository(cx);
217 let workspace = cx.entity().downgrade();
218
219 cx.new(|cx| {
220 let focus_handle = cx.focus_handle();
221 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
222 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
223 this.hide_scrollbar(window, cx);
224 })
225 .detach();
226
227 // just to let us render a placeholder editor.
228 // Once the active git repo is set, this buffer will be replaced.
229 let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
230 let commit_editor =
231 cx.new(|cx| commit_message_editor(temporary_buffer, project.clone(), window, cx));
232 commit_editor.update(cx, |editor, cx| {
233 editor.clear(window, cx);
234 });
235
236 let scroll_handle = UniformListScrollHandle::new();
237
238 cx.subscribe_in(
239 &git_store,
240 window,
241 move |this, git_store, event, window, cx| match event {
242 GitEvent::FileSystemUpdated => {
243 this.schedule_update(false, window, cx);
244 }
245 GitEvent::ActiveRepositoryChanged | GitEvent::GitStateUpdated => {
246 this.active_repository = git_store.read(cx).active_repository();
247 this.schedule_update(true, window, cx);
248 }
249 },
250 )
251 .detach();
252
253 let scrollbar_state =
254 ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity());
255
256 let repository_selector =
257 cx.new(|cx| RepositorySelector::new(project.clone(), window, cx));
258
259 let mut git_panel = Self {
260 active_repository,
261 commit_editor,
262 conflicted_count: 0,
263 conflicted_staged_count: 0,
264 current_modifiers: window.modifiers(),
265 add_coauthors: true,
266 entries: Vec::new(),
267 entries_by_path: HashMap::default(),
268 focus_handle: cx.focus_handle(),
269 fs,
270 hide_scrollbar_task: None,
271 new_count: 0,
272 new_staged_count: 0,
273 pending: Vec::new(),
274 pending_commit: None,
275 pending_serialization: Task::ready(None),
276 project,
277 repository_selector,
278 scroll_handle,
279 scrollbar_state,
280 selected_entry: None,
281 show_scrollbar: false,
282 tracked_count: 0,
283 tracked_staged_count: 0,
284 update_visible_entries_task: Task::ready(()),
285 width: Some(px(360.)),
286 context_menu: None,
287 workspace,
288 };
289 git_panel.schedule_update(false, window, cx);
290 git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
291 git_panel
292 })
293 }
294
295 pub fn select_entry_by_path(
296 &mut self,
297 path: ProjectPath,
298 _: &mut Window,
299 cx: &mut Context<Self>,
300 ) {
301 let Some(git_repo) = self.active_repository.as_ref() else {
302 return;
303 };
304 let Some(repo_path) = git_repo.read(cx).project_path_to_repo_path(&path) else {
305 return;
306 };
307 let Some(ix) = self.entries_by_path.get(&repo_path) else {
308 return;
309 };
310 self.selected_entry = Some(*ix);
311 cx.notify();
312 }
313
314 fn serialize(&mut self, cx: &mut Context<Self>) {
315 let width = self.width;
316 self.pending_serialization = cx.background_executor().spawn(
317 async move {
318 KEY_VALUE_STORE
319 .write_kvp(
320 GIT_PANEL_KEY.into(),
321 serde_json::to_string(&SerializedGitPanel { width })?,
322 )
323 .await?;
324 anyhow::Ok(())
325 }
326 .log_err(),
327 );
328 }
329
330 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
331 let mut dispatch_context = KeyContext::new_with_defaults();
332 dispatch_context.add("GitPanel");
333
334 if self.is_focused(window, cx) {
335 dispatch_context.add("menu");
336 dispatch_context.add("ChangesList");
337 }
338
339 if self.commit_editor.read(cx).is_focused(window) {
340 dispatch_context.add("CommitEditor");
341 }
342
343 dispatch_context
344 }
345
346 fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
347 window
348 .focused(cx)
349 .map_or(false, |focused| self.focus_handle == focused)
350 }
351
352 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
353 cx.emit(PanelEvent::Close);
354 }
355
356 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
357 if !self.focus_handle.contains_focused(window, cx) {
358 cx.emit(Event::Focus);
359 }
360 }
361
362 fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
363 GitPanelSettings::get_global(cx)
364 .scrollbar
365 .show
366 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
367 }
368
369 fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
370 let show = self.show_scrollbar(cx);
371 match show {
372 ShowScrollbar::Auto => true,
373 ShowScrollbar::System => true,
374 ShowScrollbar::Always => true,
375 ShowScrollbar::Never => false,
376 }
377 }
378
379 fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
380 let show = self.show_scrollbar(cx);
381 match show {
382 ShowScrollbar::Auto => true,
383 ShowScrollbar::System => cx
384 .try_global::<ScrollbarAutoHide>()
385 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
386 ShowScrollbar::Always => false,
387 ShowScrollbar::Never => true,
388 }
389 }
390
391 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
392 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
393 if !self.should_autohide_scrollbar(cx) {
394 return;
395 }
396 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
397 cx.background_executor()
398 .timer(SCROLLBAR_SHOW_INTERVAL)
399 .await;
400 panel
401 .update(&mut cx, |panel, cx| {
402 panel.show_scrollbar = false;
403 cx.notify();
404 })
405 .log_err();
406 }))
407 }
408
409 fn handle_modifiers_changed(
410 &mut self,
411 event: &ModifiersChangedEvent,
412 _: &mut Window,
413 cx: &mut Context<Self>,
414 ) {
415 self.current_modifiers = event.modifiers;
416 cx.notify();
417 }
418
419 fn calculate_depth_and_difference(
420 repo_path: &RepoPath,
421 visible_entries: &HashSet<RepoPath>,
422 ) -> (usize, usize) {
423 let ancestors = repo_path.ancestors().skip(1);
424 for ancestor in ancestors {
425 if let Some(parent_entry) = visible_entries.get(ancestor) {
426 let entry_component_count = repo_path.components().count();
427 let parent_component_count = parent_entry.components().count();
428
429 let difference = entry_component_count - parent_component_count;
430
431 let parent_depth = parent_entry
432 .ancestors()
433 .skip(1) // Skip the parent itself
434 .filter(|ancestor| visible_entries.contains(*ancestor))
435 .count();
436
437 return (parent_depth + 1, difference);
438 }
439 }
440
441 (0, 0)
442 }
443
444 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
445 if let Some(selected_entry) = self.selected_entry {
446 self.scroll_handle
447 .scroll_to_item(selected_entry, ScrollStrategy::Center);
448 }
449
450 cx.notify();
451 }
452
453 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
454 if self.entries.first().is_some() {
455 self.selected_entry = Some(1);
456 self.scroll_to_selected_entry(cx);
457 }
458 }
459
460 fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
461 let item_count = self.entries.len();
462 if item_count == 0 {
463 return;
464 }
465
466 if let Some(selected_entry) = self.selected_entry {
467 let new_selected_entry = if selected_entry > 0 {
468 selected_entry - 1
469 } else {
470 selected_entry
471 };
472
473 if matches!(
474 self.entries.get(new_selected_entry),
475 Some(GitListEntry::Header(..))
476 ) {
477 if new_selected_entry > 0 {
478 self.selected_entry = Some(new_selected_entry - 1)
479 }
480 } else {
481 self.selected_entry = Some(new_selected_entry);
482 }
483
484 self.scroll_to_selected_entry(cx);
485 }
486
487 cx.notify();
488 }
489
490 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
491 let item_count = self.entries.len();
492 if item_count == 0 {
493 return;
494 }
495
496 if let Some(selected_entry) = self.selected_entry {
497 let new_selected_entry = if selected_entry < item_count - 1 {
498 selected_entry + 1
499 } else {
500 selected_entry
501 };
502 if matches!(
503 self.entries.get(new_selected_entry),
504 Some(GitListEntry::Header(..))
505 ) {
506 self.selected_entry = Some(new_selected_entry + 1);
507 } else {
508 self.selected_entry = Some(new_selected_entry);
509 }
510
511 self.scroll_to_selected_entry(cx);
512 }
513
514 cx.notify();
515 }
516
517 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
518 if self.entries.last().is_some() {
519 self.selected_entry = Some(self.entries.len() - 1);
520 self.scroll_to_selected_entry(cx);
521 }
522 }
523
524 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
525 self.commit_editor.update(cx, |editor, cx| {
526 window.focus(&editor.focus_handle(cx));
527 });
528 cx.notify();
529 }
530
531 fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
532 let have_entries = self
533 .active_repository
534 .as_ref()
535 .map_or(false, |active_repository| {
536 active_repository.read(cx).entry_count() > 0
537 });
538 if have_entries && self.selected_entry.is_none() {
539 self.selected_entry = Some(1);
540 self.scroll_to_selected_entry(cx);
541 cx.notify();
542 }
543 }
544
545 fn focus_changes_list(
546 &mut self,
547 _: &FocusChanges,
548 window: &mut Window,
549 cx: &mut Context<Self>,
550 ) {
551 self.select_first_entry_if_none(cx);
552
553 cx.focus_self(window);
554 cx.notify();
555 }
556
557 fn get_selected_entry(&self) -> Option<&GitListEntry> {
558 self.selected_entry.and_then(|i| self.entries.get(i))
559 }
560
561 fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
562 maybe!({
563 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
564
565 self.workspace
566 .update(cx, |workspace, cx| {
567 ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
568 })
569 .ok()
570 });
571 self.focus_handle.focus(window);
572 }
573
574 fn open_file(
575 &mut self,
576 _: &menu::SecondaryConfirm,
577 window: &mut Window,
578 cx: &mut Context<Self>,
579 ) {
580 maybe!({
581 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
582 let active_repo = self.active_repository.as_ref()?;
583 let path = active_repo
584 .read(cx)
585 .repo_path_to_project_path(&entry.repo_path)?;
586 if entry.status.is_deleted() {
587 return None;
588 }
589
590 self.workspace
591 .update(cx, |workspace, cx| {
592 workspace
593 .open_path_preview(path, None, false, false, window, cx)
594 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
595 Some(format!("{e}"))
596 });
597 })
598 .ok()
599 });
600 }
601
602 fn revert(
603 &mut self,
604 _: &editor::actions::RevertFile,
605 window: &mut Window,
606 cx: &mut Context<Self>,
607 ) {
608 maybe!({
609 let list_entry = self.entries.get(self.selected_entry?)?.clone();
610 let entry = list_entry.status_entry()?;
611 let active_repo = self.active_repository.as_ref()?;
612 let path = active_repo
613 .read(cx)
614 .repo_path_to_project_path(&entry.repo_path)?;
615 let workspace = self.workspace.clone();
616
617 if entry.status.is_staged() != Some(false) {
618 self.update_staging_area_for_entries(false, vec![entry.repo_path.clone()], cx);
619 }
620
621 if entry.status.is_created() {
622 let prompt = window.prompt(
623 PromptLevel::Info,
624 "Do you want to trash this file?",
625 None,
626 &["Trash", "Cancel"],
627 cx,
628 );
629 cx.spawn_in(window, |_, mut cx| async move {
630 match prompt.await {
631 Ok(0) => {}
632 _ => return Ok(()),
633 }
634 let task = workspace.update(&mut cx, |workspace, cx| {
635 workspace
636 .project()
637 .update(cx, |project, cx| project.delete_file(path, true, cx))
638 })?;
639 if let Some(task) = task {
640 task.await?;
641 }
642 Ok(())
643 })
644 .detach_and_prompt_err(
645 "Failed to trash file",
646 window,
647 cx,
648 |e, _, _| Some(format!("{e}")),
649 );
650 return Some(());
651 }
652
653 let open_path = workspace.update(cx, |workspace, cx| {
654 workspace.open_path_preview(path, None, true, false, window, cx)
655 });
656
657 cx.spawn_in(window, |_, mut cx| async move {
658 let item = open_path?.await?;
659 let editor = cx.update(|_, cx| {
660 item.act_as::<Editor>(cx)
661 .ok_or_else(|| anyhow::anyhow!("didn't open editor"))
662 })??;
663
664 if let Some(task) =
665 editor.update(&mut cx, |editor, _| editor.wait_for_diff_to_load())?
666 {
667 task.await
668 };
669
670 editor.update_in(&mut cx, |editor, window, cx| {
671 editor.revert_file(&Default::default(), window, cx);
672 })?;
673
674 workspace
675 .update_in(&mut cx, |workspace, window, cx| {
676 workspace.save_active_item(SaveIntent::Save, window, cx)
677 })?
678 .await?;
679 Ok(())
680 })
681 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
682 Some(format!("{e}"))
683 });
684
685 Some(())
686 });
687 }
688
689 fn toggle_staged_for_entry(
690 &mut self,
691 entry: &GitListEntry,
692 _window: &mut Window,
693 cx: &mut Context<Self>,
694 ) {
695 let Some(active_repository) = self.active_repository.as_ref() else {
696 return;
697 };
698 let (stage, repo_paths) = match entry {
699 GitListEntry::GitStatusEntry(status_entry) => {
700 if status_entry.status.is_staged().unwrap_or(false) {
701 (false, vec![status_entry.repo_path.clone()])
702 } else {
703 (true, vec![status_entry.repo_path.clone()])
704 }
705 }
706 GitListEntry::Header(section) => {
707 let goal_staged_state = !self.header_state(section.header).selected();
708 let repository = active_repository.read(cx);
709 let entries = self
710 .entries
711 .iter()
712 .filter_map(|entry| entry.status_entry())
713 .filter(|status_entry| {
714 section.contains(&status_entry, repository)
715 && status_entry.is_staged != Some(goal_staged_state)
716 })
717 .map(|status_entry| status_entry.repo_path.clone())
718 .collect::<Vec<_>>();
719
720 (goal_staged_state, entries)
721 }
722 };
723 self.update_staging_area_for_entries(stage, repo_paths, cx);
724 }
725
726 fn update_staging_area_for_entries(
727 &mut self,
728 stage: bool,
729 repo_paths: Vec<RepoPath>,
730 cx: &mut Context<Self>,
731 ) {
732 let Some(active_repository) = self.active_repository.clone() else {
733 return;
734 };
735 let op_id = self.pending.iter().map(|p| p.op_id).max().unwrap_or(0) + 1;
736 self.pending.push(PendingOperation {
737 op_id,
738 will_become_staged: stage,
739 repo_paths: repo_paths.iter().cloned().collect(),
740 finished: false,
741 });
742 let repo_paths = repo_paths.clone();
743 let repository = active_repository.read(cx);
744 self.update_counts(repository);
745 cx.notify();
746
747 cx.spawn({
748 |this, mut cx| async move {
749 let result = cx
750 .update(|cx| {
751 if stage {
752 active_repository
753 .update(cx, |repo, cx| repo.stage_entries(repo_paths.clone(), cx))
754 } else {
755 active_repository
756 .update(cx, |repo, cx| repo.unstage_entries(repo_paths.clone(), cx))
757 }
758 })?
759 .await;
760
761 this.update(&mut cx, |this, cx| {
762 for pending in this.pending.iter_mut() {
763 if pending.op_id == op_id {
764 pending.finished = true
765 }
766 }
767 result
768 .map_err(|e| {
769 this.show_err_toast(e, cx);
770 })
771 .ok();
772 cx.notify();
773 })
774 }
775 })
776 .detach();
777 }
778
779 fn toggle_staged_for_selected(
780 &mut self,
781 _: &git::ToggleStaged,
782 window: &mut Window,
783 cx: &mut Context<Self>,
784 ) {
785 if let Some(selected_entry) = self.get_selected_entry().cloned() {
786 self.toggle_staged_for_entry(&selected_entry, window, cx);
787 }
788 }
789
790 /// Commit all staged changes
791 fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
792 let editor = self.commit_editor.read(cx);
793 if editor.is_empty(cx) {
794 if !editor.focus_handle(cx).contains_focused(window, cx) {
795 editor.focus_handle(cx).focus(window);
796 return;
797 }
798 }
799
800 self.commit_changes(window, cx)
801 }
802
803 fn commit_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) {
804 let Some(active_repository) = self.active_repository.clone() else {
805 return;
806 };
807 let error_spawn = |message, window: &mut Window, cx: &mut App| {
808 let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
809 cx.spawn(|_| async move {
810 prompt.await.ok();
811 })
812 .detach();
813 };
814
815 if self.has_unstaged_conflicts() {
816 error_spawn(
817 "There are still conflicts. You must stage these before committing",
818 window,
819 cx,
820 );
821 return;
822 }
823
824 let mut message = self.commit_editor.read(cx).text(cx);
825 if message.trim().is_empty() {
826 self.commit_editor.read(cx).focus_handle(cx).focus(window);
827 return;
828 }
829 if self.add_coauthors {
830 self.fill_co_authors(&mut message, cx);
831 }
832
833 let task = if self.has_staged_changes() {
834 // Repository serializes all git operations, so we can just send a commit immediately
835 let commit_task = active_repository.read(cx).commit(message.into(), None);
836 cx.background_executor()
837 .spawn(async move { commit_task.await? })
838 } else {
839 let changed_files = self
840 .entries
841 .iter()
842 .filter_map(|entry| entry.status_entry())
843 .filter(|status_entry| !status_entry.status.is_created())
844 .map(|status_entry| status_entry.repo_path.clone())
845 .collect::<Vec<_>>();
846
847 if changed_files.is_empty() {
848 error_spawn("No changes to commit", window, cx);
849 return;
850 }
851
852 let stage_task =
853 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
854 cx.spawn(|_, mut cx| async move {
855 stage_task.await?;
856 let commit_task = active_repository
857 .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
858 commit_task.await?
859 })
860 };
861 let task = cx.spawn_in(window, |this, mut cx| async move {
862 let result = task.await;
863 this.update_in(&mut cx, |this, window, cx| {
864 this.pending_commit.take();
865 match result {
866 Ok(()) => {
867 this.commit_editor
868 .update(cx, |editor, cx| editor.clear(window, cx));
869 }
870 Err(e) => this.show_err_toast(e, cx),
871 }
872 })
873 .ok();
874 });
875
876 self.pending_commit = Some(task);
877 }
878
879 fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
880 let Some(repo) = self.active_repository.clone() else {
881 return;
882 };
883 let prior_head = self.load_commit_details("HEAD", cx);
884
885 let task = cx.spawn(|_, mut cx| async move {
886 let prior_head = prior_head.await?;
887
888 repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
889 .await??;
890
891 Ok(prior_head)
892 });
893
894 let task = cx.spawn_in(window, |this, mut cx| async move {
895 let result = task.await;
896 this.update_in(&mut cx, |this, window, cx| {
897 this.pending_commit.take();
898 match result {
899 Ok(prior_commit) => {
900 this.commit_editor.update(cx, |editor, cx| {
901 editor.set_text(prior_commit.message, window, cx)
902 });
903 }
904 Err(e) => this.show_err_toast(e, cx),
905 }
906 })
907 .ok();
908 });
909
910 self.pending_commit = Some(task);
911 }
912
913 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
914 let mut new_co_authors = Vec::new();
915 let project = self.project.read(cx);
916
917 let Some(room) = self
918 .workspace
919 .upgrade()
920 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
921 else {
922 return Vec::default();
923 };
924
925 let room = room.read(cx);
926
927 for (peer_id, collaborator) in project.collaborators() {
928 if collaborator.is_host {
929 continue;
930 }
931
932 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
933 continue;
934 };
935 if participant.can_write() && participant.user.email.is_some() {
936 let email = participant.user.email.clone().unwrap();
937
938 new_co_authors.push((
939 participant
940 .user
941 .name
942 .clone()
943 .unwrap_or_else(|| participant.user.github_login.clone()),
944 email,
945 ))
946 }
947 }
948 if !project.is_local() && !project.is_read_only(cx) {
949 if let Some(user) = room.local_participant_user(cx) {
950 if let Some(email) = user.email.clone() {
951 new_co_authors.push((
952 user.name
953 .clone()
954 .unwrap_or_else(|| user.github_login.clone()),
955 email.clone(),
956 ))
957 }
958 }
959 }
960 new_co_authors
961 }
962
963 fn toggle_fill_co_authors(
964 &mut self,
965 _: &ToggleFillCoAuthors,
966 _: &mut Window,
967 cx: &mut Context<Self>,
968 ) {
969 self.add_coauthors = !self.add_coauthors;
970 cx.notify();
971 }
972
973 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
974 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
975
976 let existing_text = message.to_ascii_lowercase();
977 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
978 let mut ends_with_co_authors = false;
979 let existing_co_authors = existing_text
980 .lines()
981 .filter_map(|line| {
982 let line = line.trim();
983 if line.starts_with(&lowercase_co_author_prefix) {
984 ends_with_co_authors = true;
985 Some(line)
986 } else {
987 ends_with_co_authors = false;
988 None
989 }
990 })
991 .collect::<HashSet<_>>();
992
993 let new_co_authors = self
994 .potential_co_authors(cx)
995 .into_iter()
996 .filter(|(_, email)| {
997 !existing_co_authors
998 .iter()
999 .any(|existing| existing.contains(email.as_str()))
1000 })
1001 .collect::<Vec<_>>();
1002
1003 if new_co_authors.is_empty() {
1004 return;
1005 }
1006
1007 if !ends_with_co_authors {
1008 message.push('\n');
1009 }
1010 for (name, email) in new_co_authors {
1011 message.push('\n');
1012 message.push_str(CO_AUTHOR_PREFIX);
1013 message.push_str(&name);
1014 message.push_str(" <");
1015 message.push_str(&email);
1016 message.push('>');
1017 }
1018 message.push('\n');
1019 }
1020
1021 fn schedule_update(
1022 &mut self,
1023 clear_pending: bool,
1024 window: &mut Window,
1025 cx: &mut Context<Self>,
1026 ) {
1027 let handle = cx.entity().downgrade();
1028 self.reopen_commit_buffer(window, cx);
1029 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1030 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1031 if let Some(git_panel) = handle.upgrade() {
1032 git_panel
1033 .update_in(&mut cx, |git_panel, _, cx| {
1034 if clear_pending {
1035 git_panel.clear_pending();
1036 }
1037 git_panel.update_visible_entries(cx);
1038 })
1039 .ok();
1040 }
1041 });
1042 }
1043
1044 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1045 let Some(active_repo) = self.active_repository.as_ref() else {
1046 return;
1047 };
1048 let load_buffer = active_repo.update(cx, |active_repo, cx| {
1049 let project = self.project.read(cx);
1050 active_repo.open_commit_buffer(
1051 Some(project.languages().clone()),
1052 project.buffer_store().clone(),
1053 cx,
1054 )
1055 });
1056
1057 cx.spawn_in(window, |git_panel, mut cx| async move {
1058 let buffer = load_buffer.await?;
1059 git_panel.update_in(&mut cx, |git_panel, window, cx| {
1060 if git_panel
1061 .commit_editor
1062 .read(cx)
1063 .buffer()
1064 .read(cx)
1065 .as_singleton()
1066 .as_ref()
1067 != Some(&buffer)
1068 {
1069 git_panel.commit_editor = cx.new(|cx| {
1070 commit_message_editor(buffer, git_panel.project.clone(), window, cx)
1071 });
1072 }
1073 })
1074 })
1075 .detach_and_log_err(cx);
1076 }
1077
1078 fn clear_pending(&mut self) {
1079 self.pending.retain(|v| !v.finished)
1080 }
1081
1082 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1083 self.entries.clear();
1084 self.entries_by_path.clear();
1085 let mut changed_entries = Vec::new();
1086 let mut new_entries = Vec::new();
1087 let mut conflict_entries = Vec::new();
1088
1089 let Some(repo) = self.active_repository.as_ref() else {
1090 // Just clear entries if no repository is active.
1091 cx.notify();
1092 return;
1093 };
1094
1095 // First pass - collect all paths
1096 let repo = repo.read(cx);
1097 let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
1098
1099 // Second pass - create entries with proper depth calculation
1100 for entry in repo.status() {
1101 let (depth, difference) =
1102 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
1103
1104 let is_conflict = repo.has_conflict(&entry.repo_path);
1105 let is_new = entry.status.is_created();
1106 let is_staged = entry.status.is_staged();
1107
1108 let display_name = if difference > 1 {
1109 // Show partial path for deeply nested files
1110 entry
1111 .repo_path
1112 .as_ref()
1113 .iter()
1114 .skip(entry.repo_path.components().count() - difference)
1115 .collect::<PathBuf>()
1116 .to_string_lossy()
1117 .into_owned()
1118 } else {
1119 // Just show filename
1120 entry
1121 .repo_path
1122 .file_name()
1123 .map(|name| name.to_string_lossy().into_owned())
1124 .unwrap_or_default()
1125 };
1126
1127 let entry = GitStatusEntry {
1128 depth,
1129 display_name,
1130 repo_path: entry.repo_path.clone(),
1131 status: entry.status,
1132 is_staged,
1133 };
1134
1135 if is_conflict {
1136 conflict_entries.push(entry);
1137 } else if is_new {
1138 new_entries.push(entry);
1139 } else {
1140 changed_entries.push(entry);
1141 }
1142 }
1143
1144 // Sort entries by path to maintain consistent order
1145 conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1146 changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1147 new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1148
1149 if conflict_entries.len() > 0 {
1150 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1151 header: Section::Conflict,
1152 }));
1153 self.entries.extend(
1154 conflict_entries
1155 .into_iter()
1156 .map(GitListEntry::GitStatusEntry),
1157 );
1158 }
1159
1160 if changed_entries.len() > 0 {
1161 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1162 header: Section::Tracked,
1163 }));
1164 self.entries.extend(
1165 changed_entries
1166 .into_iter()
1167 .map(GitListEntry::GitStatusEntry),
1168 );
1169 }
1170 if new_entries.len() > 0 {
1171 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1172 header: Section::New,
1173 }));
1174 self.entries
1175 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1176 }
1177
1178 for (ix, entry) in self.entries.iter().enumerate() {
1179 if let Some(status_entry) = entry.status_entry() {
1180 self.entries_by_path
1181 .insert(status_entry.repo_path.clone(), ix);
1182 }
1183 }
1184 self.update_counts(repo);
1185
1186 self.select_first_entry_if_none(cx);
1187
1188 cx.notify();
1189 }
1190
1191 fn header_state(&self, header_type: Section) -> ToggleState {
1192 let (staged_count, count) = match header_type {
1193 Section::New => (self.new_staged_count, self.new_count),
1194 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1195 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1196 };
1197 if staged_count == 0 {
1198 ToggleState::Unselected
1199 } else if count == staged_count {
1200 ToggleState::Selected
1201 } else {
1202 ToggleState::Indeterminate
1203 }
1204 }
1205
1206 fn update_counts(&mut self, repo: &Repository) {
1207 self.conflicted_count = 0;
1208 self.conflicted_staged_count = 0;
1209 self.new_count = 0;
1210 self.tracked_count = 0;
1211 self.new_staged_count = 0;
1212 self.tracked_staged_count = 0;
1213 for entry in &self.entries {
1214 let Some(status_entry) = entry.status_entry() else {
1215 continue;
1216 };
1217 if repo.has_conflict(&status_entry.repo_path) {
1218 self.conflicted_count += 1;
1219 if self.entry_is_staged(status_entry) != Some(false) {
1220 self.conflicted_staged_count += 1;
1221 }
1222 } else if status_entry.status.is_created() {
1223 self.new_count += 1;
1224 if self.entry_is_staged(status_entry) != Some(false) {
1225 self.new_staged_count += 1;
1226 }
1227 } else {
1228 self.tracked_count += 1;
1229 if self.entry_is_staged(status_entry) != Some(false) {
1230 self.tracked_staged_count += 1;
1231 }
1232 }
1233 }
1234 }
1235
1236 fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1237 for pending in self.pending.iter().rev() {
1238 if pending.repo_paths.contains(&entry.repo_path) {
1239 return Some(pending.will_become_staged);
1240 }
1241 }
1242 entry.is_staged
1243 }
1244
1245 fn has_staged_changes(&self) -> bool {
1246 self.tracked_staged_count > 0
1247 || self.new_staged_count > 0
1248 || self.conflicted_staged_count > 0
1249 }
1250
1251 fn has_tracked_changes(&self) -> bool {
1252 self.tracked_count > 0
1253 }
1254
1255 fn has_unstaged_conflicts(&self) -> bool {
1256 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1257 }
1258
1259 fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1260 let Some(workspace) = self.workspace.upgrade() else {
1261 return;
1262 };
1263 let notif_id = NotificationId::Named("git-operation-error".into());
1264
1265 let message = e.to_string();
1266 workspace.update(cx, |workspace, cx| {
1267 let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1268 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1269 });
1270 workspace.show_toast(toast, cx);
1271 });
1272 }
1273
1274 pub fn panel_button(
1275 &self,
1276 id: impl Into<SharedString>,
1277 label: impl Into<SharedString>,
1278 ) -> Button {
1279 let id = id.into().clone();
1280 let label = label.into().clone();
1281
1282 Button::new(id, label)
1283 .label_size(LabelSize::Small)
1284 .layer(ElevationIndex::ElevatedSurface)
1285 .size(ButtonSize::Compact)
1286 .style(ButtonStyle::Filled)
1287 }
1288
1289 pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1290 Checkbox::container_size(cx).to_pixels(window.rem_size())
1291 }
1292
1293 pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1294 h_flex()
1295 .items_center()
1296 .h(px(8.))
1297 .child(Divider::horizontal_dashed().color(DividerColor::Border))
1298 }
1299
1300 pub fn render_panel_header(
1301 &self,
1302 window: &mut Window,
1303 cx: &mut Context<Self>,
1304 ) -> impl IntoElement {
1305 let all_repositories = self
1306 .project
1307 .read(cx)
1308 .git_store()
1309 .read(cx)
1310 .all_repositories();
1311
1312 let has_repo_above = all_repositories.iter().any(|repo| {
1313 repo.read(cx)
1314 .repository_entry
1315 .work_directory
1316 .is_above_project()
1317 });
1318
1319 self.panel_header_container(window, cx)
1320 .when(all_repositories.len() > 1 || has_repo_above, |el| {
1321 el.child(self.render_repository_selector(cx))
1322 })
1323 }
1324
1325 pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1326 let active_repository = self.project.read(cx).active_repository(cx);
1327 let repository_display_name = active_repository
1328 .as_ref()
1329 .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1330 .unwrap_or_default();
1331
1332 RepositorySelectorPopoverMenu::new(
1333 self.repository_selector.clone(),
1334 ButtonLike::new("active-repository")
1335 .style(ButtonStyle::Subtle)
1336 .child(Label::new(repository_display_name).size(LabelSize::Small)),
1337 Tooltip::text("Select a repository"),
1338 )
1339 }
1340
1341 pub fn render_commit_editor(
1342 &self,
1343 window: &mut Window,
1344 cx: &mut Context<Self>,
1345 ) -> impl IntoElement {
1346 let editor = self.commit_editor.clone();
1347 let can_commit = (self.has_staged_changes() || self.has_tracked_changes())
1348 && self.pending_commit.is_none()
1349 && !editor.read(cx).is_empty(cx)
1350 && !self.has_unstaged_conflicts()
1351 && self.has_write_access(cx);
1352
1353 // let can_commit_all =
1354 // !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
1355 let panel_editor_style = panel_editor_style(true, window, cx);
1356
1357 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
1358
1359 let focus_handle_1 = self.focus_handle(cx).clone();
1360 let tooltip = if self.has_staged_changes() {
1361 "Commit staged changes"
1362 } else {
1363 "Commit changes to tracked files"
1364 };
1365 let title = if self.has_staged_changes() {
1366 "Commit"
1367 } else {
1368 "Commit All"
1369 };
1370
1371 let commit_button = panel_filled_button(title)
1372 .tooltip(move |window, cx| {
1373 let focus_handle = focus_handle_1.clone();
1374 Tooltip::for_action_in(tooltip, &Commit, &focus_handle, window, cx)
1375 })
1376 .disabled(!can_commit)
1377 .on_click({
1378 cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1379 });
1380
1381 let potential_co_authors = self.potential_co_authors(cx);
1382 let enable_coauthors = if potential_co_authors.is_empty() {
1383 None
1384 } else {
1385 Some(
1386 IconButton::new("co-authors", IconName::Person)
1387 .icon_color(Color::Disabled)
1388 .selected_icon_color(Color::Selected)
1389 .toggle_state(self.add_coauthors)
1390 .tooltip(move |_, cx| {
1391 let title = format!(
1392 "Add co-authored-by:{}{}",
1393 if potential_co_authors.len() == 1 {
1394 ""
1395 } else {
1396 "\n"
1397 },
1398 potential_co_authors
1399 .iter()
1400 .map(|(name, email)| format!(" {} <{}>", name, email))
1401 .join("\n")
1402 );
1403 Tooltip::simple(title, cx)
1404 })
1405 .on_click(cx.listener(|this, _, _, cx| {
1406 this.add_coauthors = !this.add_coauthors;
1407 cx.notify();
1408 })),
1409 )
1410 };
1411
1412 let branch = self
1413 .active_repository
1414 .as_ref()
1415 .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
1416 .unwrap_or_else(|| "<no branch>".into());
1417
1418 let branch_selector = Button::new("branch-selector", branch)
1419 .color(Color::Muted)
1420 .style(ButtonStyle::Subtle)
1421 .icon(IconName::GitBranch)
1422 .icon_size(IconSize::Small)
1423 .icon_color(Color::Muted)
1424 .size(ButtonSize::Compact)
1425 .icon_position(IconPosition::Start)
1426 .tooltip(Tooltip::for_action_title(
1427 "Switch Branch",
1428 &zed_actions::git::Branch,
1429 ))
1430 .on_click(cx.listener(|_, _, window, cx| {
1431 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1432 }))
1433 .style(ButtonStyle::Transparent);
1434
1435 let footer_size = px(32.);
1436 let gap = px(16.0);
1437
1438 let max_height = window.line_height() * 6. + gap + footer_size;
1439
1440 panel_editor_container(window, cx)
1441 .id("commit-editor-container")
1442 .relative()
1443 .h(max_height)
1444 .w_full()
1445 .border_t_1()
1446 .border_color(cx.theme().colors().border)
1447 .bg(cx.theme().colors().editor_background)
1448 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1449 window.focus(&editor_focus_handle);
1450 }))
1451 .child(EditorElement::new(&self.commit_editor, panel_editor_style))
1452 .child(
1453 h_flex()
1454 .absolute()
1455 .bottom_0()
1456 .left_2()
1457 .h(footer_size)
1458 .flex_none()
1459 .child(branch_selector),
1460 )
1461 .child(
1462 h_flex()
1463 .absolute()
1464 .bottom_0()
1465 .right_2()
1466 .h(footer_size)
1467 .flex_none()
1468 .children(enable_coauthors)
1469 .child(commit_button),
1470 )
1471 }
1472
1473 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1474 let active_repository = self.active_repository.as_ref()?;
1475 let branch = active_repository.read(cx).branch()?;
1476 let commit = branch.most_recent_commit.as_ref()?.clone();
1477
1478 if branch.upstream.as_ref().is_some_and(|upstream| {
1479 if let Some(tracking) = &upstream.tracking {
1480 tracking.ahead == 0
1481 } else {
1482 true
1483 }
1484 }) {
1485 return None;
1486 }
1487 let tooltip = if self.has_staged_changes() {
1488 "git reset HEAD^ --soft"
1489 } else {
1490 "git reset HEAD^"
1491 };
1492
1493 let this = cx.entity();
1494 Some(
1495 h_flex()
1496 .items_center()
1497 .py_1p5()
1498 .px(px(8.))
1499 .bg(cx.theme().colors().background)
1500 .border_t_1()
1501 .border_color(cx.theme().colors().border)
1502 .gap_1p5()
1503 .child(
1504 div()
1505 .flex_grow()
1506 .overflow_hidden()
1507 .max_w(relative(0.6))
1508 .h_full()
1509 .child(
1510 Label::new(commit.subject.clone())
1511 .size(LabelSize::Small)
1512 .text_ellipsis(),
1513 )
1514 .id("commit-msg-hover")
1515 .hoverable_tooltip(move |window, cx| {
1516 GitPanelMessageTooltip::new(
1517 this.clone(),
1518 commit.sha.clone(),
1519 window,
1520 cx,
1521 )
1522 .into()
1523 }),
1524 )
1525 .child(div().flex_1())
1526 .child(
1527 panel_filled_button("Uncommit")
1528 .icon(IconName::Undo)
1529 .icon_size(IconSize::Small)
1530 .icon_color(Color::Muted)
1531 .icon_position(IconPosition::Start)
1532 .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
1533 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
1534 ),
1535 )
1536 }
1537
1538 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1539 h_flex()
1540 .h_full()
1541 .flex_grow()
1542 .justify_center()
1543 .items_center()
1544 .child(
1545 v_flex()
1546 .gap_3()
1547 .child(if self.active_repository.is_some() {
1548 "No changes to commit"
1549 } else {
1550 "No Git repositories"
1551 })
1552 .text_ui_sm(cx)
1553 .mx_auto()
1554 .text_color(Color::Placeholder.color(cx)),
1555 )
1556 }
1557
1558 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1559 let scroll_bar_style = self.show_scrollbar(cx);
1560 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1561
1562 if !self.should_show_scrollbar(cx)
1563 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1564 {
1565 return None;
1566 }
1567
1568 Some(
1569 div()
1570 .id("git-panel-vertical-scroll")
1571 .occlude()
1572 .flex_none()
1573 .h_full()
1574 .cursor_default()
1575 .when(show_container, |this| this.pl_1().px_1p5())
1576 .when(!show_container, |this| {
1577 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1578 })
1579 .on_mouse_move(cx.listener(|_, _, _, cx| {
1580 cx.notify();
1581 cx.stop_propagation()
1582 }))
1583 .on_hover(|_, _, cx| {
1584 cx.stop_propagation();
1585 })
1586 .on_any_mouse_down(|_, _, cx| {
1587 cx.stop_propagation();
1588 })
1589 .on_mouse_up(
1590 MouseButton::Left,
1591 cx.listener(|this, _, window, cx| {
1592 if !this.scrollbar_state.is_dragging()
1593 && !this.focus_handle.contains_focused(window, cx)
1594 {
1595 this.hide_scrollbar(window, cx);
1596 cx.notify();
1597 }
1598
1599 cx.stop_propagation();
1600 }),
1601 )
1602 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1603 cx.notify();
1604 }))
1605 .children(Scrollbar::vertical(
1606 // percentage as f32..end_offset as f32,
1607 self.scrollbar_state.clone(),
1608 )),
1609 )
1610 }
1611
1612 pub fn render_buffer_header_controls(
1613 &self,
1614 entity: &Entity<Self>,
1615 file: &Arc<dyn File>,
1616 _: &Window,
1617 cx: &App,
1618 ) -> Option<AnyElement> {
1619 let repo = self.active_repository.as_ref()?.read(cx);
1620 let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
1621 let ix = self.entries_by_path.get(&repo_path)?;
1622 let entry = self.entries.get(*ix)?;
1623
1624 let is_staged = self.entry_is_staged(entry.status_entry()?);
1625
1626 let checkbox = Checkbox::new("stage-file", is_staged.into())
1627 .disabled(!self.has_write_access(cx))
1628 .fill()
1629 .elevation(ElevationIndex::Surface)
1630 .on_click({
1631 let entry = entry.clone();
1632 let git_panel = entity.downgrade();
1633 move |_, window, cx| {
1634 git_panel
1635 .update(cx, |this, cx| {
1636 this.toggle_staged_for_entry(&entry, window, cx);
1637 cx.stop_propagation();
1638 })
1639 .ok();
1640 }
1641 });
1642 Some(
1643 h_flex()
1644 .id("start-slot")
1645 .child(checkbox)
1646 .child(git_status_icon(entry.status_entry()?.status, cx))
1647 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1648 // prevent the list item active state triggering when toggling checkbox
1649 cx.stop_propagation();
1650 })
1651 .into_any_element(),
1652 )
1653 }
1654
1655 fn render_entries(
1656 &self,
1657 has_write_access: bool,
1658 _: &Window,
1659 cx: &mut Context<Self>,
1660 ) -> impl IntoElement {
1661 let entry_count = self.entries.len();
1662
1663 v_flex()
1664 .size_full()
1665 .flex_grow()
1666 .overflow_hidden()
1667 .child(
1668 uniform_list(cx.entity().clone(), "entries", entry_count, {
1669 move |this, range, window, cx| {
1670 let mut items = Vec::with_capacity(range.end - range.start);
1671
1672 for ix in range {
1673 match &this.entries.get(ix) {
1674 Some(GitListEntry::GitStatusEntry(entry)) => {
1675 items.push(this.render_entry(
1676 ix,
1677 entry,
1678 has_write_access,
1679 window,
1680 cx,
1681 ));
1682 }
1683 Some(GitListEntry::Header(header)) => {
1684 items.push(this.render_list_header(
1685 ix,
1686 header,
1687 has_write_access,
1688 window,
1689 cx,
1690 ));
1691 }
1692 None => {}
1693 }
1694 }
1695
1696 items
1697 }
1698 })
1699 .size_full()
1700 .with_sizing_behavior(ListSizingBehavior::Infer)
1701 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1702 .track_scroll(self.scroll_handle.clone()),
1703 )
1704 .children(self.render_scrollbar(cx))
1705 }
1706
1707 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
1708 Label::new(label.into()).color(color).single_line()
1709 }
1710
1711 fn render_list_header(
1712 &self,
1713 ix: usize,
1714 header: &GitHeaderEntry,
1715 _: bool,
1716 _: &Window,
1717 _: &Context<Self>,
1718 ) -> AnyElement {
1719 div()
1720 .w_full()
1721 .child(
1722 ListItem::new(ix)
1723 .spacing(ListItemSpacing::Sparse)
1724 .disabled(true)
1725 .child(
1726 Label::new(header.title())
1727 .color(Color::Muted)
1728 .size(LabelSize::Small)
1729 .single_line(),
1730 ),
1731 )
1732 .into_any_element()
1733 }
1734
1735 fn load_commit_details(
1736 &self,
1737 sha: &str,
1738 cx: &mut Context<Self>,
1739 ) -> Task<Result<CommitDetails>> {
1740 let Some(repo) = self.active_repository.clone() else {
1741 return Task::ready(Err(anyhow::anyhow!("no active repo")));
1742 };
1743 repo.update(cx, |repo, cx| repo.show(sha, cx))
1744 }
1745
1746 fn deploy_context_menu(
1747 &mut self,
1748 position: Point<Pixels>,
1749 ix: usize,
1750 window: &mut Window,
1751 cx: &mut Context<Self>,
1752 ) {
1753 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
1754 return;
1755 };
1756 let revert_title = if entry.status.is_deleted() {
1757 "Restore file"
1758 } else if entry.status.is_created() {
1759 "Trash file"
1760 } else {
1761 "Discard changes"
1762 };
1763 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
1764 context_menu
1765 .action("Stage File", ToggleStaged.boxed_clone())
1766 .action(revert_title, editor::actions::RevertFile.boxed_clone())
1767 .separator()
1768 .action("Open Diff", Confirm.boxed_clone())
1769 .action("Open File", SecondaryConfirm.boxed_clone())
1770 });
1771
1772 let subscription = cx.subscribe_in(
1773 &context_menu,
1774 window,
1775 |this, _, _: &DismissEvent, window, cx| {
1776 if this.context_menu.as_ref().is_some_and(|context_menu| {
1777 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1778 }) {
1779 cx.focus_self(window);
1780 }
1781 this.context_menu.take();
1782 cx.notify();
1783 },
1784 );
1785 self.selected_entry = Some(ix);
1786 self.context_menu = Some((context_menu, position, subscription));
1787 cx.notify();
1788 }
1789
1790 fn render_entry(
1791 &self,
1792 ix: usize,
1793 entry: &GitStatusEntry,
1794 has_write_access: bool,
1795 window: &Window,
1796 cx: &Context<Self>,
1797 ) -> AnyElement {
1798 let display_name = entry
1799 .repo_path
1800 .file_name()
1801 .map(|name| name.to_string_lossy().into_owned())
1802 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
1803
1804 let repo_path = entry.repo_path.clone();
1805 let selected = self.selected_entry == Some(ix);
1806 let status_style = GitPanelSettings::get_global(cx).status_style;
1807 let status = entry.status;
1808 let has_conflict = status.is_conflicted();
1809 let is_modified = status.is_modified();
1810 let is_deleted = status.is_deleted();
1811
1812 let label_color = if status_style == StatusStyle::LabelColor {
1813 if has_conflict {
1814 Color::Conflict
1815 } else if is_modified {
1816 Color::Modified
1817 } else if is_deleted {
1818 // We don't want a bunch of red labels in the list
1819 Color::Disabled
1820 } else {
1821 Color::Created
1822 }
1823 } else {
1824 Color::Default
1825 };
1826
1827 let path_color = if status.is_deleted() {
1828 Color::Disabled
1829 } else {
1830 Color::Muted
1831 };
1832
1833 let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
1834
1835 let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
1836
1837 if !self.has_staged_changes() && !entry.status.is_created() {
1838 is_staged = ToggleState::Selected;
1839 }
1840
1841 let checkbox = Checkbox::new(id, is_staged)
1842 .disabled(!has_write_access)
1843 .fill()
1844 .placeholder(!self.has_staged_changes())
1845 .elevation(ElevationIndex::Surface)
1846 .on_click({
1847 let entry = entry.clone();
1848 cx.listener(move |this, _, window, cx| {
1849 this.toggle_staged_for_entry(
1850 &GitListEntry::GitStatusEntry(entry.clone()),
1851 window,
1852 cx,
1853 );
1854 cx.stop_propagation();
1855 })
1856 });
1857
1858 let start_slot = h_flex()
1859 .id(("start-slot", ix))
1860 .gap(DynamicSpacing::Base04.rems(cx))
1861 .child(checkbox)
1862 .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
1863 .child(git_status_icon(status, cx))
1864 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1865 // prevent the list item active state triggering when toggling checkbox
1866 cx.stop_propagation();
1867 });
1868
1869 div()
1870 .w_full()
1871 .child(
1872 ListItem::new(ix)
1873 .spacing(ListItemSpacing::Sparse)
1874 .start_slot(start_slot)
1875 .toggle_state(selected)
1876 .focused(selected && self.focus_handle(cx).is_focused(window))
1877 .disabled(!has_write_access)
1878 .on_click({
1879 cx.listener(move |this, event: &ClickEvent, window, cx| {
1880 this.selected_entry = Some(ix);
1881 cx.notify();
1882 if event.modifiers().secondary() {
1883 this.open_file(&Default::default(), window, cx)
1884 } else {
1885 this.open_diff(&Default::default(), window, cx);
1886 }
1887 })
1888 })
1889 .on_secondary_mouse_down(cx.listener(
1890 move |this, event: &MouseDownEvent, window, cx| {
1891 this.deploy_context_menu(event.position, ix, window, cx)
1892 },
1893 ))
1894 .child(
1895 h_flex()
1896 .when_some(repo_path.parent(), |this, parent| {
1897 let parent_str = parent.to_string_lossy();
1898 if !parent_str.is_empty() {
1899 this.child(
1900 self.entry_label(format!("{}/", parent_str), path_color)
1901 .when(status.is_deleted(), |this| this.strikethrough()),
1902 )
1903 } else {
1904 this
1905 }
1906 })
1907 .child(
1908 self.entry_label(display_name.clone(), label_color)
1909 .when(status.is_deleted(), |this| this.strikethrough()),
1910 ),
1911 ),
1912 )
1913 .into_any_element()
1914 }
1915
1916 fn has_write_access(&self, cx: &App) -> bool {
1917 !self.project.read(cx).is_read_only(cx)
1918 }
1919}
1920
1921impl Render for GitPanel {
1922 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1923 let project = self.project.read(cx);
1924 let has_entries = self
1925 .active_repository
1926 .as_ref()
1927 .map_or(false, |active_repository| {
1928 active_repository.read(cx).entry_count() > 0
1929 });
1930 let room = self
1931 .workspace
1932 .upgrade()
1933 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1934
1935 let has_write_access = self.has_write_access(cx);
1936
1937 let has_co_authors = room.map_or(false, |room| {
1938 room.read(cx)
1939 .remote_participants()
1940 .values()
1941 .any(|remote_participant| remote_participant.can_write())
1942 });
1943
1944 v_flex()
1945 .id("git_panel")
1946 .key_context(self.dispatch_context(window, cx))
1947 .track_focus(&self.focus_handle)
1948 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1949 .when(has_write_access && !project.is_read_only(cx), |this| {
1950 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1951 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1952 }))
1953 .on_action(cx.listener(GitPanel::commit))
1954 })
1955 .on_action(cx.listener(Self::select_first))
1956 .on_action(cx.listener(Self::select_next))
1957 .on_action(cx.listener(Self::select_prev))
1958 .on_action(cx.listener(Self::select_last))
1959 .on_action(cx.listener(Self::close_panel))
1960 .on_action(cx.listener(Self::open_diff))
1961 .on_action(cx.listener(Self::open_file))
1962 .on_action(cx.listener(Self::revert))
1963 .on_action(cx.listener(Self::focus_changes_list))
1964 .on_action(cx.listener(Self::focus_editor))
1965 .on_action(cx.listener(Self::toggle_staged_for_selected))
1966 .when(has_write_access && has_co_authors, |git_panel| {
1967 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
1968 })
1969 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1970 .on_hover(cx.listener(|this, hovered, window, cx| {
1971 if *hovered {
1972 this.show_scrollbar = true;
1973 this.hide_scrollbar_task.take();
1974 cx.notify();
1975 } else if !this.focus_handle.contains_focused(window, cx) {
1976 this.hide_scrollbar(window, cx);
1977 }
1978 }))
1979 .size_full()
1980 .overflow_hidden()
1981 .bg(ElevationIndex::Surface.bg(cx))
1982 .child(self.render_panel_header(window, cx))
1983 .child(if has_entries {
1984 self.render_entries(has_write_access, window, cx)
1985 .into_any_element()
1986 } else {
1987 self.render_empty_state(cx).into_any_element()
1988 })
1989 .children(self.render_previous_commit(cx))
1990 .child(self.render_commit_editor(window, cx))
1991 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1992 deferred(
1993 anchored()
1994 .position(*position)
1995 .anchor(gpui::Corner::TopLeft)
1996 .child(menu.clone()),
1997 )
1998 .with_priority(1)
1999 }))
2000 }
2001}
2002
2003impl Focusable for GitPanel {
2004 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2005 self.focus_handle.clone()
2006 }
2007}
2008
2009impl EventEmitter<Event> for GitPanel {}
2010
2011impl EventEmitter<PanelEvent> for GitPanel {}
2012
2013pub(crate) struct GitPanelAddon {
2014 pub(crate) git_panel: Entity<GitPanel>,
2015}
2016
2017impl editor::Addon for GitPanelAddon {
2018 fn to_any(&self) -> &dyn std::any::Any {
2019 self
2020 }
2021
2022 fn render_buffer_header_controls(
2023 &self,
2024 excerpt_info: &ExcerptInfo,
2025 window: &Window,
2026 cx: &App,
2027 ) -> Option<AnyElement> {
2028 let file = excerpt_info.buffer.file()?;
2029 let git_panel = self.git_panel.read(cx);
2030
2031 git_panel.render_buffer_header_controls(&self.git_panel, &file, window, cx)
2032 }
2033}
2034
2035impl Panel for GitPanel {
2036 fn persistent_name() -> &'static str {
2037 "GitPanel"
2038 }
2039
2040 fn position(&self, _: &Window, cx: &App) -> DockPosition {
2041 GitPanelSettings::get_global(cx).dock
2042 }
2043
2044 fn position_is_valid(&self, position: DockPosition) -> bool {
2045 matches!(position, DockPosition::Left | DockPosition::Right)
2046 }
2047
2048 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2049 settings::update_settings_file::<GitPanelSettings>(
2050 self.fs.clone(),
2051 cx,
2052 move |settings, _| settings.dock = Some(position),
2053 );
2054 }
2055
2056 fn size(&self, _: &Window, cx: &App) -> Pixels {
2057 self.width
2058 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2059 }
2060
2061 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2062 self.width = size;
2063 self.serialize(cx);
2064 cx.notify();
2065 }
2066
2067 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2068 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2069 }
2070
2071 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2072 Some("Git Panel")
2073 }
2074
2075 fn toggle_action(&self) -> Box<dyn Action> {
2076 Box::new(ToggleFocus)
2077 }
2078
2079 fn activation_priority(&self) -> u32 {
2080 2
2081 }
2082}
2083
2084impl PanelHeader for GitPanel {}
2085
2086struct GitPanelMessageTooltip {
2087 commit_tooltip: Option<Entity<CommitTooltip>>,
2088}
2089
2090impl GitPanelMessageTooltip {
2091 fn new(
2092 git_panel: Entity<GitPanel>,
2093 sha: SharedString,
2094 window: &mut Window,
2095 cx: &mut App,
2096 ) -> Entity<Self> {
2097 let workspace = git_panel.read(cx).workspace.clone();
2098 cx.new(|cx| {
2099 cx.spawn_in(window, |this, mut cx| async move {
2100 let language_registry = workspace.update(&mut cx, |workspace, _cx| {
2101 workspace.app_state().languages.clone()
2102 })?;
2103
2104 let details = git_panel
2105 .update(&mut cx, |git_panel, cx| {
2106 git_panel.load_commit_details(&sha, cx)
2107 })?
2108 .await?;
2109
2110 let mut parsed_message = ParsedMarkdown::default();
2111 markdown::parse_markdown_block(
2112 &details.message,
2113 Some(&language_registry),
2114 None,
2115 &mut parsed_message.text,
2116 &mut parsed_message.highlights,
2117 &mut parsed_message.region_ranges,
2118 &mut parsed_message.regions,
2119 )
2120 .await;
2121
2122 let commit_details = editor::commit_tooltip::CommitDetails {
2123 sha: details.sha.clone(),
2124 committer_name: details.committer_name.clone(),
2125 committer_email: details.committer_email.clone(),
2126 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2127 message: Some(editor::commit_tooltip::ParsedCommitMessage {
2128 message: details.message.clone(),
2129 parsed_message,
2130 ..Default::default()
2131 }),
2132 };
2133
2134 this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2135 this.commit_tooltip = Some(cx.new(move |cx| {
2136 CommitTooltip::new(
2137 commit_details,
2138 panel_editor_style(true, window, cx),
2139 Some(workspace),
2140 )
2141 }));
2142 cx.notify();
2143 })
2144 })
2145 .detach();
2146
2147 Self {
2148 commit_tooltip: None,
2149 }
2150 })
2151 }
2152}
2153
2154impl Render for GitPanelMessageTooltip {
2155 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2156 if let Some(commit_tooltip) = &self.commit_tooltip {
2157 commit_tooltip.clone().into_any_element()
2158 } else {
2159 gpui::Empty.into_any_element()
2160 }
2161 }
2162}