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 has_write_access: bool,
831 cx: &mut Context<Self>,
832 ) -> impl IntoElement {
833 let focus_handle = self.focus_handle(cx).clone();
834 let entry_count = self
835 .active_repository
836 .as_ref()
837 .map_or(0, RepositoryHandle::entry_count);
838
839 let changes_string = match entry_count {
840 0 => "No changes".to_string(),
841 1 => "1 change".to_string(),
842 n => format!("{} changes", n),
843 };
844
845 // for our use case treat None as false
846 let all_staged = self.all_staged.unwrap_or(false);
847
848 h_flex()
849 .h(px(32.))
850 .items_center()
851 .px_2()
852 .bg(ElevationIndex::Surface.bg(cx))
853 .child(
854 h_flex()
855 .gap_2()
856 .child(
857 Checkbox::new(
858 "all-changes",
859 if entry_count == 0 {
860 ToggleState::Selected
861 } else {
862 self.all_staged
863 .map_or(ToggleState::Indeterminate, ToggleState::from)
864 },
865 )
866 .fill()
867 .elevation(ElevationIndex::Surface)
868 .tooltip(if all_staged {
869 Tooltip::text("Unstage all changes")
870 } else {
871 Tooltip::text("Stage all changes")
872 })
873 .disabled(!has_write_access || entry_count == 0)
874 .on_click(cx.listener(
875 move |git_panel, _, window, cx| match all_staged {
876 true => git_panel.unstage_all(&UnstageAll, window, cx),
877 false => git_panel.stage_all(&StageAll, window, cx),
878 },
879 )),
880 )
881 .child(
882 div()
883 .id("changes-checkbox-label")
884 .text_buffer(cx)
885 .text_ui_sm(cx)
886 .child(changes_string)
887 .on_click(cx.listener(
888 move |git_panel, _, window, cx| match all_staged {
889 true => git_panel.unstage_all(&UnstageAll, window, cx),
890 false => git_panel.stage_all(&StageAll, window, cx),
891 },
892 )),
893 ),
894 )
895 .child(div().flex_grow())
896 .child(
897 h_flex()
898 .gap_2()
899 // TODO: Re-add once revert all is added
900 // .child(
901 // IconButton::new("discard-changes", IconName::Undo)
902 // .tooltip({
903 // let focus_handle = focus_handle.clone();
904 // move |cx| {
905 // Tooltip::for_action_in(
906 // "Discard all changes",
907 // &RevertAll,
908 // &focus_handle,
909 // cx,
910 // )
911 // }
912 // })
913 // .icon_size(IconSize::Small)
914 // .disabled(true),
915 // )
916 .child(if self.all_staged.unwrap_or(false) {
917 self.panel_button("unstage-all", "Unstage All")
918 .tooltip({
919 let focus_handle = focus_handle.clone();
920 move |window, cx| {
921 Tooltip::for_action_in(
922 "Unstage all changes",
923 &UnstageAll,
924 &focus_handle,
925 window,
926 cx,
927 )
928 }
929 })
930 .key_binding(ui::KeyBinding::for_action_in(
931 &UnstageAll,
932 &focus_handle,
933 window,
934 ))
935 .on_click(cx.listener(move |this, _, window, cx| {
936 this.unstage_all(&UnstageAll, window, cx)
937 }))
938 } else {
939 self.panel_button("stage-all", "Stage All")
940 .tooltip({
941 let focus_handle = focus_handle.clone();
942 move |window, cx| {
943 Tooltip::for_action_in(
944 "Stage all changes",
945 &StageAll,
946 &focus_handle,
947 window,
948 cx,
949 )
950 }
951 })
952 .key_binding(ui::KeyBinding::for_action_in(
953 &StageAll,
954 &focus_handle,
955 window,
956 ))
957 .on_click(cx.listener(move |this, _, window, cx| {
958 this.stage_all(&StageAll, window, cx)
959 }))
960 }),
961 )
962 }
963
964 pub fn render_commit_editor(
965 &self,
966 has_write_access: bool,
967 cx: &Context<Self>,
968 ) -> impl IntoElement {
969 let editor = self.commit_editor.clone();
970 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
971 let (can_commit, can_commit_all) =
972 self.active_repository
973 .as_ref()
974 .map_or((false, false), |active_repository| {
975 (
976 has_write_access && active_repository.can_commit(false, cx),
977 has_write_access && active_repository.can_commit(true, cx),
978 )
979 });
980
981 let focus_handle_1 = self.focus_handle(cx).clone();
982 let focus_handle_2 = self.focus_handle(cx).clone();
983
984 let commit_staged_button = self
985 .panel_button("commit-staged-changes", "Commit")
986 .tooltip(move |window, cx| {
987 let focus_handle = focus_handle_1.clone();
988 Tooltip::for_action_in(
989 "Commit all staged changes",
990 &CommitChanges,
991 &focus_handle,
992 window,
993 cx,
994 )
995 })
996 .disabled(!can_commit)
997 .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
998 this.commit_changes(&CommitChanges, window, cx)
999 }));
1000
1001 let commit_all_button = self
1002 .panel_button("commit-all-changes", "Commit All")
1003 .tooltip(move |window, cx| {
1004 let focus_handle = focus_handle_2.clone();
1005 Tooltip::for_action_in(
1006 "Commit all changes, including unstaged changes",
1007 &CommitAllChanges,
1008 &focus_handle,
1009 window,
1010 cx,
1011 )
1012 })
1013 .disabled(!can_commit_all)
1014 .on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
1015 this.commit_all_changes(&CommitAllChanges, window, cx)
1016 }));
1017
1018 div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
1019 v_flex()
1020 .id("commit-editor-container")
1021 .relative()
1022 .h_full()
1023 .py_2p5()
1024 .px_3()
1025 .bg(cx.theme().colors().editor_background)
1026 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
1027 window.focus(&editor_focus_handle);
1028 }))
1029 .child(self.commit_editor.clone())
1030 .child(
1031 h_flex()
1032 .absolute()
1033 .bottom_2p5()
1034 .right_3()
1035 .child(div().gap_1().flex_grow())
1036 .child(if self.current_modifiers.alt {
1037 commit_all_button
1038 } else {
1039 commit_staged_button
1040 }),
1041 ),
1042 )
1043 }
1044
1045 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1046 h_flex()
1047 .h_full()
1048 .flex_1()
1049 .justify_center()
1050 .items_center()
1051 .child(
1052 v_flex()
1053 .gap_3()
1054 .child("No changes to commit")
1055 .text_ui_sm(cx)
1056 .mx_auto()
1057 .text_color(Color::Placeholder.color(cx)),
1058 )
1059 }
1060
1061 fn render_scrollbar(&self, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
1062 let scroll_bar_style = self.show_scrollbar(cx);
1063 let show_container = matches!(scroll_bar_style, ShowScrollbar::Always);
1064
1065 if !self.should_show_scrollbar(cx)
1066 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
1067 {
1068 return None;
1069 }
1070
1071 Some(
1072 div()
1073 .id("git-panel-vertical-scroll")
1074 .occlude()
1075 .flex_none()
1076 .h_full()
1077 .cursor_default()
1078 .when(show_container, |this| this.pl_1().px_1p5())
1079 .when(!show_container, |this| {
1080 this.absolute().right_1().top_1().bottom_1().w(px(12.))
1081 })
1082 .on_mouse_move(cx.listener(|_, _, _, cx| {
1083 cx.notify();
1084 cx.stop_propagation()
1085 }))
1086 .on_hover(|_, _, cx| {
1087 cx.stop_propagation();
1088 })
1089 .on_any_mouse_down(|_, _, cx| {
1090 cx.stop_propagation();
1091 })
1092 .on_mouse_up(
1093 MouseButton::Left,
1094 cx.listener(|this, _, window, cx| {
1095 if !this.scrollbar_state.is_dragging()
1096 && !this.focus_handle.contains_focused(window, cx)
1097 {
1098 this.hide_scrollbar(window, cx);
1099 cx.notify();
1100 }
1101
1102 cx.stop_propagation();
1103 }),
1104 )
1105 .on_scroll_wheel(cx.listener(|_, _, _, cx| {
1106 cx.notify();
1107 }))
1108 .children(Scrollbar::vertical(
1109 // percentage as f32..end_offset as f32,
1110 self.scrollbar_state.clone(),
1111 )),
1112 )
1113 }
1114
1115 fn render_entries(&self, has_write_access: bool, cx: &mut Context<Self>) -> impl IntoElement {
1116 let entry_count = self.visible_entries.len();
1117
1118 h_flex()
1119 .size_full()
1120 .overflow_hidden()
1121 .child(
1122 uniform_list(cx.entity().clone(), "entries", entry_count, {
1123 move |git_panel, range, _window, cx| {
1124 let mut items = Vec::with_capacity(range.end - range.start);
1125 git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
1126 items.push(git_panel.render_entry(ix, details, has_write_access, cx));
1127 });
1128 items
1129 }
1130 })
1131 .size_full()
1132 .with_sizing_behavior(ListSizingBehavior::Infer)
1133 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
1134 // .with_width_from_item(self.max_width_item_index)
1135 .track_scroll(self.scroll_handle.clone()),
1136 )
1137 .children(self.render_scrollbar(cx))
1138 }
1139
1140 fn render_entry(
1141 &self,
1142 ix: usize,
1143 entry_details: GitListEntry,
1144 has_write_access: bool,
1145 cx: &Context<Self>,
1146 ) -> impl IntoElement {
1147 let repo_path = entry_details.repo_path.clone();
1148 let selected = self.selected_entry == Some(ix);
1149 let status_style = GitPanelSettings::get_global(cx).status_style;
1150 let status = entry_details.status;
1151
1152 let mut label_color = cx.theme().colors().text;
1153 if status_style == StatusStyle::LabelColor {
1154 label_color = if status.is_conflicted() {
1155 cx.theme().colors().version_control_conflict
1156 } else if status.is_modified() {
1157 cx.theme().colors().version_control_modified
1158 } else if status.is_deleted() {
1159 // Don't use `version_control_deleted` here or all the
1160 // deleted entries will be likely a red color.
1161 cx.theme().colors().text_disabled
1162 } else {
1163 cx.theme().colors().version_control_added
1164 }
1165 }
1166
1167 let path_color = status
1168 .is_deleted()
1169 .then_some(cx.theme().colors().text_disabled)
1170 .unwrap_or(cx.theme().colors().text_muted);
1171
1172 let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1173 let checkbox_id =
1174 ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
1175 let is_tree_view = false;
1176 let handle = cx.entity().downgrade();
1177
1178 let end_slot = h_flex()
1179 .invisible()
1180 .when(selected, |this| this.visible())
1181 .when(!selected, |this| {
1182 this.group_hover("git-panel-entry", |this| this.visible())
1183 })
1184 .gap_1()
1185 .items_center()
1186 .child(
1187 IconButton::new("more", IconName::EllipsisVertical)
1188 .icon_color(Color::Placeholder)
1189 .icon_size(IconSize::Small),
1190 );
1191
1192 let mut entry = h_flex()
1193 .id(entry_id)
1194 .group("git-panel-entry")
1195 .h(px(28.))
1196 .w_full()
1197 .pr(px(4.))
1198 .items_center()
1199 .gap_2()
1200 .font_buffer(cx)
1201 .text_ui_sm(cx)
1202 .when(!selected, |this| {
1203 this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
1204 });
1205
1206 if is_tree_view {
1207 entry = entry.pl(px(8. + 12. * entry_details.depth as f32))
1208 } else {
1209 entry = entry.pl(px(8.))
1210 }
1211
1212 if selected {
1213 entry = entry.bg(cx.theme().status().info_background);
1214 }
1215
1216 entry = entry
1217 .child(
1218 Checkbox::new(
1219 checkbox_id,
1220 entry_details
1221 .is_staged
1222 .map_or(ToggleState::Indeterminate, ToggleState::from),
1223 )
1224 .disabled(!has_write_access)
1225 .fill()
1226 .elevation(ElevationIndex::Surface)
1227 .on_click({
1228 let handle = handle.clone();
1229 let repo_path = repo_path.clone();
1230 move |toggle, _window, cx| {
1231 let Some(this) = handle.upgrade() else {
1232 return;
1233 };
1234 this.update(cx, |this, cx| {
1235 this.visible_entries[ix].is_staged = match *toggle {
1236 ToggleState::Selected => Some(true),
1237 ToggleState::Unselected => Some(false),
1238 ToggleState::Indeterminate => None,
1239 };
1240 let repo_path = repo_path.clone();
1241 let Some(active_repository) = this.active_repository.as_ref() else {
1242 return;
1243 };
1244 let result = match toggle {
1245 ToggleState::Selected | ToggleState::Indeterminate => {
1246 active_repository
1247 .stage_entries(vec![repo_path], this.err_sender.clone())
1248 }
1249 ToggleState::Unselected => active_repository
1250 .unstage_entries(vec![repo_path], this.err_sender.clone()),
1251 };
1252 if let Err(e) = result {
1253 this.show_err_toast("toggle staged error", e, cx);
1254 }
1255 });
1256 }
1257 }),
1258 )
1259 .when(status_style == StatusStyle::Icon, |this| {
1260 this.child(git_status_icon(status, cx))
1261 })
1262 .child(
1263 h_flex()
1264 .text_color(label_color)
1265 .when(status.is_deleted(), |this| this.line_through())
1266 .when_some(repo_path.parent(), |this, parent| {
1267 let parent_str = parent.to_string_lossy();
1268 if !parent_str.is_empty() {
1269 this.child(
1270 div()
1271 .text_color(path_color)
1272 .child(format!("{}/", parent_str)),
1273 )
1274 } else {
1275 this
1276 }
1277 })
1278 .child(div().child(entry_details.display_name.clone())),
1279 )
1280 .child(div().flex_1())
1281 .child(end_slot)
1282 .on_click(move |_, window, cx| {
1283 // TODO: add `select_entry` method then do after that
1284 window.dispatch_action(Box::new(OpenSelected), cx);
1285
1286 handle
1287 .update(cx, |git_panel, _| {
1288 git_panel.selected_entry = Some(ix);
1289 })
1290 .ok();
1291 });
1292
1293 entry
1294 }
1295}
1296
1297impl Render for GitPanel {
1298 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1299 let project = self.project.read(cx);
1300 let has_entries = self
1301 .active_repository
1302 .as_ref()
1303 .map_or(false, |active_repository| {
1304 active_repository.entry_count() > 0
1305 });
1306 let room = self
1307 .workspace
1308 .upgrade()
1309 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
1310
1311 let has_write_access = room
1312 .as_ref()
1313 .map_or(true, |room| room.read(cx).local_participant().can_write());
1314
1315 let has_co_authors = room.map_or(false, |room| {
1316 has_write_access
1317 && room
1318 .read(cx)
1319 .remote_participants()
1320 .values()
1321 .any(|remote_participant| remote_participant.can_write())
1322 });
1323
1324 v_flex()
1325 .id("git_panel")
1326 .key_context(self.dispatch_context(window, cx))
1327 .track_focus(&self.focus_handle)
1328 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1329 .when(!project.is_read_only(cx), |this| {
1330 this.on_action(cx.listener(|this, &ToggleStaged, window, cx| {
1331 this.toggle_staged_for_selected(&ToggleStaged, window, cx)
1332 }))
1333 .on_action(
1334 cx.listener(|this, &StageAll, window, cx| {
1335 this.stage_all(&StageAll, window, cx)
1336 }),
1337 )
1338 .on_action(cx.listener(|this, &UnstageAll, window, cx| {
1339 this.unstage_all(&UnstageAll, window, cx)
1340 }))
1341 .on_action(cx.listener(|this, &RevertAll, window, cx| {
1342 this.discard_all(&RevertAll, window, cx)
1343 }))
1344 .on_action(cx.listener(|this, &CommitChanges, window, cx| {
1345 this.commit_changes(&CommitChanges, window, cx)
1346 }))
1347 .on_action(cx.listener(|this, &CommitAllChanges, window, cx| {
1348 this.commit_all_changes(&CommitAllChanges, window, cx)
1349 }))
1350 })
1351 .when(self.is_focused(window, cx), |this| {
1352 this.on_action(cx.listener(Self::select_first))
1353 .on_action(cx.listener(Self::select_next))
1354 .on_action(cx.listener(Self::select_prev))
1355 .on_action(cx.listener(Self::select_last))
1356 .on_action(cx.listener(Self::close_panel))
1357 })
1358 .on_action(cx.listener(Self::open_selected))
1359 .on_action(cx.listener(Self::focus_changes_list))
1360 .on_action(cx.listener(Self::focus_editor))
1361 .on_action(cx.listener(Self::toggle_staged_for_selected))
1362 .when(has_co_authors, |git_panel| {
1363 git_panel.on_action(cx.listener(Self::fill_co_authors))
1364 })
1365 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1366 .on_hover(cx.listener(|this, hovered, window, cx| {
1367 if *hovered {
1368 this.show_scrollbar = true;
1369 this.hide_scrollbar_task.take();
1370 cx.notify();
1371 } else if !this.focus_handle.contains_focused(window, cx) {
1372 this.hide_scrollbar(window, cx);
1373 }
1374 }))
1375 .size_full()
1376 .overflow_hidden()
1377 .font_buffer(cx)
1378 .py_1()
1379 .bg(ElevationIndex::Surface.bg(cx))
1380 .child(self.render_panel_header(window, has_write_access, cx))
1381 .child(self.render_divider(cx))
1382 .child(if has_entries {
1383 self.render_entries(has_write_access, cx).into_any_element()
1384 } else {
1385 self.render_empty_state(cx).into_any_element()
1386 })
1387 .child(self.render_divider(cx))
1388 .child(self.render_commit_editor(has_write_access, cx))
1389 }
1390}
1391
1392impl Focusable for GitPanel {
1393 fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
1394 self.focus_handle.clone()
1395 }
1396}
1397
1398impl EventEmitter<Event> for GitPanel {}
1399
1400impl EventEmitter<PanelEvent> for GitPanel {}
1401
1402impl Panel for GitPanel {
1403 fn persistent_name() -> &'static str {
1404 "GitPanel"
1405 }
1406
1407 fn position(&self, _: &Window, cx: &App) -> DockPosition {
1408 GitPanelSettings::get_global(cx).dock
1409 }
1410
1411 fn position_is_valid(&self, position: DockPosition) -> bool {
1412 matches!(position, DockPosition::Left | DockPosition::Right)
1413 }
1414
1415 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
1416 settings::update_settings_file::<GitPanelSettings>(
1417 self.fs.clone(),
1418 cx,
1419 move |settings, _| settings.dock = Some(position),
1420 );
1421 }
1422
1423 fn size(&self, _: &Window, cx: &App) -> Pixels {
1424 self.width
1425 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1426 }
1427
1428 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
1429 self.width = size;
1430 self.serialize(cx);
1431 cx.notify();
1432 }
1433
1434 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
1435 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1436 }
1437
1438 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
1439 Some("Git Panel")
1440 }
1441
1442 fn toggle_action(&self) -> Box<dyn Action> {
1443 Box::new(ToggleFocus)
1444 }
1445
1446 fn activation_priority(&self) -> u32 {
1447 2
1448 }
1449}