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