1use crate::git_panel_settings::StatusStyle;
2use crate::{git_panel_settings::GitPanelSettings, git_status_icon};
3use anyhow::Result;
4use db::kvp::KEY_VALUE_STORE;
5use editor::actions::MoveToEnd;
6use editor::scroll::ScrollbarAutoHide;
7use editor::{Editor, EditorMode, EditorSettings, MultiBuffer, ShowScrollbar};
8use futures::channel::mpsc;
9use futures::StreamExt as _;
10use git::repository::RepoPath;
11use git::status::FileStatus;
12use git::{CommitAllChanges, CommitChanges, RevertAll, StageAll, ToggleStaged, UnstageAll};
13use gpui::*;
14use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
15use project::git::RepositoryHandle;
16use project::{Fs, Project, ProjectPath};
17use serde::{Deserialize, Serialize};
18use settings::Settings as _;
19use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
20use theme::ThemeSettings;
21use ui::{
22 prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
23};
24use util::{ResultExt, TryFutureExt};
25use workspace::notifications::{DetachAndPromptErr, NotificationId};
26use workspace::Toast;
27use workspace::{
28 dock::{DockPosition, Panel, PanelEvent},
29 Workspace,
30};
31
32actions!(
33 git_panel,
34 [
35 Close,
36 ToggleFocus,
37 OpenMenu,
38 OpenSelected,
39 FocusEditor,
40 FocusChanges,
41 FillCoAuthors,
42 ]
43);
44
45const GIT_PANEL_KEY: &str = "GitPanel";
46
47const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
48
49pub fn init(cx: &mut App) {
50 cx.observe_new(
51 |workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
52 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
53 workspace.toggle_panel_focus::<GitPanel>(window, cx);
54 });
55 },
56 )
57 .detach();
58}
59
60#[derive(Debug, Clone)]
61pub enum Event {
62 Focus,
63 OpenedEntry { path: ProjectPath },
64}
65
66#[derive(Serialize, Deserialize)]
67struct SerializedGitPanel {
68 width: Option<Pixels>,
69}
70
71#[derive(Debug, PartialEq, Eq, Clone)]
72pub struct GitListEntry {
73 depth: usize,
74 display_name: String,
75 repo_path: RepoPath,
76 status: FileStatus,
77 is_staged: Option<bool>,
78}
79
80pub struct GitPanel {
81 current_modifiers: Modifiers,
82 focus_handle: FocusHandle,
83 fs: Arc<dyn Fs>,
84 hide_scrollbar_task: Option<Task<()>>,
85 pending_serialization: Task<Option<()>>,
86 workspace: WeakEntity<Workspace>,
87 project: Entity<Project>,
88 active_repository: Option<RepositoryHandle>,
89 scroll_handle: UniformListScrollHandle,
90 scrollbar_state: ScrollbarState,
91 selected_entry: Option<usize>,
92 show_scrollbar: bool,
93 update_visible_entries_task: Task<()>,
94 commit_editor: Entity<Editor>,
95 visible_entries: Vec<GitListEntry>,
96 all_staged: Option<bool>,
97 width: Option<Pixels>,
98 err_sender: mpsc::Sender<anyhow::Error>,
99}
100
101fn commit_message_editor(
102 active_repository: Option<&RepositoryHandle>,
103 window: &mut Window,
104 cx: &mut Context<'_, Editor>,
105) -> Editor {
106 let theme = ThemeSettings::get_global(cx);
107
108 let mut text_style = window.text_style();
109 let refinement = TextStyleRefinement {
110 font_family: Some(theme.buffer_font.family.clone()),
111 font_features: Some(FontFeatures::disable_ligatures()),
112 font_size: Some(px(12.).into()),
113 color: Some(cx.theme().colors().editor_foreground),
114 background_color: Some(gpui::transparent_black()),
115 ..Default::default()
116 };
117 text_style.refine(&refinement);
118
119 let mut commit_editor = if let Some(active_repository) = active_repository.as_ref() {
120 let buffer = cx.new(|cx| MultiBuffer::singleton(active_repository.commit_message(), cx));
121 Editor::new(
122 EditorMode::AutoHeight { max_lines: 10 },
123 buffer,
124 None,
125 false,
126 window,
127 cx,
128 )
129 } else {
130 Editor::auto_height(10, window, cx)
131 };
132 commit_editor.set_use_autoclose(false);
133 commit_editor.set_show_gutter(false, cx);
134 commit_editor.set_show_wrap_guides(false, cx);
135 commit_editor.set_show_indent_guides(false, cx);
136 commit_editor.set_text_style_refinement(refinement);
137 commit_editor.set_placeholder_text("Enter commit message", cx);
138 commit_editor
139}
140
141impl GitPanel {
142 pub fn load(
143 workspace: WeakEntity<Workspace>,
144 cx: AsyncWindowContext,
145 ) -> Task<Result<Entity<Self>>> {
146 cx.spawn(|mut cx| async move { workspace.update_in(&mut cx, Self::new) })
147 }
148
149 pub fn new(
150 workspace: &mut Workspace,
151 window: &mut Window,
152 cx: &mut Context<Workspace>,
153 ) -> Entity<Self> {
154 let fs = workspace.app_state().fs.clone();
155 let project = workspace.project().clone();
156 let git_state = project.read(cx).git_state().cloned();
157 let active_repository = project.read(cx).active_repository(cx);
158 let (err_sender, mut err_receiver) = mpsc::channel(1);
159 let workspace = cx.entity().downgrade();
160
161 let git_panel = cx.new(|cx| {
162 let focus_handle = cx.focus_handle();
163 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
164 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
165 this.hide_scrollbar(window, cx);
166 })
167 .detach();
168
169 let commit_editor =
170 cx.new(|cx| commit_message_editor(active_repository.as_ref(), window, cx));
171
172 let scroll_handle = UniformListScrollHandle::new();
173
174 if let Some(git_state) = git_state {
175 cx.subscribe_in(
176 &git_state,
177 window,
178 move |this, git_state, event, window, cx| match event {
179 project::git::Event::RepositoriesUpdated => {
180 this.active_repository = git_state.read(cx).active_repository();
181 this.schedule_update(window, cx);
182 }
183 },
184 )
185 .detach();
186 }
187
188 let mut git_panel = Self {
189 focus_handle: cx.focus_handle(),
190 pending_serialization: Task::ready(None),
191 visible_entries: Vec::new(),
192 all_staged: None,
193 current_modifiers: window.modifiers(),
194 width: Some(px(360.)),
195 scrollbar_state: ScrollbarState::new(scroll_handle.clone())
196 .parent_model(&cx.entity()),
197 selected_entry: None,
198 show_scrollbar: false,
199 hide_scrollbar_task: None,
200 update_visible_entries_task: Task::ready(()),
201 active_repository,
202 scroll_handle,
203 fs,
204 commit_editor,
205 project,
206 err_sender,
207 workspace,
208 };
209 git_panel.schedule_update(window, cx);
210 git_panel.show_scrollbar = git_panel.should_show_scrollbar(cx);
211 git_panel
212 });
213
214 let handle = git_panel.downgrade();
215 cx.spawn(|_, mut cx| async move {
216 while let Some(e) = err_receiver.next().await {
217 let Some(this) = handle.upgrade() else {
218 break;
219 };
220 if this
221 .update(&mut cx, |this, cx| {
222 this.show_err_toast("git operation error", e, cx);
223 })
224 .is_err()
225 {
226 break;
227 }
228 }
229 })
230 .detach();
231
232 cx.subscribe_in(
233 &git_panel,
234 window,
235 move |workspace, _, event: &Event, window, cx| match event.clone() {
236 Event::OpenedEntry { path } => {
237 workspace
238 .open_path_preview(path, None, false, false, window, cx)
239 .detach_and_prompt_err("Failed to open file", window, cx, |e, _, _| {
240 Some(format!("{e}"))
241 });
242 }
243 Event::Focus => { /* TODO */ }
244 },
245 )
246 .detach();
247
248 git_panel
249 }
250
251 fn serialize(&mut self, cx: &mut Context<Self>) {
252 // TODO: we can store stage status here
253 let width = self.width;
254 self.pending_serialization = cx.background_executor().spawn(
255 async move {
256 KEY_VALUE_STORE
257 .write_kvp(
258 GIT_PANEL_KEY.into(),
259 serde_json::to_string(&SerializedGitPanel { width })?,
260 )
261 .await?;
262 anyhow::Ok(())
263 }
264 .log_err(),
265 );
266 }
267
268 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
269 let mut dispatch_context = KeyContext::new_with_defaults();
270 dispatch_context.add("GitPanel");
271
272 if self.is_focused(window, cx) {
273 dispatch_context.add("menu");
274 dispatch_context.add("ChangesList");
275 }
276
277 if self.commit_editor.read(cx).is_focused(window) {
278 dispatch_context.add("CommitEditor");
279 }
280
281 dispatch_context
282 }
283
284 fn is_focused(&self, window: &Window, cx: &Context<Self>) -> bool {
285 window
286 .focused(cx)
287 .map_or(false, |focused| self.focus_handle == focused)
288 }
289
290 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
291 cx.emit(PanelEvent::Close);
292 }
293
294 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
295 if !self.focus_handle.contains_focused(window, cx) {
296 cx.emit(Event::Focus);
297 }
298 }
299
300 fn show_scrollbar(&self, cx: &mut Context<Self>) -> ShowScrollbar {
301 GitPanelSettings::get_global(cx)
302 .scrollbar
303 .show
304 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show)
305 }
306
307 fn should_show_scrollbar(&self, cx: &mut Context<Self>) -> bool {
308 let show = self.show_scrollbar(cx);
309 match show {
310 ShowScrollbar::Auto => true,
311 ShowScrollbar::System => true,
312 ShowScrollbar::Always => true,
313 ShowScrollbar::Never => false,
314 }
315 }
316
317 fn should_autohide_scrollbar(&self, cx: &mut Context<Self>) -> bool {
318 let show = self.show_scrollbar(cx);
319 match show {
320 ShowScrollbar::Auto => true,
321 ShowScrollbar::System => cx
322 .try_global::<ScrollbarAutoHide>()
323 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
324 ShowScrollbar::Always => false,
325 ShowScrollbar::Never => true,
326 }
327 }
328
329 fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
330 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
331 if !self.should_autohide_scrollbar(cx) {
332 return;
333 }
334 self.hide_scrollbar_task = Some(cx.spawn_in(window, |panel, mut cx| async move {
335 cx.background_executor()
336 .timer(SCROLLBAR_SHOW_INTERVAL)
337 .await;
338 panel
339 .update(&mut cx, |panel, cx| {
340 panel.show_scrollbar = false;
341 cx.notify();
342 })
343 .log_err();
344 }))
345 }
346
347 fn handle_modifiers_changed(
348 &mut self,
349 event: &ModifiersChangedEvent,
350 _: &mut Window,
351 cx: &mut Context<Self>,
352 ) {
353 self.current_modifiers = event.modifiers;
354 cx.notify();
355 }
356
357 fn calculate_depth_and_difference(
358 repo_path: &RepoPath,
359 visible_entries: &HashSet<RepoPath>,
360 ) -> (usize, usize) {
361 let ancestors = repo_path.ancestors().skip(1);
362 for ancestor in ancestors {
363 if let Some(parent_entry) = visible_entries.get(ancestor) {
364 let entry_component_count = repo_path.components().count();
365 let parent_component_count = parent_entry.components().count();
366
367 let difference = entry_component_count - parent_component_count;
368
369 let parent_depth = parent_entry
370 .ancestors()
371 .skip(1) // Skip the parent itself
372 .filter(|ancestor| visible_entries.contains(*ancestor))
373 .count();
374
375 return (parent_depth + 1, difference);
376 }
377 }
378
379 (0, 0)
380 }
381
382 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
383 if let Some(selected_entry) = self.selected_entry {
384 self.scroll_handle
385 .scroll_to_item(selected_entry, ScrollStrategy::Center);
386 }
387
388 cx.notify();
389 }
390
391 fn select_first(&mut self, _: &SelectFirst, _window: &mut Window, cx: &mut Context<Self>) {
392 if self.visible_entries.first().is_some() {
393 self.selected_entry = Some(0);
394 self.scroll_to_selected_entry(cx);
395 }
396 }
397
398 fn select_prev(&mut self, _: &SelectPrev, _window: &mut Window, cx: &mut Context<Self>) {
399 let item_count = self.visible_entries.len();
400 if item_count == 0 {
401 return;
402 }
403
404 if let Some(selected_entry) = self.selected_entry {
405 let new_selected_entry = if selected_entry > 0 {
406 selected_entry - 1
407 } else {
408 selected_entry
409 };
410
411 self.selected_entry = Some(new_selected_entry);
412
413 self.scroll_to_selected_entry(cx);
414 }
415
416 cx.notify();
417 }
418
419 fn select_next(&mut self, _: &SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
420 let item_count = self.visible_entries.len();
421 if item_count == 0 {
422 return;
423 }
424
425 if let Some(selected_entry) = self.selected_entry {
426 let new_selected_entry = if selected_entry < item_count - 1 {
427 selected_entry + 1
428 } else {
429 selected_entry
430 };
431
432 self.selected_entry = Some(new_selected_entry);
433
434 self.scroll_to_selected_entry(cx);
435 }
436
437 cx.notify();
438 }
439
440 fn select_last(&mut self, _: &SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
441 if self.visible_entries.last().is_some() {
442 self.selected_entry = Some(self.visible_entries.len() - 1);
443 self.scroll_to_selected_entry(cx);
444 }
445 }
446
447 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
448 self.commit_editor.update(cx, |editor, cx| {
449 window.focus(&editor.focus_handle(cx));
450 });
451 cx.notify();
452 }
453
454 fn select_first_entry_if_none(&mut self, cx: &mut Context<Self>) {
455 let have_entries = self
456 .active_repository
457 .as_ref()
458 .map_or(false, |active_repository| {
459 active_repository.entry_count() > 0
460 });
461 if have_entries && self.selected_entry.is_none() {
462 self.selected_entry = Some(0);
463 self.scroll_to_selected_entry(cx);
464 cx.notify();
465 }
466 }
467
468 fn focus_changes_list(
469 &mut self,
470 _: &FocusChanges,
471 window: &mut Window,
472 cx: &mut Context<Self>,
473 ) {
474 self.select_first_entry_if_none(cx);
475
476 cx.focus_self(window);
477 cx.notify();
478 }
479
480 fn get_selected_entry(&self) -> Option<&GitListEntry> {
481 self.selected_entry
482 .and_then(|i| self.visible_entries.get(i))
483 }
484
485 fn open_selected(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context<Self>) {
486 if let Some(entry) = self
487 .selected_entry
488 .and_then(|i| self.visible_entries.get(i))
489 {
490 self.open_entry(entry, cx);
491 }
492 }
493
494 fn toggle_staged_for_entry(
495 &mut self,
496 entry: &GitListEntry,
497 _window: &mut Window,
498 cx: &mut Context<Self>,
499 ) {
500 let Some(active_repository) = self.active_repository.as_ref() else {
501 return;
502 };
503 let result = if entry.status.is_staged().unwrap_or(false) {
504 active_repository
505 .unstage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
506 } else {
507 active_repository.stage_entries(vec![entry.repo_path.clone()], self.err_sender.clone())
508 };
509 if let Err(e) = result {
510 self.show_err_toast("toggle staged error", e, cx);
511 }
512 cx.notify();
513 }
514
515 fn toggle_staged_for_selected(
516 &mut self,
517 _: &git::ToggleStaged,
518 window: &mut Window,
519 cx: &mut Context<Self>,
520 ) {
521 if let Some(selected_entry) = self.get_selected_entry().cloned() {
522 self.toggle_staged_for_entry(&selected_entry, window, cx);
523 }
524 }
525
526 fn open_entry(&self, entry: &GitListEntry, cx: &mut Context<Self>) {
527 let Some(active_repository) = self.active_repository.as_ref() else {
528 return;
529 };
530 let Some(path) = active_repository.unrelativize(&entry.repo_path) else {
531 return;
532 };
533 let path_exists = self.project.update(cx, |project, cx| {
534 project.entry_for_path(&path, cx).is_some()
535 });
536 if !path_exists {
537 return;
538 }
539 // TODO maybe move all of this into project?
540 cx.emit(Event::OpenedEntry { path });
541 }
542
543 fn stage_all(&mut self, _: &git::StageAll, _window: &mut Window, cx: &mut Context<Self>) {
544 let Some(active_repository) = self.active_repository.as_ref() else {
545 return;
546 };
547 for entry in &mut self.visible_entries {
548 entry.is_staged = Some(true);
549 }
550 self.all_staged = Some(true);
551
552 if let Err(e) = active_repository.stage_all(self.err_sender.clone()) {
553 self.show_err_toast("stage all error", e, cx);
554 };
555 }
556
557 fn unstage_all(&mut self, _: &git::UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
558 let Some(active_repository) = self.active_repository.as_ref() else {
559 return;
560 };
561 for entry in &mut self.visible_entries {
562 entry.is_staged = Some(false);
563 }
564 self.all_staged = Some(false);
565 if let Err(e) = active_repository.unstage_all(self.err_sender.clone()) {
566 self.show_err_toast("unstage all error", e, cx);
567 };
568 }
569
570 fn discard_all(&mut self, _: &git::RevertAll, _window: &mut Window, _cx: &mut Context<Self>) {
571 // TODO: Implement discard all
572 println!("Discard all triggered");
573 }
574
575 /// Commit all staged changes
576 fn commit_changes(
577 &mut self,
578 _: &git::CommitChanges,
579 _window: &mut Window,
580 cx: &mut Context<Self>,
581 ) {
582 let Some(active_repository) = self.active_repository.as_ref() else {
583 return;
584 };
585 if !active_repository.can_commit(false, cx) {
586 return;
587 }
588 active_repository.commit(self.err_sender.clone(), cx);
589 }
590
591 /// Commit all changes, regardless of whether they are staged or not
592 fn commit_all_changes(
593 &mut self,
594 _: &git::CommitAllChanges,
595 _window: &mut Window,
596 cx: &mut Context<Self>,
597 ) {
598 let Some(active_repository) = self.active_repository.as_ref() else {
599 return;
600 };
601 if !active_repository.can_commit(true, cx) {
602 return;
603 }
604 active_repository.commit_all(self.err_sender.clone(), cx);
605 }
606
607 fn fill_co_authors(&mut self, _: &FillCoAuthors, window: &mut Window, cx: &mut Context<Self>) {
608 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
609
610 let Some(room) = self
611 .workspace
612 .upgrade()
613 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
614 else {
615 return;
616 };
617
618 let mut existing_text = self.commit_editor.read(cx).text(cx);
619 existing_text.make_ascii_lowercase();
620 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
621 let mut ends_with_co_authors = false;
622 let existing_co_authors = existing_text
623 .lines()
624 .filter_map(|line| {
625 let line = line.trim();
626 if line.starts_with(&lowercase_co_author_prefix) {
627 ends_with_co_authors = true;
628 Some(line)
629 } else {
630 ends_with_co_authors = false;
631 None
632 }
633 })
634 .collect::<HashSet<_>>();
635
636 let new_co_authors = room
637 .read(cx)
638 .remote_participants()
639 .values()
640 .filter(|participant| participant.can_write())
641 .map(|participant| participant.user.clone())
642 .filter_map(|user| {
643 let email = user.email.as_deref()?;
644 let name = user.name.as_deref().unwrap_or(&user.github_login);
645 Some(format!("{CO_AUTHOR_PREFIX}{name} <{email}>"))
646 })
647 .filter(|co_author| {
648 !existing_co_authors.contains(co_author.to_ascii_lowercase().as_str())
649 })
650 .collect::<Vec<_>>();
651 if new_co_authors.is_empty() {
652 return;
653 }
654
655 self.commit_editor.update(cx, |editor, cx| {
656 let editor_end = editor.buffer().read(cx).read(cx).len();
657 let mut edit = String::new();
658 if !ends_with_co_authors {
659 edit.push('\n');
660 }
661 for co_author in new_co_authors {
662 edit.push('\n');
663 edit.push_str(&co_author);
664 }
665
666 editor.edit(Some((editor_end..editor_end, edit)), cx);
667 editor.move_to_end(&MoveToEnd, window, cx);
668 editor.focus_handle(cx).focus(window);
669 });
670 }
671
672 fn for_each_visible_entry(
673 &self,
674 range: Range<usize>,
675 cx: &mut Context<Self>,
676 mut callback: impl FnMut(usize, GitListEntry, &mut Context<Self>),
677 ) {
678 let visible_entries = &self.visible_entries;
679
680 for (ix, entry) in visible_entries
681 .iter()
682 .enumerate()
683 .skip(range.start)
684 .take(range.end - range.start)
685 {
686 let status = entry.status;
687 let filename = entry
688 .repo_path
689 .file_name()
690 .map(|name| name.to_string_lossy().into_owned())
691 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
692
693 let details = GitListEntry {
694 repo_path: entry.repo_path.clone(),
695 status,
696 depth: 0,
697 display_name: filename,
698 is_staged: entry.is_staged,
699 };
700
701 callback(ix, details, cx);
702 }
703 }
704
705 fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
706 let handle = cx.entity().downgrade();
707 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
708 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
709 if let Some(this) = handle.upgrade() {
710 this.update_in(&mut cx, |this, window, cx| {
711 this.update_visible_entries(cx);
712 let active_repository = this.active_repository.as_ref();
713 this.commit_editor =
714 cx.new(|cx| commit_message_editor(active_repository, window, cx));
715 })
716 .ok();
717 }
718 });
719 }
720
721 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
722 self.visible_entries.clear();
723
724 let Some(repo) = self.active_repository.as_ref() else {
725 // Just clear entries if no repository is active.
726 cx.notify();
727 return;
728 };
729
730 // First pass - collect all paths
731 let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
732
733 // Second pass - create entries with proper depth calculation
734 let mut all_staged = None;
735 for (ix, entry) in repo.status().enumerate() {
736 let (depth, difference) =
737 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
738 let is_staged = entry.status.is_staged();
739 all_staged = if ix == 0 {
740 is_staged
741 } else {
742 match (all_staged, is_staged) {
743 (None, _) | (_, None) => None,
744 (Some(a), Some(b)) => (a == b).then_some(a),
745 }
746 };
747
748 let display_name = if difference > 1 {
749 // Show partial path for deeply nested files
750 entry
751 .repo_path
752 .as_ref()
753 .iter()
754 .skip(entry.repo_path.components().count() - difference)
755 .collect::<PathBuf>()
756 .to_string_lossy()
757 .into_owned()
758 } else {
759 // Just show filename
760 entry
761 .repo_path
762 .file_name()
763 .map(|name| name.to_string_lossy().into_owned())
764 .unwrap_or_default()
765 };
766
767 let entry = GitListEntry {
768 depth,
769 display_name,
770 repo_path: entry.repo_path.clone(),
771 status: entry.status,
772 is_staged,
773 };
774
775 self.visible_entries.push(entry);
776 }
777 self.all_staged = all_staged;
778
779 // Sort entries by path to maintain consistent order
780 self.visible_entries
781 .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
782
783 self.select_first_entry_if_none(cx);
784
785 cx.notify();
786 }
787
788 fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut Context<Self>) {
789 let Some(workspace) = self.workspace.upgrade() else {
790 return;
791 };
792 let notif_id = NotificationId::Named(id.into());
793 let message = e.to_string();
794 workspace.update(cx, |workspace, cx| {
795 let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
796 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
797 });
798 workspace.show_toast(toast, cx);
799 });
800 }
801}
802
803// GitPanel –– Render
804impl GitPanel {
805 pub fn panel_button(
806 &self,
807 id: impl Into<SharedString>,
808 label: impl Into<SharedString>,
809 ) -> Button {
810 let id = id.into().clone();
811 let label = label.into().clone();
812
813 Button::new(id, label)
814 .label_size(LabelSize::Small)
815 .layer(ElevationIndex::ElevatedSurface)
816 .size(ButtonSize::Compact)
817 .style(ButtonStyle::Filled)
818 }
819
820 pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
821 h_flex()
822 .items_center()
823 .h(px(8.))
824 .child(Divider::horizontal_dashed().color(DividerColor::Border))
825 }
826
827 pub fn render_panel_header(
828 &self,
829 window: &mut Window,
830 cx: &mut Context<Self>,
831 ) -> impl IntoElement {
832 let focus_handle = self.focus_handle(cx).clone();
833 let entry_count = self
834 .active_repository
835 .as_ref()
836 .map_or(0, RepositoryHandle::entry_count);
837
838 let changes_string = match entry_count {
839 0 => "No changes".to_string(),
840 1 => "1 change".to_string(),
841 n => format!("{} changes", n),
842 };
843
844 // for our use case treat None as false
845 let all_staged = self.all_staged.unwrap_or(false);
846
847 h_flex()
848 .h(px(32.))
849 .items_center()
850 .px_2()
851 .bg(ElevationIndex::Surface.bg(cx))
852 .child(
853 h_flex()
854 .gap_2()
855 .child(
856 Checkbox::new(
857 "all-changes",
858 if entry_count == 0 {
859 ToggleState::Selected
860 } else {
861 self.all_staged
862 .map_or(ToggleState::Indeterminate, ToggleState::from)
863 },
864 )
865 .fill()
866 .elevation(ElevationIndex::Surface)
867 .tooltip(if all_staged {
868 Tooltip::text("Unstage all changes")
869 } else {
870 Tooltip::text("Stage all changes")
871 })
872 .disabled(entry_count == 0)
873 .on_click(cx.listener(
874 move |git_panel, _, window, cx| match all_staged {
875 true => git_panel.unstage_all(&UnstageAll, window, cx),
876 false => git_panel.stage_all(&StageAll, window, cx),
877 },
878 )),
879 )
880 .child(
881 div()
882 .id("changes-checkbox-label")
883 .text_buffer(cx)
884 .text_ui_sm(cx)
885 .child(changes_string)
886 .on_click(cx.listener(
887 move |git_panel, _, window, cx| match all_staged {
888 true => git_panel.unstage_all(&UnstageAll, window, cx),
889 false => git_panel.stage_all(&StageAll, window, cx),
890 },
891 )),
892 ),
893 )
894 .child(div().flex_grow())
895 .child(
896 h_flex()
897 .gap_2()
898 // TODO: Re-add once revert all is added
899 // .child(
900 // IconButton::new("discard-changes", IconName::Undo)
901 // .tooltip({
902 // let focus_handle = focus_handle.clone();
903 // move |cx| {
904 // Tooltip::for_action_in(
905 // "Discard all changes",
906 // &RevertAll,
907 // &focus_handle,
908 // cx,
909 // )
910 // }
911 // })
912 // .icon_size(IconSize::Small)
913 // .disabled(true),
914 // )
915 .child(if self.all_staged.unwrap_or(false) {
916 self.panel_button("unstage-all", "Unstage All")
917 .tooltip({
918 let focus_handle = focus_handle.clone();
919 move |window, cx| {
920 Tooltip::for_action_in(
921 "Unstage all changes",
922 &UnstageAll,
923 &focus_handle,
924 window,
925 cx,
926 )
927 }
928 })
929 .key_binding(ui::KeyBinding::for_action_in(
930 &UnstageAll,
931 &focus_handle,
932 window,
933 ))
934 .on_click(cx.listener(move |this, _, window, cx| {
935 this.unstage_all(&UnstageAll, window, cx)
936 }))
937 } else {
938 self.panel_button("stage-all", "Stage All")
939 .tooltip({
940 let focus_handle = focus_handle.clone();
941 move |window, cx| {
942 Tooltip::for_action_in(
943 "Stage all changes",
944 &StageAll,
945 &focus_handle,
946 window,
947 cx,
948 )
949 }
950 })
951 .key_binding(ui::KeyBinding::for_action_in(
952 &StageAll,
953 &focus_handle,
954 window,
955 ))
956 .on_click(cx.listener(move |this, _, window, cx| {
957 this.stage_all(&StageAll, window, cx)
958 }))
959 }),
960 )
961 }
962
963 pub fn render_commit_editor(&self, cx: &Context<Self>) -> impl IntoElement {
964 let editor = self.commit_editor.clone();
965 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
966 let (can_commit, can_commit_all) =
967 self.active_repository
968 .as_ref()
969 .map_or((false, false), |active_repository| {
970 (
971 active_repository.can_commit(false, cx),
972 active_repository.can_commit(true, cx),
973 )
974 });
975
976 let focus_handle_1 = self.focus_handle(cx).clone();
977 let focus_handle_2 = self.focus_handle(cx).clone();
978
979 let commit_staged_button = self
980 .panel_button("commit-staged-changes", "Commit")
981 .tooltip(move |window, cx| {
982 let focus_handle = focus_handle_1.clone();
983 Tooltip::for_action_in(
984 "Commit all staged changes",
985 &CommitChanges,
986 &focus_handle,
987 window,
988 cx,
989 )
990 })
991 .disabled(!can_commit)
992 .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
993 this.commit_changes(&CommitChanges, window, cx)
994 }));
995
996 let commit_all_button = self
997 .panel_button("commit-all-changes", "Commit All")
998 .tooltip(move |window, cx| {
999 let focus_handle = focus_handle_2.clone();
1000 Tooltip::for_action_in(
1001 "Commit all changes, including unstaged changes",
1002 &CommitAllChanges,
1003 &focus_handle,
1004 window,
1005 cx,
1006 )
1007 })
1008 .disabled(!can_commit_all)
1009 .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
1010 this.commit_all_changes(&CommitAllChanges, window, cx)
1011 }));
1012
1013 div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
1014 v_flex()
1015 .id("commit-editor-container")
1016 .relative()
1017 .h_full()
1018 .py_2p5()
1019 .px_3()
1020 .bg(cx.theme().colors().editor_background)
1021 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1022 window.focus(&editor_focus_handle);
1023 }))
1024 .child(self.commit_editor.clone())
1025 .child(
1026 h_flex()
1027 .absolute()
1028 .bottom_2p5()
1029 .right_3()
1030 .child(div().gap_1().flex_grow())
1031 .child(if self.current_modifiers.alt {
1032 commit_all_button
1033 } else {
1034 commit_staged_button
1035 }),
1036 ),
1037 )
1038 }
1039
1040 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1041 h_flex()
1042 .h_full()
1043 .flex_1()
1044 .justify_center()
1045 .items_center()
1046 .child(
1047 v_flex()
1048 .gap_3()
1049 .child("No changes to commit")
1050 .text_ui_sm(cx)
1051 .mx_auto()
1052 .text_color(Color::Placeholder.color(cx)),
1053 )
1054 }
1055
1056 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1057 let scroll_bar_style = self.show_scrollbar(cx);
1058 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1059
1060 if !self.should_show_scrollbar(cx)
1061 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1062 {
1063 return None;
1064 }
1065
1066 Some(
1067 div()
1068 .id("git-panel-vertical-scroll")
1069 .occlude()
1070 .flex_none()
1071 .h_full()
1072 .cursor_default()
1073 .when(show_container, |this| this.pl_1().px_1p5())
1074 .when(!show_container, |this| {
1075 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1076 })
1077 .on_mouse_move(cx.listener(|_, _, _, cx| {
1078 cx.notify();
1079 cx.stop_propagation()
1080 }))
1081 .on_hover(|_, _, cx| {
1082 cx.stop_propagation();
1083 })
1084 .on_any_mouse_down(|_, _, cx| {
1085 cx.stop_propagation();
1086 })
1087 .on_mouse_up(
1088 MouseButton::Left,
1089 cx.listener(|this, _, window, cx| {
1090 if !this.scrollbar_state.is_dragging()
1091 && !this.focus_handle.contains_focused(window, cx)
1092 {
1093 this.hide_scrollbar(window, cx);
1094 cx.notify();
1095 }
1096
1097 cx.stop_propagation();
1098 }),
1099 )
1100 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1101 cx.notify();
1102 }))
1103 .children(Scrollbar::vertical(
1104 // percentage as f32..end_offset as f32,
1105 self.scrollbar_state.clone(),
1106 )),
1107 )
1108 }
1109
1110 fn render_entries(&self, cx: &mut Context<Self>) -> impl IntoElement {
1111 let entry_count = self.visible_entries.len();
1112
1113 h_flex()
1114 .size_full()
1115 .overflow_hidden()
1116 .child(
1117 uniform_list(cx.entity().clone(), "entries", entry_count, {
1118 move |git_panel, range, _window, cx| {
1119 let mut items = Vec::with_capacity(range.end - range.start);
1120 git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
1121 items.push(git_panel.render_entry(ix, details, cx));
1122 });
1123 items
1124 }
1125 })
1126 .size_full()
1127 .with_sizing_behavior(ListSizingBehavior::Infer)
1128 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1129 // .with_width_from_item(self.max_width_item_index)
1130 .track_scroll(self.scroll_handle.clone()),
1131 )
1132 .children(self.render_scrollbar(cx))
1133 }
1134
1135 fn render_entry(
1136 &self,
1137 ix: usize,
1138 entry_details: GitListEntry,
1139 cx: &Context<Self>,
1140 ) -> impl IntoElement {
1141 let repo_path = entry_details.repo_path.clone();
1142 let selected = self.selected_entry == Some(ix);
1143 let status_style = GitPanelSettings::get_global(cx).status_style;
1144 let status = entry_details.status;
1145
1146 let mut label_color = cx.theme().colors().text;
1147 if status_style == StatusStyle::LabelColor {
1148 label_color = if status.is_conflicted() {
1149 cx.theme().colors().version_control_conflict
1150 } else if status.is_modified() {
1151 cx.theme().colors().version_control_modified
1152 } else if status.is_deleted() {
1153 // Don't use `version_control_deleted` here or all the
1154 // deleted entries will be likely a red color.
1155 cx.theme().colors().text_disabled
1156 } else {
1157 cx.theme().colors().version_control_added
1158 }
1159 }
1160
1161 let path_color = status
1162 .is_deleted()
1163 .then_some(cx.theme().colors().text_disabled)
1164 .unwrap_or(cx.theme().colors().text_muted);
1165
1166 let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1167 let checkbox_id =
1168 ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
1169 let is_tree_view = false;
1170 let handle = cx.entity().downgrade();
1171
1172 let end_slot = h_flex()
1173 .invisible()
1174 .when(selected, |this| this.visible())
1175 .when(!selected, |this| {
1176 this.group_hover("git-panel-entry", |this| this.visible())
1177 })
1178 .gap_1()
1179 .items_center()
1180 .child(
1181 IconButton::new("more", IconName::EllipsisVertical)
1182 .icon_color(Color::Placeholder)
1183 .icon_size(IconSize::Small),
1184 );
1185
1186 let mut entry = h_flex()
1187 .id(entry_id)
1188 .group("git-panel-entry")
1189 .h(px(28.))
1190 .w_full()
1191 .pr(px(4.))
1192 .items_center()
1193 .gap_2()
1194 .font_buffer(cx)
1195 .text_ui_sm(cx)
1196 .when(!selected, |this| {
1197 this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
1198 });
1199
1200 if is_tree_view {
1201 entry = entry.pl(px(8. + 12. * entry_details.depth as f32))
1202 } else {
1203 entry = entry.pl(px(8.))
1204 }
1205
1206 if selected {
1207 entry = entry.bg(cx.theme().status().info_background);
1208 }
1209
1210 entry = entry
1211 .child(
1212 Checkbox::new(
1213 checkbox_id,
1214 entry_details
1215 .is_staged
1216 .map_or(ToggleState::Indeterminate, ToggleState::from),
1217 )
1218 .fill()
1219 .elevation(ElevationIndex::Surface)
1220 .on_click({
1221 let handle = handle.clone();
1222 let repo_path = repo_path.clone();
1223 move |toggle, _window, cx| {
1224 let Some(this) = handle.upgrade() else {
1225 return;
1226 };
1227 this.update(cx, |this, cx| {
1228 this.visible_entries[ix].is_staged = match *toggle {
1229 ToggleState::Selected => Some(true),
1230 ToggleState::Unselected => Some(false),
1231 ToggleState::Indeterminate => None,
1232 };
1233 let repo_path = repo_path.clone();
1234 let Some(active_repository) = this.active_repository.as_ref() else {
1235 return;
1236 };
1237 let result = match toggle {
1238 ToggleState::Selected | ToggleState::Indeterminate => {
1239 active_repository
1240 .stage_entries(vec![repo_path], this.err_sender.clone())
1241 }
1242 ToggleState::Unselected => active_repository
1243 .unstage_entries(vec![repo_path], this.err_sender.clone()),
1244 };
1245 if let Err(e) = result {
1246 this.show_err_toast("toggle staged error", e, cx);
1247 }
1248 });
1249 }
1250 }),
1251 )
1252 .when(status_style == StatusStyle::Icon, |this| {
1253 this.child(git_status_icon(status, cx))
1254 })
1255 .child(
1256 h_flex()
1257 .text_color(label_color)
1258 .when(status.is_deleted(), |this| this.line_through())
1259 .when_some(repo_path.parent(), |this, parent| {
1260 let parent_str = parent.to_string_lossy();
1261 if !parent_str.is_empty() {
1262 this.child(
1263 div()
1264 .text_color(path_color)
1265 .child(format!("{}/", parent_str)),
1266 )
1267 } else {
1268 this
1269 }
1270 })
1271 .child(div().child(entry_details.display_name.clone())),
1272 )
1273 .child(div().flex_1())
1274 .child(end_slot)
1275 .on_click(move |_, window, cx| {
1276 // TODO: add `select_entry` method then do after that
1277 window.dispatch_action(Box::new(OpenSelected), cx);
1278
1279 handle
1280 .update(cx, |git_panel, _| {
1281 git_panel.selected_entry = Some(ix);
1282 })
1283 .ok();
1284 });
1285
1286 entry
1287 }
1288}
1289
1290impl Render for GitPanel {
1291 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1292 let project = self.project.read(cx);
1293 let has_entries = self
1294 .active_repository
1295 .as_ref()
1296 .map_or(false, |active_repository| {
1297 active_repository.entry_count() > 0
1298 });
1299 let has_co_authors = self
1300 .workspace
1301 .upgrade()
1302 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
1303 .map(|room| {
1304 let room = room.read(cx);
1305 room.local_participant().can_write()
1306 && room
1307 .remote_participants()
1308 .values()
1309 .any(|remote_participant| remote_participant.can_write())
1310 })
1311 .unwrap_or(false);
1312
1313 v_flex()
1314 .id("git_panel")
1315 .key_context(self.dispatch_context(window, cx))
1316 .track_focus(&self.focus_handle)
1317 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1318 .when(!project.is_read_only(cx), |this| {
1319 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1320 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1321 }))
1322 .on_action(
1323 cx.listener(|this, &StageAll, window, cx| {
1324 this.stage_all(&StageAll, window, cx)
1325 }),
1326 )
1327 .on_action(cx.listener(|this, &UnstageAll, window, cx| {
1328 this.unstage_all(&UnstageAll, window, cx)
1329 }))
1330 .on_action(cx.listener(|this, &RevertAll, window, cx| {
1331 this.discard_all(&RevertAll, window, cx)
1332 }))
1333 .on_action(cx.listener(|this, &CommitChanges, window, cx| {
1334 this.commit_changes(&CommitChanges, window, cx)
1335 }))
1336 .on_action(cx.listener(|this, &CommitAllChanges, window, cx| {
1337 this.commit_all_changes(&CommitAllChanges, window, cx)
1338 }))
1339 })
1340 .when(self.is_focused(window, cx), |this| {
1341 this.on_action(cx.listener(Self::select_first))
1342 .on_action(cx.listener(Self::select_next))
1343 .on_action(cx.listener(Self::select_prev))
1344 .on_action(cx.listener(Self::select_last))
1345 .on_action(cx.listener(Self::close_panel))
1346 })
1347 .on_action(cx.listener(Self::open_selected))
1348 .on_action(cx.listener(Self::focus_changes_list))
1349 .on_action(cx.listener(Self::focus_editor))
1350 .on_action(cx.listener(Self::toggle_staged_for_selected))
1351 .when(has_co_authors, |git_panel| {
1352 git_panel.on_action(cx.listener(Self::fill_co_authors))
1353 })
1354 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1355 .on_hover(cx.listener(|this, hovered, window, cx| {
1356 if *hovered {
1357 this.show_scrollbar = true;
1358 this.hide_scrollbar_task.take();
1359 cx.notify();
1360 } else if !this.focus_handle.contains_focused(window, cx) {
1361 this.hide_scrollbar(window, cx);
1362 }
1363 }))
1364 .size_full()
1365 .overflow_hidden()
1366 .font_buffer(cx)
1367 .py_1()
1368 .bg(ElevationIndex::Surface.bg(cx))
1369 .child(self.render_panel_header(window, cx))
1370 .child(self.render_divider(cx))
1371 .child(if has_entries {
1372 self.render_entries(cx).into_any_element()
1373 } else {
1374 self.render_empty_state(cx).into_any_element()
1375 })
1376 .child(self.render_divider(cx))
1377 .child(self.render_commit_editor(cx))
1378 }
1379}
1380
1381impl Focusable for GitPanel {
1382 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1383 self.focus_handle.clone()
1384 }
1385}
1386
1387impl EventEmitter<Event> for GitPanel {}
1388
1389impl EventEmitter<PanelEvent> for GitPanel {}
1390
1391impl Panel for GitPanel {
1392 fn persistent_name() -> &'static str {
1393 "GitPanel"
1394 }
1395
1396 fn position(&self, _: &Window, cx: &App) -> DockPosition {
1397 GitPanelSettings::get_global(cx).dock
1398 }
1399
1400 fn position_is_valid(&self, position: DockPosition) -> bool {
1401 matches!(position, DockPosition::Left | DockPosition::Right)
1402 }
1403
1404 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1405 settings::update_settings_file::<GitPanelSettings>(
1406 self.fs.clone(),
1407 cx,
1408 move |settings, _| settings.dock = Some(position),
1409 );
1410 }
1411
1412 fn size(&self, _: &Window, cx: &App) -> Pixels {
1413 self.width
1414 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1415 }
1416
1417 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1418 self.width = size;
1419 self.serialize(cx);
1420 cx.notify();
1421 }
1422
1423 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
1424 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1425 }
1426
1427 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
1428 Some("Git Panel")
1429 }
1430
1431 fn toggle_action(&self) -> Box<dyn Action> {
1432 Box::new(ToggleFocus)
1433 }
1434
1435 fn activation_priority(&self) -> u32 {
1436 2
1437 }
1438}