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