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