git_panel.rs

  1use anyhow::Result;
  2use collections::HashMap;
  3use db::kvp::KEY_VALUE_STORE;
  4use git::repository::GitFileStatus;
  5use gpui::{
  6    actions, prelude::*, uniform_list, Action, AppContext, AsyncWindowContext, ClickEvent,
  7    CursorStyle, EventEmitter, FocusHandle, FocusableView, KeyContext,
  8    ListHorizontalSizingBehavior, ListSizingBehavior, Model, Modifiers, ModifiersChangedEvent,
  9    MouseButton, Stateful, Task, UniformListScrollHandle, View, WeakView,
 10};
 11use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, WorktreeId};
 12use serde::{Deserialize, Serialize};
 13use settings::Settings as _;
 14use std::{
 15    cell::OnceCell,
 16    collections::HashSet,
 17    ffi::OsStr,
 18    ops::Range,
 19    path::{Path, PathBuf},
 20    sync::Arc,
 21    time::Duration,
 22};
 23use ui::{
 24    prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
 25};
 26use util::{ResultExt, TryFutureExt};
 27use workspace::dock::{DockPosition, Panel, PanelEvent};
 28use workspace::Workspace;
 29
 30use crate::{git_status_icon, settings::GitPanelSettings};
 31use crate::{CommitAllChanges, CommitStagedChanges, DiscardAll, StageAll, UnstageAll};
 32
 33actions!(git_panel, [ToggleFocus]);
 34
 35const GIT_PANEL_KEY: &str = "GitPanel";
 36
 37pub fn init(cx: &mut AppContext) {
 38    cx.observe_new_views(
 39        |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
 40            workspace.register_action(|workspace, _: &ToggleFocus, cx| {
 41                workspace.toggle_panel_focus::<GitPanel>(cx);
 42            });
 43        },
 44    )
 45    .detach();
 46}
 47
 48#[derive(Debug)]
 49pub enum Event {
 50    Focus,
 51}
 52
 53pub struct GitStatusEntry {}
 54
 55#[derive(Debug, PartialEq, Eq, Clone)]
 56struct EntryDetails {
 57    filename: String,
 58    display_name: String,
 59    path: Arc<Path>,
 60    kind: EntryKind,
 61    depth: usize,
 62    is_expanded: bool,
 63    status: Option<GitFileStatus>,
 64}
 65
 66impl EntryDetails {
 67    pub fn is_dir(&self) -> bool {
 68        self.kind.is_dir()
 69    }
 70}
 71
 72#[derive(Serialize, Deserialize)]
 73struct SerializedGitPanel {
 74    width: Option<Pixels>,
 75}
 76
 77pub struct GitPanel {
 78    _workspace: WeakView<Workspace>,
 79    current_modifiers: Modifiers,
 80    focus_handle: FocusHandle,
 81    fs: Arc<dyn Fs>,
 82    hide_scrollbar_task: Option<Task<()>>,
 83    pending_serialization: Task<Option<()>>,
 84    project: Model<Project>,
 85    scroll_handle: UniformListScrollHandle,
 86    scrollbar_state: ScrollbarState,
 87    selected_item: Option<usize>,
 88    show_scrollbar: bool,
 89    expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
 90
 91    // The entries that are currently shown in the panel, aka
 92    // not hidden by folding or such
 93    visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
 94    width: Option<Pixels>,
 95}
 96
 97impl GitPanel {
 98    pub fn load(
 99        workspace: WeakView<Workspace>,
100        cx: AsyncWindowContext,
101    ) -> Task<Result<View<Self>>> {
102        cx.spawn(|mut cx| async move {
103            // Clippy incorrectly classifies this as a redundant closure
104            #[allow(clippy::redundant_closure)]
105            workspace.update(&mut cx, |workspace, cx| Self::new(workspace, cx))
106        })
107    }
108
109    pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
110        let fs = workspace.app_state().fs.clone();
111        let weak_workspace = workspace.weak_handle();
112        let project = workspace.project().clone();
113
114        let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
115            let focus_handle = cx.focus_handle();
116            cx.on_focus(&focus_handle, Self::focus_in).detach();
117            cx.on_focus_out(&focus_handle, |this, _, cx| {
118                this.hide_scrollbar(cx);
119            })
120            .detach();
121            cx.subscribe(&project, |this, _project, event, cx| match event {
122                project::Event::WorktreeRemoved(id) => {
123                    this.expanded_dir_ids.remove(id);
124                    this.update_visible_entries(None, cx);
125                    cx.notify();
126                }
127                project::Event::WorktreeUpdatedEntries(_, _)
128                | project::Event::WorktreeAdded(_)
129                | project::Event::WorktreeOrderChanged => {
130                    this.update_visible_entries(None, cx);
131                    cx.notify();
132                }
133                _ => {}
134            })
135            .detach();
136
137            let scroll_handle = UniformListScrollHandle::new();
138
139            let mut this = Self {
140                _workspace: weak_workspace,
141                focus_handle: cx.focus_handle(),
142                fs,
143                pending_serialization: Task::ready(None),
144                project,
145                visible_entries: Vec::new(),
146                current_modifiers: cx.modifiers(),
147                expanded_dir_ids: Default::default(),
148
149                width: Some(px(360.)),
150                scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
151                scroll_handle,
152                selected_item: None,
153                show_scrollbar: !Self::should_autohide_scrollbar(cx),
154                hide_scrollbar_task: None,
155            };
156            this.update_visible_entries(None, cx);
157            this
158        });
159
160        git_panel
161    }
162
163    fn serialize(&mut self, cx: &mut ViewContext<Self>) {
164        let width = self.width;
165        self.pending_serialization = cx.background_executor().spawn(
166            async move {
167                KEY_VALUE_STORE
168                    .write_kvp(
169                        GIT_PANEL_KEY.into(),
170                        serde_json::to_string(&SerializedGitPanel { width })?,
171                    )
172                    .await?;
173                anyhow::Ok(())
174            }
175            .log_err(),
176        );
177    }
178
179    fn dispatch_context(&self) -> KeyContext {
180        let mut dispatch_context = KeyContext::new_with_defaults();
181        dispatch_context.add("GitPanel");
182        dispatch_context.add("menu");
183
184        dispatch_context
185    }
186
187    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
188        if !self.focus_handle.contains_focused(cx) {
189            cx.emit(Event::Focus);
190        }
191    }
192
193    fn should_show_scrollbar(_cx: &AppContext) -> bool {
194        // TODO: plug into settings
195        true
196    }
197
198    fn should_autohide_scrollbar(_cx: &AppContext) -> bool {
199        // TODO: plug into settings
200        true
201    }
202
203    fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
204        const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
205        if !Self::should_autohide_scrollbar(cx) {
206            return;
207        }
208        self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
209            cx.background_executor()
210                .timer(SCROLLBAR_SHOW_INTERVAL)
211                .await;
212            panel
213                .update(&mut cx, |panel, cx| {
214                    panel.show_scrollbar = false;
215                    cx.notify();
216                })
217                .log_err();
218        }))
219    }
220
221    fn handle_modifiers_changed(
222        &mut self,
223        event: &ModifiersChangedEvent,
224        cx: &mut ViewContext<Self>,
225    ) {
226        self.current_modifiers = event.modifiers;
227        cx.notify();
228    }
229
230    fn calculate_depth_and_difference(
231        entry: &Entry,
232        visible_worktree_entries: &HashSet<Arc<Path>>,
233    ) -> (usize, usize) {
234        let (depth, difference) = entry
235            .path
236            .ancestors()
237            .skip(1) // Skip the entry itself
238            .find_map(|ancestor| {
239                if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
240                    let entry_path_components_count = entry.path.components().count();
241                    let parent_path_components_count = parent_entry.components().count();
242                    let difference = entry_path_components_count - parent_path_components_count;
243                    let depth = parent_entry
244                        .ancestors()
245                        .skip(1)
246                        .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
247                        .count();
248                    Some((depth + 1, difference))
249                } else {
250                    None
251                }
252            })
253            .unwrap_or((0, 0));
254
255        (depth, difference)
256    }
257}
258
259impl GitPanel {
260    fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
261        // TODO: Implement stage all
262        println!("Stage all triggered");
263    }
264
265    fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
266        // TODO: Implement unstage all
267        println!("Unstage all triggered");
268    }
269
270    fn discard_all(&mut self, _: &DiscardAll, _cx: &mut ViewContext<Self>) {
271        // TODO: Implement discard all
272        println!("Discard all triggered");
273    }
274
275    /// Commit all staged changes
276    fn commit_staged_changes(&mut self, _: &CommitStagedChanges, _cx: &mut ViewContext<Self>) {
277        // TODO: Implement commit all staged
278        println!("Commit staged changes triggered");
279    }
280
281    /// Commit all changes, regardless of whether they are staged or not
282    fn commit_all_changes(&mut self, _: &CommitAllChanges, _cx: &mut ViewContext<Self>) {
283        // TODO: Implement commit all changes
284        println!("Commit all changes triggered");
285    }
286
287    fn all_staged(&self) -> bool {
288        // TODO: Implement all_staged
289        true
290    }
291
292    fn no_entries(&self) -> bool {
293        self.visible_entries.is_empty()
294    }
295
296    fn entry_count(&self) -> usize {
297        self.visible_entries
298            .iter()
299            .map(|(_, entries, _)| {
300                entries
301                    .iter()
302                    .filter(|entry| entry.git_status.is_some())
303                    .count()
304            })
305            .sum()
306    }
307
308    fn for_each_visible_entry(
309        &self,
310        range: Range<usize>,
311        cx: &mut ViewContext<Self>,
312        mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<Self>),
313    ) {
314        let mut ix = 0;
315        for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
316            if ix >= range.end {
317                return;
318            }
319
320            if ix + visible_worktree_entries.len() <= range.start {
321                ix += visible_worktree_entries.len();
322                continue;
323            }
324
325            let end_ix = range.end.min(ix + visible_worktree_entries.len());
326            // let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
327            if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
328                let snapshot = worktree.read(cx).snapshot();
329                let root_name = OsStr::new(snapshot.root_name());
330                let expanded_entry_ids = self
331                    .expanded_dir_ids
332                    .get(&snapshot.id())
333                    .map(Vec::as_slice)
334                    .unwrap_or(&[]);
335
336                let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
337                let entries = entries_paths.get_or_init(|| {
338                    visible_worktree_entries
339                        .iter()
340                        .map(|e| (e.path.clone()))
341                        .collect()
342                });
343
344                for entry in visible_worktree_entries[entry_range].iter() {
345                    let status = entry.git_status;
346                    let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
347
348                    let (depth, difference) = Self::calculate_depth_and_difference(entry, entries);
349
350                    let filename = match difference {
351                        diff if diff > 1 => entry
352                            .path
353                            .iter()
354                            .skip(entry.path.components().count() - diff)
355                            .collect::<PathBuf>()
356                            .to_str()
357                            .unwrap_or_default()
358                            .to_string(),
359                        _ => entry
360                            .path
361                            .file_name()
362                            .map(|name| name.to_string_lossy().into_owned())
363                            .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
364                    };
365
366                    let display_name = entry.path.to_string_lossy().into_owned();
367
368                    let details = EntryDetails {
369                        filename,
370                        display_name,
371                        kind: entry.kind,
372                        is_expanded,
373                        path: entry.path.clone(),
374                        status,
375                        depth,
376                    };
377                    callback(entry.id, details, cx);
378                }
379            }
380            ix = end_ix;
381        }
382    }
383
384    // TODO: Update expanded directory state
385    fn update_visible_entries(
386        &mut self,
387        new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
388        cx: &mut ViewContext<Self>,
389    ) {
390        let project = self.project.read(cx);
391        self.visible_entries.clear();
392        for worktree in project.visible_worktrees(cx) {
393            let snapshot = worktree.read(cx).snapshot();
394            let worktree_id = snapshot.id();
395
396            let mut visible_worktree_entries = Vec::new();
397            let mut entry_iter = snapshot.entries(true, 0);
398            while let Some(entry) = entry_iter.entry() {
399                // Only include entries with a git status
400                if entry.git_status.is_some() {
401                    visible_worktree_entries.push(entry.clone());
402                }
403                entry_iter.advance();
404            }
405
406            snapshot.propagate_git_statuses(&mut visible_worktree_entries);
407            project::sort_worktree_entries(&mut visible_worktree_entries);
408
409            if !visible_worktree_entries.is_empty() {
410                self.visible_entries
411                    .push((worktree_id, visible_worktree_entries, OnceCell::new()));
412            }
413        }
414
415        if let Some((worktree_id, entry_id)) = new_selected_entry {
416            self.selected_item = self.visible_entries.iter().enumerate().find_map(
417                |(worktree_index, (id, entries, _))| {
418                    if *id == worktree_id {
419                        entries
420                            .iter()
421                            .position(|entry| entry.id == entry_id)
422                            .map(|entry_index| worktree_index * entries.len() + entry_index)
423                    } else {
424                        None
425                    }
426                },
427            );
428        }
429
430        cx.notify();
431    }
432}
433
434impl GitPanel {
435    pub fn panel_button(
436        &self,
437        id: impl Into<SharedString>,
438        label: impl Into<SharedString>,
439    ) -> Button {
440        let id = id.into().clone();
441        let label = label.into().clone();
442
443        Button::new(id, label)
444            .label_size(LabelSize::Small)
445            .layer(ElevationIndex::ElevatedSurface)
446            .size(ButtonSize::Compact)
447            .style(ButtonStyle::Filled)
448    }
449
450    pub fn render_divider(&self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
451        h_flex()
452            .items_center()
453            .h(px(8.))
454            .child(Divider::horizontal_dashed().color(DividerColor::Border))
455    }
456
457    pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
458        let focus_handle = self.focus_handle(cx).clone();
459
460        let changes_string = format!("{} changes", self.entry_count());
461
462        h_flex()
463            .h(px(32.))
464            .items_center()
465            .px_3()
466            .bg(ElevationIndex::Surface.bg(cx))
467            .child(
468                h_flex()
469                    .gap_2()
470                    .child(Checkbox::new("all-changes", true.into()).disabled(true))
471                    .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
472            )
473            .child(div().flex_grow())
474            .child(
475                h_flex()
476                    .gap_2()
477                    .child(
478                        IconButton::new("discard-changes", IconName::Undo)
479                            .tooltip(move |cx| {
480                                let focus_handle = focus_handle.clone();
481
482                                Tooltip::for_action_in(
483                                    "Discard all changes",
484                                    &DiscardAll,
485                                    &focus_handle,
486                                    cx,
487                                )
488                            })
489                            .icon_size(IconSize::Small)
490                            .disabled(true),
491                    )
492                    .child(if self.all_staged() {
493                        self.panel_button("unstage-all", "Unstage All").on_click(
494                            cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(DiscardAll))),
495                        )
496                    } else {
497                        self.panel_button("stage-all", "Stage All").on_click(
498                            cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))),
499                        )
500                    }),
501            )
502    }
503
504    pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
505        let focus_handle_1 = self.focus_handle(cx).clone();
506        let focus_handle_2 = self.focus_handle(cx).clone();
507
508        let commit_staged_button = self
509            .panel_button("commit-staged-changes", "Commit")
510            .tooltip(move |cx| {
511                let focus_handle = focus_handle_1.clone();
512                Tooltip::for_action_in(
513                    "Commit all staged changes",
514                    &CommitStagedChanges,
515                    &focus_handle,
516                    cx,
517                )
518            })
519            .on_click(cx.listener(|this, _: &ClickEvent, cx| {
520                this.commit_staged_changes(&CommitStagedChanges, cx)
521            }));
522
523        let commit_all_button = self
524            .panel_button("commit-all-changes", "Commit All")
525            .tooltip(move |cx| {
526                let focus_handle = focus_handle_2.clone();
527                Tooltip::for_action_in(
528                    "Commit all changes, including unstaged changes",
529                    &CommitAllChanges,
530                    &focus_handle,
531                    cx,
532                )
533            })
534            .on_click(cx.listener(|this, _: &ClickEvent, cx| {
535                this.commit_all_changes(&CommitAllChanges, cx)
536            }));
537
538        div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
539            v_flex()
540                .h_full()
541                .py_2p5()
542                .px_3()
543                .bg(cx.theme().colors().editor_background)
544                .font_buffer(cx)
545                .text_ui_sm(cx)
546                .text_color(cx.theme().colors().text_muted)
547                .child("Add a message")
548                .gap_1()
549                .child(div().flex_grow())
550                .child(h_flex().child(div().gap_1().flex_grow()).child(
551                    if self.current_modifiers.alt {
552                        commit_all_button
553                    } else {
554                        commit_staged_button
555                    },
556                ))
557                .cursor(CursorStyle::OperationNotAllowed)
558                .opacity(0.5),
559        )
560    }
561
562    fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
563        h_flex()
564            .h_full()
565            .flex_1()
566            .justify_center()
567            .items_center()
568            .child(
569                v_flex()
570                    .gap_3()
571                    .child("No changes to commit")
572                    .text_ui_sm(cx)
573                    .mx_auto()
574                    .text_color(Color::Placeholder.color(cx)),
575            )
576    }
577
578    fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
579        if !Self::should_show_scrollbar(cx)
580            || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
581        {
582            return None;
583        }
584        Some(
585            div()
586                .occlude()
587                .id("project-panel-vertical-scroll")
588                .on_mouse_move(cx.listener(|_, _, cx| {
589                    cx.notify();
590                    cx.stop_propagation()
591                }))
592                .on_hover(|_, cx| {
593                    cx.stop_propagation();
594                })
595                .on_any_mouse_down(|_, cx| {
596                    cx.stop_propagation();
597                })
598                .on_mouse_up(
599                    MouseButton::Left,
600                    cx.listener(|this, _, cx| {
601                        if !this.scrollbar_state.is_dragging()
602                            && !this.focus_handle.contains_focused(cx)
603                        {
604                            this.hide_scrollbar(cx);
605                            cx.notify();
606                        }
607
608                        cx.stop_propagation();
609                    }),
610                )
611                .on_scroll_wheel(cx.listener(|_, _, cx| {
612                    cx.notify();
613                }))
614                .h_full()
615                .absolute()
616                .right_1()
617                .top_1()
618                .bottom_1()
619                .w(px(12.))
620                .cursor_default()
621                .children(Scrollbar::vertical(
622                    // percentage as f32..end_offset as f32,
623                    self.scrollbar_state.clone(),
624                )),
625        )
626    }
627
628    fn render_entries(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
629        let item_count = self
630            .visible_entries
631            .iter()
632            .map(|(_, worktree_entries, _)| worktree_entries.len())
633            .sum();
634        h_flex()
635            .size_full()
636            .overflow_hidden()
637            .child(
638                uniform_list(cx.view().clone(), "entries", item_count, {
639                    |this, range, cx| {
640                        let mut items = Vec::with_capacity(range.end - range.start);
641                        this.for_each_visible_entry(range, cx, |id, details, cx| {
642                            items.push(this.render_entry(id, details, cx));
643                        });
644                        items
645                    }
646                })
647                .size_full()
648                .with_sizing_behavior(ListSizingBehavior::Infer)
649                .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
650                // .with_width_from_item(self.max_width_item_index)
651                .track_scroll(self.scroll_handle.clone()),
652            )
653            .children(self.render_scrollbar(cx))
654    }
655
656    fn render_entry(
657        &self,
658        id: ProjectEntryId,
659        details: EntryDetails,
660        cx: &ViewContext<Self>,
661    ) -> impl IntoElement {
662        let id = id.to_proto() as usize;
663        let checkbox_id = ElementId::Name(format!("checkbox_{}", id).into());
664        let is_staged = ToggleState::Selected;
665
666        h_flex()
667            .id(id)
668            .h(px(28.))
669            .w_full()
670            .pl(px(12. + 12. * details.depth as f32))
671            .pr(px(4.))
672            .items_center()
673            .gap_2()
674            .font_buffer(cx)
675            .text_ui_sm(cx)
676            .when(!details.is_dir(), |this| {
677                this.child(Checkbox::new(checkbox_id, is_staged))
678            })
679            .when_some(details.status, |this, status| {
680                this.child(git_status_icon(status))
681            })
682            .child(h_flex().gap_1p5().child(details.display_name.clone()))
683    }
684}
685
686impl Render for GitPanel {
687    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
688        let project = self.project.read(cx);
689
690        v_flex()
691            .id("git_panel")
692            .key_context(self.dispatch_context())
693            .track_focus(&self.focus_handle)
694            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
695            .when(!project.is_read_only(cx), |this| {
696                this.on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx)))
697                    .on_action(
698                        cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)),
699                    )
700                    .on_action(
701                        cx.listener(|this, &DiscardAll, cx| this.discard_all(&DiscardAll, cx)),
702                    )
703                    .on_action(cx.listener(|this, &CommitStagedChanges, cx| {
704                        this.commit_staged_changes(&CommitStagedChanges, cx)
705                    }))
706                    .on_action(cx.listener(|this, &CommitAllChanges, cx| {
707                        this.commit_all_changes(&CommitAllChanges, cx)
708                    }))
709            })
710            .on_hover(cx.listener(|this, hovered, cx| {
711                if *hovered {
712                    this.show_scrollbar = true;
713                    this.hide_scrollbar_task.take();
714                    cx.notify();
715                } else if !this.focus_handle.contains_focused(cx) {
716                    this.hide_scrollbar(cx);
717                }
718            }))
719            .size_full()
720            .overflow_hidden()
721            .font_buffer(cx)
722            .py_1()
723            .bg(ElevationIndex::Surface.bg(cx))
724            .child(self.render_panel_header(cx))
725            .child(self.render_divider(cx))
726            .child(if !self.no_entries() {
727                self.render_entries(cx).into_any_element()
728            } else {
729                self.render_empty_state(cx).into_any_element()
730            })
731            .child(self.render_divider(cx))
732            .child(self.render_commit_editor(cx))
733    }
734}
735
736impl FocusableView for GitPanel {
737    fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
738        self.focus_handle.clone()
739    }
740}
741
742impl EventEmitter<Event> for GitPanel {}
743
744impl EventEmitter<PanelEvent> for GitPanel {}
745
746impl Panel for GitPanel {
747    fn persistent_name() -> &'static str {
748        "GitPanel"
749    }
750
751    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
752        GitPanelSettings::get_global(cx).dock
753    }
754
755    fn position_is_valid(&self, position: DockPosition) -> bool {
756        matches!(position, DockPosition::Left | DockPosition::Right)
757    }
758
759    fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
760        settings::update_settings_file::<GitPanelSettings>(
761            self.fs.clone(),
762            cx,
763            move |settings, _| settings.dock = Some(position),
764        );
765    }
766
767    fn size(&self, cx: &gpui::WindowContext) -> Pixels {
768        self.width
769            .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
770    }
771
772    fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
773        self.width = size;
774        self.serialize(cx);
775        cx.notify();
776    }
777
778    fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
779        Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
780    }
781
782    fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
783        Some("Git Panel")
784    }
785
786    fn toggle_action(&self) -> Box<dyn Action> {
787        Box::new(ToggleFocus)
788    }
789}