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