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 name_and_email: Option<(SharedString, SharedString)>,
580 _window: &mut Window,
581 cx: &mut Context<Self>,
582 ) {
583 let Some(active_repository) = self.active_repository.as_ref() else {
584 return;
585 };
586 if !active_repository.can_commit(false, cx) {
587 return;
588 }
589 active_repository.commit(name_and_email, self.err_sender.clone(), cx);
590 }
591
592 /// Commit all changes, regardless of whether they are staged or not
593 fn commit_all_changes(
594 &mut self,
595 _: &git::CommitAllChanges,
596 name_and_email: Option<(SharedString, SharedString)>,
597 _window: &mut Window,
598 cx: &mut Context<Self>,
599 ) {
600 let Some(active_repository) = self.active_repository.as_ref() else {
601 return;
602 };
603 if !active_repository.can_commit(true, cx) {
604 return;
605 }
606 active_repository.commit_all(name_and_email, self.err_sender.clone(), cx);
607 }
608
609 fn fill_co_authors(&mut self, _: &FillCoAuthors, window: &mut Window, cx: &mut Context<Self>) {
610 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
611
612 let Some(room) = self
613 .workspace
614 .upgrade()
615 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
616 else {
617 return;
618 };
619
620 let mut existing_text = self.commit_editor.read(cx).text(cx);
621 existing_text.make_ascii_lowercase();
622 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
623 let mut ends_with_co_authors = false;
624 let existing_co_authors = existing_text
625 .lines()
626 .filter_map(|line| {
627 let line = line.trim();
628 if line.starts_with(&lowercase_co_author_prefix) {
629 ends_with_co_authors = true;
630 Some(line)
631 } else {
632 ends_with_co_authors = false;
633 None
634 }
635 })
636 .collect::<HashSet<_>>();
637
638 let new_co_authors = room
639 .read(cx)
640 .remote_participants()
641 .values()
642 .filter(|participant| participant.can_write())
643 .map(|participant| participant.user.as_ref())
644 .filter_map(|user| {
645 let email = user.email.as_deref()?;
646 let name = user.name.as_deref().unwrap_or(&user.github_login);
647 Some(format!("{CO_AUTHOR_PREFIX}{name} <{email}>"))
648 })
649 .filter(|co_author| {
650 !existing_co_authors.contains(co_author.to_ascii_lowercase().as_str())
651 })
652 .collect::<Vec<_>>();
653 if new_co_authors.is_empty() {
654 return;
655 }
656
657 self.commit_editor.update(cx, |editor, cx| {
658 let editor_end = editor.buffer().read(cx).read(cx).len();
659 let mut edit = String::new();
660 if !ends_with_co_authors {
661 edit.push('\n');
662 }
663 for co_author in new_co_authors {
664 edit.push('\n');
665 edit.push_str(&co_author);
666 }
667
668 editor.edit(Some((editor_end..editor_end, edit)), cx);
669 editor.move_to_end(&MoveToEnd, window, cx);
670 editor.focus_handle(cx).focus(window);
671 });
672 }
673
674 fn for_each_visible_entry(
675 &self,
676 range: Range<usize>,
677 cx: &mut Context<Self>,
678 mut callback: impl FnMut(usize, GitListEntry, &mut Context<Self>),
679 ) {
680 let visible_entries = &self.visible_entries;
681
682 for (ix, entry) in visible_entries
683 .iter()
684 .enumerate()
685 .skip(range.start)
686 .take(range.end - range.start)
687 {
688 let status = entry.status;
689 let filename = entry
690 .repo_path
691 .file_name()
692 .map(|name| name.to_string_lossy().into_owned())
693 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
694
695 let details = GitListEntry {
696 repo_path: entry.repo_path.clone(),
697 status,
698 depth: 0,
699 display_name: filename,
700 is_staged: entry.is_staged,
701 };
702
703 callback(ix, details, cx);
704 }
705 }
706
707 fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
708 let handle = cx.entity().downgrade();
709 self.update_visible_entries_task = cx.spawn_in(window, |_, mut cx| async move {
710 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
711 if let Some(this) = handle.upgrade() {
712 this.update_in(&mut cx, |this, window, cx| {
713 this.update_visible_entries(cx);
714 let active_repository = this.active_repository.as_ref();
715 this.commit_editor =
716 cx.new(|cx| commit_message_editor(active_repository, window, cx));
717 })
718 .ok();
719 }
720 });
721 }
722
723 fn update_visible_entries(&mut self, cx: &mut Context<Self>) {
724 self.visible_entries.clear();
725
726 let Some(repo) = self.active_repository.as_ref() else {
727 // Just clear entries if no repository is active.
728 cx.notify();
729 return;
730 };
731
732 // First pass - collect all paths
733 let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
734
735 // Second pass - create entries with proper depth calculation
736 let mut all_staged = None;
737 for (ix, entry) in repo.status().enumerate() {
738 let (depth, difference) =
739 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
740 let is_staged = entry.status.is_staged();
741 all_staged = if ix == 0 {
742 is_staged
743 } else {
744 match (all_staged, is_staged) {
745 (None, _) | (_, None) => None,
746 (Some(a), Some(b)) => (a == b).then_some(a),
747 }
748 };
749
750 let display_name = if difference > 1 {
751 // Show partial path for deeply nested files
752 entry
753 .repo_path
754 .as_ref()
755 .iter()
756 .skip(entry.repo_path.components().count() - difference)
757 .collect::<PathBuf>()
758 .to_string_lossy()
759 .into_owned()
760 } else {
761 // Just show filename
762 entry
763 .repo_path
764 .file_name()
765 .map(|name| name.to_string_lossy().into_owned())
766 .unwrap_or_default()
767 };
768
769 let entry = GitListEntry {
770 depth,
771 display_name,
772 repo_path: entry.repo_path.clone(),
773 status: entry.status,
774 is_staged,
775 };
776
777 self.visible_entries.push(entry);
778 }
779 self.all_staged = all_staged;
780
781 // Sort entries by path to maintain consistent order
782 self.visible_entries
783 .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
784
785 self.select_first_entry_if_none(cx);
786
787 cx.notify();
788 }
789
790 fn show_err_toast(&self, id: &'static str, e: anyhow::Error, cx: &mut Context<Self>) {
791 let Some(workspace) = self.workspace.upgrade() else {
792 return;
793 };
794 let notif_id = NotificationId::Named(id.into());
795 let message = e.to_string();
796 workspace.update(cx, |workspace, cx| {
797 let toast = Toast::new(notif_id, message).on_click("Open Zed Log", |window, cx| {
798 window.dispatch_action(workspace::OpenLog.boxed_clone(), cx);
799 });
800 workspace.show_toast(toast, cx);
801 });
802 }
803}
804
805// GitPanel –– Render
806impl GitPanel {
807 pub fn panel_button(
808 &self,
809 id: impl Into<SharedString>,
810 label: impl Into<SharedString>,
811 ) -> Button {
812 let id = id.into().clone();
813 let label = label.into().clone();
814
815 Button::new(id, label)
816 .label_size(LabelSize::Small)
817 .layer(ElevationIndex::ElevatedSurface)
818 .size(ButtonSize::Compact)
819 .style(ButtonStyle::Filled)
820 }
821
822 pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
823 h_flex()
824 .items_center()
825 .h(px(8.))
826 .child(Divider::horizontal_dashed().color(DividerColor::Border))
827 }
828
829 pub fn render_panel_header(
830 &self,
831 window: &mut Window,
832 has_write_access: bool,
833 cx: &mut Context<Self>,
834 ) -> impl IntoElement {
835 let focus_handle = self.focus_handle(cx).clone();
836 let entry_count = self
837 .active_repository
838 .as_ref()
839 .map_or(0, RepositoryHandle::entry_count);
840
841 let changes_string = match entry_count {
842 0 => "No changes".to_string(),
843 1 => "1 change".to_string(),
844 n => format!("{} changes", n),
845 };
846
847 // for our use case treat None as false
848 let all_staged = self.all_staged.unwrap_or(false);
849
850 h_flex()
851 .h(px(32.))
852 .items_center()
853 .px_2()
854 .bg(ElevationIndex::Surface.bg(cx))
855 .child(
856 h_flex()
857 .gap_2()
858 .child(
859 Checkbox::new(
860 "all-changes",
861 if entry_count == 0 {
862 ToggleState::Selected
863 } else {
864 self.all_staged
865 .map_or(ToggleState::Indeterminate, ToggleState::from)
866 },
867 )
868 .fill()
869 .elevation(ElevationIndex::Surface)
870 .tooltip(if all_staged {
871 Tooltip::text("Unstage all changes")
872 } else {
873 Tooltip::text("Stage all changes")
874 })
875 .disabled(!has_write_access || entry_count == 0)
876 .on_click(cx.listener(
877 move |git_panel, _, window, cx| match all_staged {
878 true => git_panel.unstage_all(&UnstageAll, window, cx),
879 false => git_panel.stage_all(&StageAll, window, cx),
880 },
881 )),
882 )
883 .child(
884 div()
885 .id("changes-checkbox-label")
886 .text_buffer(cx)
887 .text_ui_sm(cx)
888 .child(changes_string)
889 .on_click(cx.listener(
890 move |git_panel, _, window, cx| match all_staged {
891 true => git_panel.unstage_all(&UnstageAll, window, cx),
892 false => git_panel.stage_all(&StageAll, window, cx),
893 },
894 )),
895 ),
896 )
897 .child(div().flex_grow())
898 .child(
899 h_flex()
900 .gap_2()
901 // TODO: Re-add once revert all is added
902 // .child(
903 // IconButton::new("discard-changes", IconName::Undo)
904 // .tooltip({
905 // let focus_handle = focus_handle.clone();
906 // move |cx| {
907 // Tooltip::for_action_in(
908 // "Discard all changes",
909 // &RevertAll,
910 // &focus_handle,
911 // cx,
912 // )
913 // }
914 // })
915 // .icon_size(IconSize::Small)
916 // .disabled(true),
917 // )
918 .child(if self.all_staged.unwrap_or(false) {
919 self.panel_button("unstage-all", "Unstage All")
920 .tooltip({
921 let focus_handle = focus_handle.clone();
922 move |window, cx| {
923 Tooltip::for_action_in(
924 "Unstage all changes",
925 &UnstageAll,
926 &focus_handle,
927 window,
928 cx,
929 )
930 }
931 })
932 .key_binding(ui::KeyBinding::for_action_in(
933 &UnstageAll,
934 &focus_handle,
935 window,
936 ))
937 .on_click(cx.listener(move |this, _, window, cx| {
938 this.unstage_all(&UnstageAll, window, cx)
939 }))
940 } else {
941 self.panel_button("stage-all", "Stage All")
942 .tooltip({
943 let focus_handle = focus_handle.clone();
944 move |window, cx| {
945 Tooltip::for_action_in(
946 "Stage all changes",
947 &StageAll,
948 &focus_handle,
949 window,
950 cx,
951 )
952 }
953 })
954 .key_binding(ui::KeyBinding::for_action_in(
955 &StageAll,
956 &focus_handle,
957 window,
958 ))
959 .on_click(cx.listener(move |this, _, window, cx| {
960 this.stage_all(&StageAll, window, cx)
961 }))
962 }),
963 )
964 }
965
966 pub fn render_commit_editor(
967 &self,
968 name_and_email: Option<(SharedString, SharedString)>,
969 can_commit: bool,
970 cx: &Context<Self>,
971 ) -> impl IntoElement {
972 let editor = self.commit_editor.clone();
973 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
974 let (can_commit, can_commit_all) =
975 self.active_repository
976 .as_ref()
977 .map_or((false, false), |active_repository| {
978 (
979 can_commit && active_repository.can_commit(false, cx),
980 can_commit && active_repository.can_commit(true, cx),
981 )
982 });
983
984 let focus_handle_1 = self.focus_handle(cx).clone();
985 let focus_handle_2 = self.focus_handle(cx).clone();
986
987 let commit_staged_button = self
988 .panel_button("commit-staged-changes", "Commit")
989 .tooltip(move |window, cx| {
990 let focus_handle = focus_handle_1.clone();
991 Tooltip::for_action_in(
992 "Commit all staged changes",
993 &CommitChanges,
994 &focus_handle,
995 window,
996 cx,
997 )
998 })
999 .disabled(!can_commit)
1000 .on_click({
1001 let name_and_email = name_and_email.clone();
1002 cx.listener(move |this, _: &ClickEvent, window, cx| {
1003 this.commit_changes(&CommitChanges, name_and_email.clone(), window, cx)
1004 })
1005 });
1006
1007 let commit_all_button = self
1008 .panel_button("commit-all-changes", "Commit All")
1009 .tooltip(move |window, cx| {
1010 let focus_handle = focus_handle_2.clone();
1011 Tooltip::for_action_in(
1012 "Commit all changes, including unstaged changes",
1013 &CommitAllChanges,
1014 &focus_handle,
1015 window,
1016 cx,
1017 )
1018 })
1019 .disabled(!can_commit_all)
1020 .on_click({
1021 let name_and_email = name_and_email.clone();
1022 cx.listener(move |this, _: &ClickEvent, window, cx| {
1023 this.commit_all_changes(&CommitAllChanges, name_and_email.clone(), window, cx)
1024 })
1025 });
1026
1027 div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
1028 v_flex()
1029 .id("commit-editor-container")
1030 .relative()
1031 .h_full()
1032 .py_2p5()
1033 .px_3()
1034 .bg(cx.theme().colors().editor_background)
1035 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1036 window.focus(&editor_focus_handle);
1037 }))
1038 .child(self.commit_editor.clone())
1039 .child(
1040 h_flex()
1041 .absolute()
1042 .bottom_2p5()
1043 .right_3()
1044 .child(div().gap_1().flex_grow())
1045 .child(if self.current_modifiers.alt {
1046 commit_all_button
1047 } else {
1048 commit_staged_button
1049 }),
1050 ),
1051 )
1052 }
1053
1054 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1055 h_flex()
1056 .h_full()
1057 .flex_1()
1058 .justify_center()
1059 .items_center()
1060 .child(
1061 v_flex()
1062 .gap_3()
1063 .child("No changes to commit")
1064 .text_ui_sm(cx)
1065 .mx_auto()
1066 .text_color(Color::Placeholder.color(cx)),
1067 )
1068 }
1069
1070 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1071 let scroll_bar_style = self.show_scrollbar(cx);
1072 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1073
1074 if !self.should_show_scrollbar(cx)
1075 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1076 {
1077 return None;
1078 }
1079
1080 Some(
1081 div()
1082 .id("git-panel-vertical-scroll")
1083 .occlude()
1084 .flex_none()
1085 .h_full()
1086 .cursor_default()
1087 .when(show_container, |this| this.pl_1().px_1p5())
1088 .when(!show_container, |this| {
1089 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1090 })
1091 .on_mouse_move(cx.listener(|_, _, _, cx| {
1092 cx.notify();
1093 cx.stop_propagation()
1094 }))
1095 .on_hover(|_, _, cx| {
1096 cx.stop_propagation();
1097 })
1098 .on_any_mouse_down(|_, _, cx| {
1099 cx.stop_propagation();
1100 })
1101 .on_mouse_up(
1102 MouseButton::Left,
1103 cx.listener(|this, _, window, cx| {
1104 if !this.scrollbar_state.is_dragging()
1105 && !this.focus_handle.contains_focused(window, cx)
1106 {
1107 this.hide_scrollbar(window, cx);
1108 cx.notify();
1109 }
1110
1111 cx.stop_propagation();
1112 }),
1113 )
1114 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1115 cx.notify();
1116 }))
1117 .children(Scrollbar::vertical(
1118 // percentage as f32..end_offset as f32,
1119 self.scrollbar_state.clone(),
1120 )),
1121 )
1122 }
1123
1124 fn render_entries(&self, has_write_access: bool, cx: &mut Context<Self>) -> impl IntoElement {
1125 let entry_count = self.visible_entries.len();
1126
1127 h_flex()
1128 .size_full()
1129 .overflow_hidden()
1130 .child(
1131 uniform_list(cx.entity().clone(), "entries", entry_count, {
1132 move |git_panel, range, _window, cx| {
1133 let mut items = Vec::with_capacity(range.end - range.start);
1134 git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
1135 items.push(git_panel.render_entry(ix, details, has_write_access, cx));
1136 });
1137 items
1138 }
1139 })
1140 .size_full()
1141 .with_sizing_behavior(ListSizingBehavior::Infer)
1142 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1143 // .with_width_from_item(self.max_width_item_index)
1144 .track_scroll(self.scroll_handle.clone()),
1145 )
1146 .children(self.render_scrollbar(cx))
1147 }
1148
1149 fn render_entry(
1150 &self,
1151 ix: usize,
1152 entry_details: GitListEntry,
1153 has_write_access: bool,
1154 cx: &Context<Self>,
1155 ) -> impl IntoElement {
1156 let repo_path = entry_details.repo_path.clone();
1157 let selected = self.selected_entry == Some(ix);
1158 let status_style = GitPanelSettings::get_global(cx).status_style;
1159 let status = entry_details.status;
1160
1161 let mut label_color = cx.theme().colors().text;
1162 if status_style == StatusStyle::LabelColor {
1163 label_color = if status.is_conflicted() {
1164 cx.theme().colors().version_control_conflict
1165 } else if status.is_modified() {
1166 cx.theme().colors().version_control_modified
1167 } else if status.is_deleted() {
1168 // Don't use `version_control_deleted` here or all the
1169 // deleted entries will be likely a red color.
1170 cx.theme().colors().text_disabled
1171 } else {
1172 cx.theme().colors().version_control_added
1173 }
1174 }
1175
1176 let path_color = status
1177 .is_deleted()
1178 .then_some(cx.theme().colors().text_disabled)
1179 .unwrap_or(cx.theme().colors().text_muted);
1180
1181 let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1182 let checkbox_id =
1183 ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
1184 let is_tree_view = false;
1185 let handle = cx.entity().downgrade();
1186
1187 let end_slot = h_flex()
1188 .invisible()
1189 .when(selected, |this| this.visible())
1190 .when(!selected, |this| {
1191 this.group_hover("git-panel-entry", |this| this.visible())
1192 })
1193 .gap_1()
1194 .items_center()
1195 .child(
1196 IconButton::new("more", IconName::EllipsisVertical)
1197 .icon_color(Color::Placeholder)
1198 .icon_size(IconSize::Small),
1199 );
1200
1201 let mut entry = h_flex()
1202 .id(entry_id)
1203 .group("git-panel-entry")
1204 .h(px(28.))
1205 .w_full()
1206 .pr(px(4.))
1207 .items_center()
1208 .gap_2()
1209 .font_buffer(cx)
1210 .text_ui_sm(cx)
1211 .when(!selected, |this| {
1212 this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
1213 });
1214
1215 if is_tree_view {
1216 entry = entry.pl(px(8. + 12. * entry_details.depth as f32))
1217 } else {
1218 entry = entry.pl(px(8.))
1219 }
1220
1221 if selected {
1222 entry = entry.bg(cx.theme().status().info_background);
1223 }
1224
1225 entry = entry
1226 .child(
1227 Checkbox::new(
1228 checkbox_id,
1229 entry_details
1230 .is_staged
1231 .map_or(ToggleState::Indeterminate, ToggleState::from),
1232 )
1233 .disabled(!has_write_access)
1234 .fill()
1235 .elevation(ElevationIndex::Surface)
1236 .on_click({
1237 let handle = handle.clone();
1238 let repo_path = repo_path.clone();
1239 move |toggle, _window, cx| {
1240 let Some(this) = handle.upgrade() else {
1241 return;
1242 };
1243 this.update(cx, |this, cx| {
1244 this.visible_entries[ix].is_staged = match *toggle {
1245 ToggleState::Selected => Some(true),
1246 ToggleState::Unselected => Some(false),
1247 ToggleState::Indeterminate => None,
1248 };
1249 let repo_path = repo_path.clone();
1250 let Some(active_repository) = this.active_repository.as_ref() else {
1251 return;
1252 };
1253 let result = match toggle {
1254 ToggleState::Selected | ToggleState::Indeterminate => {
1255 active_repository
1256 .stage_entries(vec![repo_path], this.err_sender.clone())
1257 }
1258 ToggleState::Unselected => active_repository
1259 .unstage_entries(vec![repo_path], this.err_sender.clone()),
1260 };
1261 if let Err(e) = result {
1262 this.show_err_toast("toggle staged error", e, cx);
1263 }
1264 });
1265 }
1266 }),
1267 )
1268 .when(status_style == StatusStyle::Icon, |this| {
1269 this.child(git_status_icon(status, cx))
1270 })
1271 .child(
1272 h_flex()
1273 .text_color(label_color)
1274 .when(status.is_deleted(), |this| this.line_through())
1275 .when_some(repo_path.parent(), |this, parent| {
1276 let parent_str = parent.to_string_lossy();
1277 if !parent_str.is_empty() {
1278 this.child(
1279 div()
1280 .text_color(path_color)
1281 .child(format!("{}/", parent_str)),
1282 )
1283 } else {
1284 this
1285 }
1286 })
1287 .child(div().child(entry_details.display_name.clone())),
1288 )
1289 .child(div().flex_1())
1290 .child(end_slot)
1291 .on_click(move |_, window, cx| {
1292 // TODO: add `select_entry` method then do after that
1293 window.dispatch_action(Box::new(OpenSelected), cx);
1294
1295 handle
1296 .update(cx, |git_panel, _| {
1297 git_panel.selected_entry = Some(ix);
1298 })
1299 .ok();
1300 });
1301
1302 entry
1303 }
1304}
1305
1306impl Render for GitPanel {
1307 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1308 let project = self.project.read(cx);
1309 let has_entries = self
1310 .active_repository
1311 .as_ref()
1312 .map_or(false, |active_repository| {
1313 active_repository.entry_count() > 0
1314 });
1315 let room = self
1316 .workspace
1317 .upgrade()
1318 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1319
1320 let has_write_access = room
1321 .as_ref()
1322 .map_or(true, |room| room.read(cx).local_participant().can_write());
1323 let (can_commit, name_and_email) = match &room {
1324 Some(room) => {
1325 if project.is_via_collab() {
1326 if has_write_access {
1327 let name_and_email =
1328 room.read(cx).local_participant_user(cx).and_then(|user| {
1329 let email = SharedString::from(user.email.clone()?);
1330 let name = user
1331 .name
1332 .clone()
1333 .map(SharedString::from)
1334 .unwrap_or(SharedString::from(user.github_login.clone()));
1335 Some((name, email))
1336 });
1337 (name_and_email.is_some(), name_and_email)
1338 } else {
1339 (false, None)
1340 }
1341 } else {
1342 (has_write_access, None)
1343 }
1344 }
1345 None => (has_write_access, None),
1346 };
1347
1348 let has_co_authors = can_commit
1349 && has_write_access
1350 && room.map_or(false, |room| {
1351 room.read(cx)
1352 .remote_participants()
1353 .values()
1354 .any(|remote_participant| remote_participant.can_write())
1355 });
1356
1357 v_flex()
1358 .id("git_panel")
1359 .key_context(self.dispatch_context(window, cx))
1360 .track_focus(&self.focus_handle)
1361 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1362 .when(has_write_access && !project.is_read_only(cx), |this| {
1363 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1364 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1365 }))
1366 .on_action(
1367 cx.listener(|this, &StageAll, window, cx| {
1368 this.stage_all(&StageAll, window, cx)
1369 }),
1370 )
1371 .on_action(cx.listener(|this, &UnstageAll, window, cx| {
1372 this.unstage_all(&UnstageAll, window, cx)
1373 }))
1374 .on_action(cx.listener(|this, &RevertAll, window, cx| {
1375 this.discard_all(&RevertAll, window, cx)
1376 }))
1377 .when(can_commit, |git_panel| {
1378 git_panel
1379 .on_action({
1380 let name_and_email = name_and_email.clone();
1381 cx.listener(move |git_panel, &CommitChanges, window, cx| {
1382 git_panel.commit_changes(
1383 &CommitChanges,
1384 name_and_email.clone(),
1385 window,
1386 cx,
1387 )
1388 })
1389 })
1390 .on_action({
1391 let name_and_email = name_and_email.clone();
1392 cx.listener(move |git_panel, &CommitAllChanges, window, cx| {
1393 git_panel.commit_all_changes(
1394 &CommitAllChanges,
1395 name_and_email.clone(),
1396 window,
1397 cx,
1398 )
1399 })
1400 })
1401 })
1402 })
1403 .when(self.is_focused(window, cx), |this| {
1404 this.on_action(cx.listener(Self::select_first))
1405 .on_action(cx.listener(Self::select_next))
1406 .on_action(cx.listener(Self::select_prev))
1407 .on_action(cx.listener(Self::select_last))
1408 .on_action(cx.listener(Self::close_panel))
1409 })
1410 .on_action(cx.listener(Self::open_selected))
1411 .on_action(cx.listener(Self::focus_changes_list))
1412 .on_action(cx.listener(Self::focus_editor))
1413 .on_action(cx.listener(Self::toggle_staged_for_selected))
1414 .when(has_co_authors, |git_panel| {
1415 git_panel.on_action(cx.listener(Self::fill_co_authors))
1416 })
1417 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1418 .on_hover(cx.listener(|this, hovered, window, cx| {
1419 if *hovered {
1420 this.show_scrollbar = true;
1421 this.hide_scrollbar_task.take();
1422 cx.notify();
1423 } else if !this.focus_handle.contains_focused(window, cx) {
1424 this.hide_scrollbar(window, cx);
1425 }
1426 }))
1427 .size_full()
1428 .overflow_hidden()
1429 .font_buffer(cx)
1430 .py_1()
1431 .bg(ElevationIndex::Surface.bg(cx))
1432 .child(self.render_panel_header(window, has_write_access, cx))
1433 .child(self.render_divider(cx))
1434 .child(if has_entries {
1435 self.render_entries(has_write_access, cx).into_any_element()
1436 } else {
1437 self.render_empty_state(cx).into_any_element()
1438 })
1439 .child(self.render_divider(cx))
1440 .child(self.render_commit_editor(name_and_email, can_commit, cx))
1441 }
1442}
1443
1444impl Focusable for GitPanel {
1445 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1446 self.focus_handle.clone()
1447 }
1448}
1449
1450impl EventEmitter<Event> for GitPanel {}
1451
1452impl EventEmitter<PanelEvent> for GitPanel {}
1453
1454impl Panel for GitPanel {
1455 fn persistent_name() -> &'static str {
1456 "GitPanel"
1457 }
1458
1459 fn position(&self, _: &Window, cx: &App) -> DockPosition {
1460 GitPanelSettings::get_global(cx).dock
1461 }
1462
1463 fn position_is_valid(&self, position: DockPosition) -> bool {
1464 matches!(position, DockPosition::Left | DockPosition::Right)
1465 }
1466
1467 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1468 settings::update_settings_file::<GitPanelSettings>(
1469 self.fs.clone(),
1470 cx,
1471 move |settings, _| settings.dock = Some(position),
1472 );
1473 }
1474
1475 fn size(&self, _: &Window, cx: &App) -> Pixels {
1476 self.width
1477 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1478 }
1479
1480 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1481 self.width = size;
1482 self.serialize(cx);
1483 cx.notify();
1484 }
1485
1486 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
1487 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1488 }
1489
1490 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
1491 Some("Git Panel")
1492 }
1493
1494 fn toggle_action(&self) -> Box<dyn Action> {
1495 Box::new(ToggleFocus)
1496 }
1497
1498 fn activation_priority(&self) -> u32 {
1499 2
1500 }
1501}