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.read(cx).stage_entries(repo_paths.clone())
753 } else {
754 active_repository
755 .read(cx)
756 .unstage_entries(repo_paths.clone())
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 = active_repository.read(cx).stage_entries(changed_files);
853 cx.spawn(|_, mut cx| async move {
854 stage_task.await??;
855 let commit_task = active_repository
856 .update(&mut cx, |repo, _| repo.commit(message.into(), None))?;
857 commit_task.await?
858 })
859 };
860 let task = cx.spawn_in(window, |this, mut cx| async move {
861 let result = task.await;
862 this.update_in(&mut cx, |this, window, cx| {
863 this.pending_commit.take();
864 match result {
865 Ok(()) => {
866 this.commit_editor
867 .update(cx, |editor, cx| editor.clear(window, cx));
868 }
869 Err(e) => this.show_err_toast(e, cx),
870 }
871 })
872 .ok();
873 });
874
875 self.pending_commit = Some(task);
876 }
877
878 fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
879 let Some(repo) = self.active_repository.clone() else {
880 return;
881 };
882 let prior_head = self.load_commit_details("HEAD", cx);
883
884 let task = cx.spawn(|_, mut cx| async move {
885 let prior_head = prior_head.await?;
886
887 repo.update(&mut cx, |repo, _| repo.reset("HEAD^", ResetMode::Soft))?
888 .await??;
889
890 Ok(prior_head)
891 });
892
893 let task = cx.spawn_in(window, |this, mut cx| async move {
894 let result = task.await;
895 this.update_in(&mut cx, |this, window, cx| {
896 this.pending_commit.take();
897 match result {
898 Ok(prior_commit) => {
899 this.commit_editor.update(cx, |editor, cx| {
900 editor.set_text(prior_commit.message, window, cx)
901 });
902 }
903 Err(e) => this.show_err_toast(e, cx),
904 }
905 })
906 .ok();
907 });
908
909 self.pending_commit = Some(task);
910 }
911
912 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
913 let mut new_co_authors = Vec::new();
914 let project = self.project.read(cx);
915
916 let Some(room) = self
917 .workspace
918 .upgrade()
919 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
920 else {
921 return Vec::default();
922 };
923
924 let room = room.read(cx);
925
926 for (peer_id, collaborator) in project.collaborators() {
927 if collaborator.is_host {
928 continue;
929 }
930
931 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
932 continue;
933 };
934 if participant.can_write() && participant.user.email.is_some() {
935 let email = participant.user.email.clone().unwrap();
936
937 new_co_authors.push((
938 participant
939 .user
940 .name
941 .clone()
942 .unwrap_or_else(|| participant.user.github_login.clone()),
943 email,
944 ))
945 }
946 }
947 if !project.is_local() && !project.is_read_only(cx) {
948 if let Some(user) = room.local_participant_user(cx) {
949 if let Some(email) = user.email.clone() {
950 new_co_authors.push((
951 user.name
952 .clone()
953 .unwrap_or_else(|| user.github_login.clone()),
954 email.clone(),
955 ))
956 }
957 }
958 }
959 new_co_authors
960 }
961
962 fn toggle_fill_co_authors(
963 &mut self,
964 _: &ToggleFillCoAuthors,
965 _: &mut Window,
966 cx: &mut Context<Self>,
967 ) {
968 self.add_coauthors = !self.add_coauthors;
969 cx.notify();
970 }
971
972 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
973 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
974
975 let existing_text = message.to_ascii_lowercase();
976 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
977 let mut ends_with_co_authors = false;
978 let existing_co_authors = existing_text
979 .lines()
980 .filter_map(|line| {
981 let line = line.trim();
982 if line.starts_with(&lowercase_co_author_prefix) {
983 ends_with_co_authors = true;
984 Some(line)
985 } else {
986 ends_with_co_authors = false;
987 None
988 }
989 })
990 .collect::<HashSet<_>>();
991
992 let new_co_authors = self
993 .potential_co_authors(cx)
994 .into_iter()
995 .filter(|(_, email)| {
996 !existing_co_authors
997 .iter()
998 .any(|existing| existing.contains(email.as_str()))
999 })
1000 .collect::<Vec<_>>();
1001
1002 if new_co_authors.is_empty() {
1003 return;
1004 }
1005
1006 if !ends_with_co_authors {
1007 message.push('\n');
1008 }
1009 for (name, email) in new_co_authors {
1010 message.push('\n');
1011 message.push_str(CO_AUTHOR_PREFIX);
1012 message.push_str(&name);
1013 message.push_str(" <");
1014 message.push_str(&email);
1015 message.push('>');
1016 }
1017 message.push('\n');
1018 }
1019
1020 fn schedule_update(
1021 &mut self,
1022 clear_pending: bool,
1023 window: &mut Window,
1024 cx: &mut Context<Self>,
1025 ) {
1026 let handle = cx.entity().downgrade();
1027 self.reopen_commit_buffer(window, cx);
1028 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
1029 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
1030 if let Some(git_panel) = handle.upgrade() {
1031 git_panel
1032 .update_in(&mut cx, |git_panel, _, cx| {
1033 if clear_pending {
1034 git_panel.clear_pending();
1035 }
1036 git_panel.update_visible_entries(cx);
1037 })
1038 .ok();
1039 }
1040 });
1041 }
1042
1043 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1044 let Some(active_repo) = self.active_repository.as_ref() else {
1045 return;
1046 };
1047 let load_buffer = active_repo.update(cx, |active_repo, cx| {
1048 let project = self.project.read(cx);
1049 active_repo.open_commit_buffer(
1050 Some(project.languages().clone()),
1051 project.buffer_store().clone(),
1052 cx,
1053 )
1054 });
1055
1056 cx.spawn_in(window, |git_panel, mut cx| async move {
1057 let buffer = load_buffer.await?;
1058 git_panel.update_in(&mut cx, |git_panel, window, cx| {
1059 if git_panel
1060 .commit_editor
1061 .read(cx)
1062 .buffer()
1063 .read(cx)
1064 .as_singleton()
1065 .as_ref()
1066 != Some(&buffer)
1067 {
1068 git_panel.commit_editor = cx.new(|cx| {
1069 commit_message_editor(buffer, git_panel.project.clone(), window, cx)
1070 });
1071 }
1072 })
1073 })
1074 .detach_and_log_err(cx);
1075 }
1076
1077 fn clear_pending(&mut self) {
1078 self.pending.retain(|v| !v.finished)
1079 }
1080
1081 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
1082 self.entries.clear();
1083 self.entries_by_path.clear();
1084 let mut changed_entries = Vec::new();
1085 let mut new_entries = Vec::new();
1086 let mut conflict_entries = Vec::new();
1087
1088 let Some(repo) = self.active_repository.as_ref() else {
1089 // Just clear entries if no repository is active.
1090 cx.notify();
1091 return;
1092 };
1093
1094 // First pass - collect all paths
1095 let repo = repo.read(cx);
1096 let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
1097
1098 // Second pass - create entries with proper depth calculation
1099 for entry in repo.status() {
1100 let (depth, difference) =
1101 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
1102
1103 let is_conflict = repo.has_conflict(&entry.repo_path);
1104 let is_new = entry.status.is_created();
1105 let is_staged = entry.status.is_staged();
1106
1107 let display_name = if difference > 1 {
1108 // Show partial path for deeply nested files
1109 entry
1110 .repo_path
1111 .as_ref()
1112 .iter()
1113 .skip(entry.repo_path.components().count() - difference)
1114 .collect::<PathBuf>()
1115 .to_string_lossy()
1116 .into_owned()
1117 } else {
1118 // Just show filename
1119 entry
1120 .repo_path
1121 .file_name()
1122 .map(|name| name.to_string_lossy().into_owned())
1123 .unwrap_or_default()
1124 };
1125
1126 let entry = GitStatusEntry {
1127 depth,
1128 display_name,
1129 repo_path: entry.repo_path.clone(),
1130 status: entry.status,
1131 is_staged,
1132 };
1133
1134 if is_conflict {
1135 conflict_entries.push(entry);
1136 } else if is_new {
1137 new_entries.push(entry);
1138 } else {
1139 changed_entries.push(entry);
1140 }
1141 }
1142
1143 // Sort entries by path to maintain consistent order
1144 conflict_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1145 changed_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1146 new_entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
1147
1148 if conflict_entries.len() > 0 {
1149 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1150 header: Section::Conflict,
1151 }));
1152 self.entries.extend(
1153 conflict_entries
1154 .into_iter()
1155 .map(GitListEntry::GitStatusEntry),
1156 );
1157 }
1158
1159 if changed_entries.len() > 0 {
1160 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1161 header: Section::Tracked,
1162 }));
1163 self.entries.extend(
1164 changed_entries
1165 .into_iter()
1166 .map(GitListEntry::GitStatusEntry),
1167 );
1168 }
1169 if new_entries.len() > 0 {
1170 self.entries.push(GitListEntry::Header(GitHeaderEntry {
1171 header: Section::New,
1172 }));
1173 self.entries
1174 .extend(new_entries.into_iter().map(GitListEntry::GitStatusEntry));
1175 }
1176
1177 for (ix, entry) in self.entries.iter().enumerate() {
1178 if let Some(status_entry) = entry.status_entry() {
1179 self.entries_by_path
1180 .insert(status_entry.repo_path.clone(), ix);
1181 }
1182 }
1183 self.update_counts(repo);
1184
1185 self.select_first_entry_if_none(cx);
1186
1187 cx.notify();
1188 }
1189
1190 fn header_state(&self, header_type: Section) -> ToggleState {
1191 let (staged_count, count) = match header_type {
1192 Section::New => (self.new_staged_count, self.new_count),
1193 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
1194 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
1195 };
1196 if staged_count == 0 {
1197 ToggleState::Unselected
1198 } else if count == staged_count {
1199 ToggleState::Selected
1200 } else {
1201 ToggleState::Indeterminate
1202 }
1203 }
1204
1205 fn update_counts(&mut self, repo: &Repository) {
1206 self.conflicted_count = 0;
1207 self.conflicted_staged_count = 0;
1208 self.new_count = 0;
1209 self.tracked_count = 0;
1210 self.new_staged_count = 0;
1211 self.tracked_staged_count = 0;
1212 for entry in &self.entries {
1213 let Some(status_entry) = entry.status_entry() else {
1214 continue;
1215 };
1216 if repo.has_conflict(&status_entry.repo_path) {
1217 self.conflicted_count += 1;
1218 if self.entry_is_staged(status_entry) != Some(false) {
1219 self.conflicted_staged_count += 1;
1220 }
1221 } else if status_entry.status.is_created() {
1222 self.new_count += 1;
1223 if self.entry_is_staged(status_entry) != Some(false) {
1224 self.new_staged_count += 1;
1225 }
1226 } else {
1227 self.tracked_count += 1;
1228 if self.entry_is_staged(status_entry) != Some(false) {
1229 self.tracked_staged_count += 1;
1230 }
1231 }
1232 }
1233 }
1234
1235 fn entry_is_staged(&self, entry: &GitStatusEntry) -> Option<bool> {
1236 for pending in self.pending.iter().rev() {
1237 if pending.repo_paths.contains(&entry.repo_path) {
1238 return Some(pending.will_become_staged);
1239 }
1240 }
1241 entry.is_staged
1242 }
1243
1244 fn has_staged_changes(&self) -> bool {
1245 self.tracked_staged_count > 0
1246 || self.new_staged_count > 0
1247 || self.conflicted_staged_count > 0
1248 }
1249
1250 fn has_tracked_changes(&self) -> bool {
1251 self.tracked_count > 0
1252 }
1253
1254 fn has_unstaged_conflicts(&self) -> bool {
1255 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
1256 }
1257
1258 fn show_err_toast(&self, e: anyhow::Error, cx: &mut App) {
1259 let Some(workspace) = self.workspace.upgrade() else {
1260 return;
1261 };
1262 let notif_id = NotificationId::Named("git-operation-error".into());
1263
1264 let message = e.to_string();
1265 workspace.update(cx, |workspace, cx| {
1266 let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
1267 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
1268 });
1269 workspace.show_toast(toast, cx);
1270 });
1271 }
1272
1273 pub fn panel_button(
1274 &self,
1275 id: impl Into<SharedString>,
1276 label: impl Into<SharedString>,
1277 ) -> Button {
1278 let id = id.into().clone();
1279 let label = label.into().clone();
1280
1281 Button::new(id, label)
1282 .label_size(LabelSize::Small)
1283 .layer(ElevationIndex::ElevatedSurface)
1284 .size(ButtonSize::Compact)
1285 .style(ButtonStyle::Filled)
1286 }
1287
1288 pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
1289 Checkbox::container_size(cx).to_pixels(window.rem_size())
1290 }
1291
1292 pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
1293 h_flex()
1294 .items_center()
1295 .h(px(8.))
1296 .child(Divider::horizontal_dashed().color(DividerColor::Border))
1297 }
1298
1299 pub fn render_panel_header(
1300 &self,
1301 window: &mut Window,
1302 cx: &mut Context<Self>,
1303 ) -> impl IntoElement {
1304 let all_repositories = self
1305 .project
1306 .read(cx)
1307 .git_store()
1308 .read(cx)
1309 .all_repositories();
1310
1311 let has_repo_above = all_repositories.iter().any(|repo| {
1312 repo.read(cx)
1313 .repository_entry
1314 .work_directory
1315 .is_above_project()
1316 });
1317
1318 self.panel_header_container(window, cx)
1319 .when(all_repositories.len() > 1 || has_repo_above, |el| {
1320 el.child(self.render_repository_selector(cx))
1321 })
1322 }
1323
1324 pub fn render_repository_selector(&self, cx: &mut Context<Self>) -> impl IntoElement {
1325 let active_repository = self.project.read(cx).active_repository(cx);
1326 let repository_display_name = active_repository
1327 .as_ref()
1328 .map(|repo| repo.read(cx).display_name(self.project.read(cx), cx))
1329 .unwrap_or_default();
1330
1331 RepositorySelectorPopoverMenu::new(
1332 self.repository_selector.clone(),
1333 ButtonLike::new("active-repository")
1334 .style(ButtonStyle::Subtle)
1335 .child(Label::new(repository_display_name).size(LabelSize::Small)),
1336 Tooltip::text("Select a repository"),
1337 )
1338 }
1339
1340 pub fn render_commit_editor(
1341 &self,
1342 window: &mut Window,
1343 cx: &mut Context<Self>,
1344 ) -> impl IntoElement {
1345 let editor = self.commit_editor.clone();
1346 let can_commit = (self.has_staged_changes() || self.has_tracked_changes())
1347 && self.pending_commit.is_none()
1348 && !editor.read(cx).is_empty(cx)
1349 && !self.has_unstaged_conflicts()
1350 && self.has_write_access(cx);
1351
1352 // let can_commit_all =
1353 // !self.commit_pending && self.can_commit_all && !editor.read(cx).is_empty(cx);
1354 let panel_editor_style = panel_editor_style(true, window, cx);
1355
1356 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
1357
1358 let focus_handle_1 = self.focus_handle(cx).clone();
1359 let tooltip = if self.has_staged_changes() {
1360 "Commit staged changes"
1361 } else {
1362 "Commit changes to tracked files"
1363 };
1364 let title = if self.has_staged_changes() {
1365 "Commit"
1366 } else {
1367 "Commit All"
1368 };
1369
1370 let commit_button = panel_filled_button(title)
1371 .tooltip(move |window, cx| {
1372 let focus_handle = focus_handle_1.clone();
1373 Tooltip::for_action_in(tooltip, &Commit, &focus_handle, window, cx)
1374 })
1375 .disabled(!can_commit)
1376 .on_click({
1377 cx.listener(move |this, _: &ClickEvent, window, cx| this.commit_changes(window, cx))
1378 });
1379
1380 let potential_co_authors = self.potential_co_authors(cx);
1381 let enable_coauthors = if potential_co_authors.is_empty() {
1382 None
1383 } else {
1384 Some(
1385 IconButton::new("co-authors", IconName::Person)
1386 .icon_color(Color::Disabled)
1387 .selected_icon_color(Color::Selected)
1388 .toggle_state(self.add_coauthors)
1389 .tooltip(move |_, cx| {
1390 let title = format!(
1391 "Add co-authored-by:{}{}",
1392 if potential_co_authors.len() == 1 {
1393 ""
1394 } else {
1395 "\n"
1396 },
1397 potential_co_authors
1398 .iter()
1399 .map(|(name, email)| format!(" {} <{}>", name, email))
1400 .join("\n")
1401 );
1402 Tooltip::simple(title, cx)
1403 })
1404 .on_click(cx.listener(|this, _, _, cx| {
1405 this.add_coauthors = !this.add_coauthors;
1406 cx.notify();
1407 })),
1408 )
1409 };
1410
1411 let branch = self
1412 .active_repository
1413 .as_ref()
1414 .and_then(|repo| repo.read(cx).branch().map(|b| b.name.clone()))
1415 .unwrap_or_else(|| "<no branch>".into());
1416
1417 let branch_selector = Button::new("branch-selector", branch)
1418 .color(Color::Muted)
1419 .style(ButtonStyle::Subtle)
1420 .icon(IconName::GitBranch)
1421 .icon_size(IconSize::Small)
1422 .icon_color(Color::Muted)
1423 .size(ButtonSize::Compact)
1424 .icon_position(IconPosition::Start)
1425 .tooltip(Tooltip::for_action_title(
1426 "Switch Branch",
1427 &zed_actions::git::Branch,
1428 ))
1429 .on_click(cx.listener(|_, _, window, cx| {
1430 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
1431 }))
1432 .style(ButtonStyle::Transparent);
1433
1434 let footer_size = px(32.);
1435 let gap = px(16.0);
1436
1437 let max_height = window.line_height() * 6. + gap + footer_size;
1438
1439 panel_editor_container(window, cx)
1440 .id("commit-editor-container")
1441 .relative()
1442 .h(max_height)
1443 .w_full()
1444 .border_t_1()
1445 .border_color(cx.theme().colors().border)
1446 .bg(cx.theme().colors().editor_background)
1447 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1448 window.focus(&editor_focus_handle);
1449 }))
1450 .child(EditorElement::new(&self.commit_editor, panel_editor_style))
1451 .child(
1452 h_flex()
1453 .absolute()
1454 .bottom_0()
1455 .left_2()
1456 .h(footer_size)
1457 .flex_none()
1458 .child(branch_selector),
1459 )
1460 .child(
1461 h_flex()
1462 .absolute()
1463 .bottom_0()
1464 .right_2()
1465 .h(footer_size)
1466 .flex_none()
1467 .children(enable_coauthors)
1468 .child(commit_button),
1469 )
1470 }
1471
1472 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
1473 let active_repository = self.active_repository.as_ref()?;
1474 let branch = active_repository.read(cx).branch()?;
1475 let commit = branch.most_recent_commit.as_ref()?.clone();
1476
1477 if branch.upstream.as_ref().is_some_and(|upstream| {
1478 if let Some(tracking) = &upstream.tracking {
1479 tracking.ahead == 0
1480 } else {
1481 true
1482 }
1483 }) {
1484 return None;
1485 }
1486 let tooltip = if self.has_staged_changes() {
1487 "git reset HEAD^ --soft"
1488 } else {
1489 "git reset HEAD^"
1490 };
1491
1492 let this = cx.entity();
1493 Some(
1494 h_flex()
1495 .items_center()
1496 .py_1p5()
1497 .px(px(8.))
1498 .bg(cx.theme().colors().background)
1499 .border_t_1()
1500 .border_color(cx.theme().colors().border)
1501 .gap_1p5()
1502 .child(
1503 div()
1504 .flex_grow()
1505 .overflow_hidden()
1506 .max_w(relative(0.6))
1507 .h_full()
1508 .child(
1509 Label::new(commit.subject.clone())
1510 .size(LabelSize::Small)
1511 .text_ellipsis(),
1512 )
1513 .id("commit-msg-hover")
1514 .hoverable_tooltip(move |window, cx| {
1515 GitPanelMessageTooltip::new(
1516 this.clone(),
1517 commit.sha.clone(),
1518 window,
1519 cx,
1520 )
1521 .into()
1522 }),
1523 )
1524 .child(div().flex_1())
1525 .child(
1526 panel_filled_button("Uncommit")
1527 .icon(IconName::Undo)
1528 .icon_size(IconSize::Small)
1529 .icon_color(Color::Muted)
1530 .icon_position(IconPosition::Start)
1531 .tooltip(Tooltip::for_action_title(tooltip, &git::Uncommit))
1532 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
1533 ),
1534 )
1535 }
1536
1537 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1538 h_flex()
1539 .h_full()
1540 .flex_grow()
1541 .justify_center()
1542 .items_center()
1543 .child(
1544 v_flex()
1545 .gap_3()
1546 .child(if self.active_repository.is_some() {
1547 "No changes to commit"
1548 } else {
1549 "No Git repositories"
1550 })
1551 .text_ui_sm(cx)
1552 .mx_auto()
1553 .text_color(Color::Placeholder.color(cx)),
1554 )
1555 }
1556
1557 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1558 let scroll_bar_style = self.show_scrollbar(cx);
1559 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1560
1561 if !self.should_show_scrollbar(cx)
1562 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1563 {
1564 return None;
1565 }
1566
1567 Some(
1568 div()
1569 .id("git-panel-vertical-scroll")
1570 .occlude()
1571 .flex_none()
1572 .h_full()
1573 .cursor_default()
1574 .when(show_container, |this| this.pl_1().px_1p5())
1575 .when(!show_container, |this| {
1576 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1577 })
1578 .on_mouse_move(cx.listener(|_, _, _, cx| {
1579 cx.notify();
1580 cx.stop_propagation()
1581 }))
1582 .on_hover(|_, _, cx| {
1583 cx.stop_propagation();
1584 })
1585 .on_any_mouse_down(|_, _, cx| {
1586 cx.stop_propagation();
1587 })
1588 .on_mouse_up(
1589 MouseButton::Left,
1590 cx.listener(|this, _, window, cx| {
1591 if !this.scrollbar_state.is_dragging()
1592 && !this.focus_handle.contains_focused(window, cx)
1593 {
1594 this.hide_scrollbar(window, cx);
1595 cx.notify();
1596 }
1597
1598 cx.stop_propagation();
1599 }),
1600 )
1601 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1602 cx.notify();
1603 }))
1604 .children(Scrollbar::vertical(
1605 // percentage as f32..end_offset as f32,
1606 self.scrollbar_state.clone(),
1607 )),
1608 )
1609 }
1610
1611 pub fn render_buffer_header_controls(
1612 &self,
1613 entity: &Entity<Self>,
1614 file: &Arc<dyn File>,
1615 _: &Window,
1616 cx: &App,
1617 ) -> Option<AnyElement> {
1618 let repo = self.active_repository.as_ref()?.read(cx);
1619 let repo_path = repo.worktree_id_path_to_repo_path(file.worktree_id(cx), file.path())?;
1620 let ix = self.entries_by_path.get(&repo_path)?;
1621 let entry = self.entries.get(*ix)?;
1622
1623 let is_staged = self.entry_is_staged(entry.status_entry()?);
1624
1625 let checkbox = Checkbox::new("stage-file", is_staged.into())
1626 .disabled(!self.has_write_access(cx))
1627 .fill()
1628 .elevation(ElevationIndex::Surface)
1629 .on_click({
1630 let entry = entry.clone();
1631 let git_panel = entity.downgrade();
1632 move |_, window, cx| {
1633 git_panel
1634 .update(cx, |this, cx| {
1635 this.toggle_staged_for_entry(&entry, window, cx);
1636 cx.stop_propagation();
1637 })
1638 .ok();
1639 }
1640 });
1641 Some(
1642 h_flex()
1643 .id("start-slot")
1644 .child(checkbox)
1645 .child(git_status_icon(entry.status_entry()?.status, cx))
1646 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1647 // prevent the list item active state triggering when toggling checkbox
1648 cx.stop_propagation();
1649 })
1650 .into_any_element(),
1651 )
1652 }
1653
1654 fn render_entries(
1655 &self,
1656 has_write_access: bool,
1657 _: &Window,
1658 cx: &mut Context<Self>,
1659 ) -> impl IntoElement {
1660 let entry_count = self.entries.len();
1661
1662 v_flex()
1663 .size_full()
1664 .flex_grow()
1665 .overflow_hidden()
1666 .child(
1667 uniform_list(cx.entity().clone(), "entries", entry_count, {
1668 move |this, range, window, cx| {
1669 let mut items = Vec::with_capacity(range.end - range.start);
1670
1671 for ix in range {
1672 match &this.entries.get(ix) {
1673 Some(GitListEntry::GitStatusEntry(entry)) => {
1674 items.push(this.render_entry(
1675 ix,
1676 entry,
1677 has_write_access,
1678 window,
1679 cx,
1680 ));
1681 }
1682 Some(GitListEntry::Header(header)) => {
1683 items.push(this.render_list_header(
1684 ix,
1685 header,
1686 has_write_access,
1687 window,
1688 cx,
1689 ));
1690 }
1691 None => {}
1692 }
1693 }
1694
1695 items
1696 }
1697 })
1698 .size_full()
1699 .with_sizing_behavior(ListSizingBehavior::Infer)
1700 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1701 .track_scroll(self.scroll_handle.clone()),
1702 )
1703 .children(self.render_scrollbar(cx))
1704 }
1705
1706 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
1707 Label::new(label.into()).color(color).single_line()
1708 }
1709
1710 fn render_list_header(
1711 &self,
1712 ix: usize,
1713 header: &GitHeaderEntry,
1714 _: bool,
1715 _: &Window,
1716 _: &Context<Self>,
1717 ) -> AnyElement {
1718 div()
1719 .w_full()
1720 .child(
1721 ListItem::new(ix)
1722 .spacing(ListItemSpacing::Sparse)
1723 .disabled(true)
1724 .child(
1725 Label::new(header.title())
1726 .color(Color::Muted)
1727 .size(LabelSize::Small)
1728 .single_line(),
1729 ),
1730 )
1731 .into_any_element()
1732 }
1733
1734 fn load_commit_details(
1735 &self,
1736 sha: &str,
1737 cx: &mut Context<Self>,
1738 ) -> Task<Result<CommitDetails>> {
1739 let Some(repo) = self.active_repository.clone() else {
1740 return Task::ready(Err(anyhow::anyhow!("no active repo")));
1741 };
1742 repo.update(cx, |repo, cx| repo.show(sha, cx))
1743 }
1744
1745 fn deploy_context_menu(
1746 &mut self,
1747 position: Point<Pixels>,
1748 ix: usize,
1749 window: &mut Window,
1750 cx: &mut Context<Self>,
1751 ) {
1752 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
1753 return;
1754 };
1755 let revert_title = if entry.status.is_deleted() {
1756 "Restore file"
1757 } else if entry.status.is_created() {
1758 "Trash file"
1759 } else {
1760 "Discard changes"
1761 };
1762 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
1763 context_menu
1764 .action("Stage File", ToggleStaged.boxed_clone())
1765 .action(revert_title, editor::actions::RevertFile.boxed_clone())
1766 .separator()
1767 .action("Open Diff", Confirm.boxed_clone())
1768 .action("Open File", SecondaryConfirm.boxed_clone())
1769 });
1770
1771 let subscription = cx.subscribe_in(
1772 &context_menu,
1773 window,
1774 |this, _, _: &DismissEvent, window, cx| {
1775 if this.context_menu.as_ref().is_some_and(|context_menu| {
1776 context_menu.0.focus_handle(cx).contains_focused(window, cx)
1777 }) {
1778 cx.focus_self(window);
1779 }
1780 this.context_menu.take();
1781 cx.notify();
1782 },
1783 );
1784 self.selected_entry = Some(ix);
1785 self.context_menu = Some((context_menu, position, subscription));
1786 cx.notify();
1787 }
1788
1789 fn render_entry(
1790 &self,
1791 ix: usize,
1792 entry: &GitStatusEntry,
1793 has_write_access: bool,
1794 window: &Window,
1795 cx: &Context<Self>,
1796 ) -> AnyElement {
1797 let display_name = entry
1798 .repo_path
1799 .file_name()
1800 .map(|name| name.to_string_lossy().into_owned())
1801 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
1802
1803 let repo_path = entry.repo_path.clone();
1804 let selected = self.selected_entry == Some(ix);
1805 let status_style = GitPanelSettings::get_global(cx).status_style;
1806 let status = entry.status;
1807 let has_conflict = status.is_conflicted();
1808 let is_modified = status.is_modified();
1809 let is_deleted = status.is_deleted();
1810
1811 let label_color = if status_style == StatusStyle::LabelColor {
1812 if has_conflict {
1813 Color::Conflict
1814 } else if is_modified {
1815 Color::Modified
1816 } else if is_deleted {
1817 // We don't want a bunch of red labels in the list
1818 Color::Disabled
1819 } else {
1820 Color::Created
1821 }
1822 } else {
1823 Color::Default
1824 };
1825
1826 let path_color = if status.is_deleted() {
1827 Color::Disabled
1828 } else {
1829 Color::Muted
1830 };
1831
1832 let id: ElementId = ElementId::Name(format!("entry_{}", display_name).into());
1833
1834 let mut is_staged: ToggleState = self.entry_is_staged(entry).into();
1835
1836 if !self.has_staged_changes() && !entry.status.is_created() {
1837 is_staged = ToggleState::Selected;
1838 }
1839
1840 let checkbox = Checkbox::new(id, is_staged)
1841 .disabled(!has_write_access)
1842 .fill()
1843 .placeholder(!self.has_staged_changes())
1844 .elevation(ElevationIndex::Surface)
1845 .on_click({
1846 let entry = entry.clone();
1847 cx.listener(move |this, _, window, cx| {
1848 this.toggle_staged_for_entry(
1849 &GitListEntry::GitStatusEntry(entry.clone()),
1850 window,
1851 cx,
1852 );
1853 cx.stop_propagation();
1854 })
1855 });
1856
1857 let start_slot = h_flex()
1858 .id(("start-slot", ix))
1859 .gap(DynamicSpacing::Base04.rems(cx))
1860 .child(checkbox)
1861 .tooltip(|window, cx| Tooltip::for_action("Stage File", &ToggleStaged, window, cx))
1862 .child(git_status_icon(status, cx))
1863 .on_mouse_down(MouseButton::Left, |_, _, cx| {
1864 // prevent the list item active state triggering when toggling checkbox
1865 cx.stop_propagation();
1866 });
1867
1868 div()
1869 .w_full()
1870 .child(
1871 ListItem::new(ix)
1872 .spacing(ListItemSpacing::Sparse)
1873 .start_slot(start_slot)
1874 .toggle_state(selected)
1875 .focused(selected && self.focus_handle(cx).is_focused(window))
1876 .disabled(!has_write_access)
1877 .on_click({
1878 cx.listener(move |this, event: &ClickEvent, window, cx| {
1879 this.selected_entry = Some(ix);
1880 cx.notify();
1881 if event.modifiers().secondary() {
1882 this.open_file(&Default::default(), window, cx)
1883 } else {
1884 this.open_diff(&Default::default(), window, cx);
1885 }
1886 })
1887 })
1888 .on_secondary_mouse_down(cx.listener(
1889 move |this, event: &MouseDownEvent, window, cx| {
1890 this.deploy_context_menu(event.position, ix, window, cx)
1891 },
1892 ))
1893 .child(
1894 h_flex()
1895 .when_some(repo_path.parent(), |this, parent| {
1896 let parent_str = parent.to_string_lossy();
1897 if !parent_str.is_empty() {
1898 this.child(
1899 self.entry_label(format!("{}/", parent_str), path_color)
1900 .when(status.is_deleted(), |this| this.strikethrough()),
1901 )
1902 } else {
1903 this
1904 }
1905 })
1906 .child(
1907 self.entry_label(display_name.clone(), label_color)
1908 .when(status.is_deleted(), |this| this.strikethrough()),
1909 ),
1910 ),
1911 )
1912 .into_any_element()
1913 }
1914
1915 fn has_write_access(&self, cx: &App) -> bool {
1916 !self.project.read(cx).is_read_only(cx)
1917 }
1918}
1919
1920impl Render for GitPanel {
1921 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1922 let project = self.project.read(cx);
1923 let has_entries = self
1924 .active_repository
1925 .as_ref()
1926 .map_or(false, |active_repository| {
1927 active_repository.read(cx).entry_count() > 0
1928 });
1929 let room = self
1930 .workspace
1931 .upgrade()
1932 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1933
1934 let has_write_access = self.has_write_access(cx);
1935
1936 let has_co_authors = room.map_or(false, |room| {
1937 room.read(cx)
1938 .remote_participants()
1939 .values()
1940 .any(|remote_participant| remote_participant.can_write())
1941 });
1942
1943 v_flex()
1944 .id("git_panel")
1945 .key_context(self.dispatch_context(window, cx))
1946 .track_focus(&self.focus_handle)
1947 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1948 .when(has_write_access && !project.is_read_only(cx), |this| {
1949 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1950 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1951 }))
1952 .on_action(cx.listener(GitPanel::commit))
1953 })
1954 .on_action(cx.listener(Self::select_first))
1955 .on_action(cx.listener(Self::select_next))
1956 .on_action(cx.listener(Self::select_prev))
1957 .on_action(cx.listener(Self::select_last))
1958 .on_action(cx.listener(Self::close_panel))
1959 .on_action(cx.listener(Self::open_diff))
1960 .on_action(cx.listener(Self::open_file))
1961 .on_action(cx.listener(Self::revert))
1962 .on_action(cx.listener(Self::focus_changes_list))
1963 .on_action(cx.listener(Self::focus_editor))
1964 .on_action(cx.listener(Self::toggle_staged_for_selected))
1965 .when(has_write_access && has_co_authors, |git_panel| {
1966 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
1967 })
1968 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1969 .on_hover(cx.listener(|this, hovered, window, cx| {
1970 if *hovered {
1971 this.show_scrollbar = true;
1972 this.hide_scrollbar_task.take();
1973 cx.notify();
1974 } else if !this.focus_handle.contains_focused(window, cx) {
1975 this.hide_scrollbar(window, cx);
1976 }
1977 }))
1978 .size_full()
1979 .overflow_hidden()
1980 .bg(ElevationIndex::Surface.bg(cx))
1981 .child(self.render_panel_header(window, cx))
1982 .child(if has_entries {
1983 self.render_entries(has_write_access, window, cx)
1984 .into_any_element()
1985 } else {
1986 self.render_empty_state(cx).into_any_element()
1987 })
1988 .children(self.render_previous_commit(cx))
1989 .child(self.render_commit_editor(window, cx))
1990 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1991 deferred(
1992 anchored()
1993 .position(*position)
1994 .anchor(gpui::Corner::TopLeft)
1995 .child(menu.clone()),
1996 )
1997 .with_priority(1)
1998 }))
1999 }
2000}
2001
2002impl Focusable for GitPanel {
2003 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
2004 self.focus_handle.clone()
2005 }
2006}
2007
2008impl EventEmitter<Event> for GitPanel {}
2009
2010impl EventEmitter<PanelEvent> for GitPanel {}
2011
2012pub(crate) struct GitPanelAddon {
2013 pub(crate) git_panel: Entity<GitPanel>,
2014}
2015
2016impl editor::Addon for GitPanelAddon {
2017 fn to_any(&self) -> &dyn std::any::Any {
2018 self
2019 }
2020
2021 fn render_buffer_header_controls(
2022 &self,
2023 excerpt_info: &ExcerptInfo,
2024 window: &Window,
2025 cx: &App,
2026 ) -> Option<AnyElement> {
2027 let file = excerpt_info.buffer.file()?;
2028 let git_panel = self.git_panel.read(cx);
2029
2030 git_panel.render_buffer_header_controls(&self.git_panel, &file, window, cx)
2031 }
2032}
2033
2034impl Panel for GitPanel {
2035 fn persistent_name() -> &'static str {
2036 "GitPanel"
2037 }
2038
2039 fn position(&self, _: &Window, cx: &App) -> DockPosition {
2040 GitPanelSettings::get_global(cx).dock
2041 }
2042
2043 fn position_is_valid(&self, position: DockPosition) -> bool {
2044 matches!(position, DockPosition::Left | DockPosition::Right)
2045 }
2046
2047 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
2048 settings::update_settings_file::<GitPanelSettings>(
2049 self.fs.clone(),
2050 cx,
2051 move |settings, _| settings.dock = Some(position),
2052 );
2053 }
2054
2055 fn size(&self, _: &Window, cx: &App) -> Pixels {
2056 self.width
2057 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
2058 }
2059
2060 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
2061 self.width = size;
2062 self.serialize(cx);
2063 cx.notify();
2064 }
2065
2066 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
2067 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
2068 }
2069
2070 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
2071 Some("Git Panel")
2072 }
2073
2074 fn toggle_action(&self) -> Box<dyn Action> {
2075 Box::new(ToggleFocus)
2076 }
2077
2078 fn activation_priority(&self) -> u32 {
2079 2
2080 }
2081}
2082
2083impl PanelHeader for GitPanel {}
2084
2085struct GitPanelMessageTooltip {
2086 commit_tooltip: Option<Entity<CommitTooltip>>,
2087}
2088
2089impl GitPanelMessageTooltip {
2090 fn new(
2091 git_panel: Entity<GitPanel>,
2092 sha: SharedString,
2093 window: &mut Window,
2094 cx: &mut App,
2095 ) -> Entity<Self> {
2096 let workspace = git_panel.read(cx).workspace.clone();
2097 cx.new(|cx| {
2098 cx.spawn_in(window, |this, mut cx| async move {
2099 let language_registry = workspace.update(&mut cx, |workspace, _cx| {
2100 workspace.app_state().languages.clone()
2101 })?;
2102
2103 let details = git_panel
2104 .update(&mut cx, |git_panel, cx| {
2105 git_panel.load_commit_details(&sha, cx)
2106 })?
2107 .await?;
2108
2109 let mut parsed_message = ParsedMarkdown::default();
2110 markdown::parse_markdown_block(
2111 &details.message,
2112 Some(&language_registry),
2113 None,
2114 &mut parsed_message.text,
2115 &mut parsed_message.highlights,
2116 &mut parsed_message.region_ranges,
2117 &mut parsed_message.regions,
2118 )
2119 .await;
2120
2121 let commit_details = editor::commit_tooltip::CommitDetails {
2122 sha: details.sha.clone(),
2123 committer_name: details.committer_name.clone(),
2124 committer_email: details.committer_email.clone(),
2125 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
2126 message: Some(editor::commit_tooltip::ParsedCommitMessage {
2127 message: details.message.clone(),
2128 parsed_message,
2129 ..Default::default()
2130 }),
2131 };
2132
2133 this.update_in(&mut cx, |this: &mut GitPanelMessageTooltip, window, cx| {
2134 this.commit_tooltip = Some(cx.new(move |cx| {
2135 CommitTooltip::new(
2136 commit_details,
2137 panel_editor_style(true, window, cx),
2138 Some(workspace),
2139 )
2140 }));
2141 cx.notify();
2142 })
2143 })
2144 .detach();
2145
2146 Self {
2147 commit_tooltip: None,
2148 }
2149 })
2150 }
2151}
2152
2153impl Render for GitPanelMessageTooltip {
2154 fn render(&mut self, _window: &mut Window, _cx: &mut Context<'_, Self>) -> impl IntoElement {
2155 if let Some(commit_tooltip) = &self.commit_tooltip {
2156 commit_tooltip.clone().into_any_element()
2157 } else {
2158 gpui::Empty.into_any_element()
2159 }
2160 }
2161}