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