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