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