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