1use crate::{first_repository_in_project, first_worktree_repository};
2use crate::{
3 git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitChanges, GitState,
4 GitViewMode, RevertAll, StageAll, ToggleStaged, UnstageAll,
5};
6use anyhow::{Context as _, Result};
7use db::kvp::KEY_VALUE_STORE;
8use editor::Editor;
9use git::repository::{GitFileStatus, RepoPath};
10use git::status::GitStatusPair;
11use gpui::*;
12use language::Buffer;
13use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
14use project::{Fs, Project};
15use serde::{Deserialize, Serialize};
16use settings::Settings as _;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize};
19use theme::ThemeSettings;
20use ui::{
21 prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip,
22};
23use util::{ResultExt, TryFutureExt};
24use workspace::{
25 dock::{DockPosition, Panel, PanelEvent},
26 Workspace,
27};
28
29actions!(
30 git_panel,
31 [
32 Close,
33 ToggleFocus,
34 OpenMenu,
35 OpenSelected,
36 FocusEditor,
37 FocusChanges
38 ]
39);
40
41const GIT_PANEL_KEY: &str = "GitPanel";
42
43const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
44
45pub fn init(cx: &mut AppContext) {
46 cx.observe_new_views(
47 |workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>| {
48 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
49 workspace.toggle_panel_focus::<GitPanel>(cx);
50 });
51 },
52 )
53 .detach();
54}
55
56#[derive(Debug)]
57pub enum Event {
58 Focus,
59}
60
61#[derive(Serialize, Deserialize)]
62struct SerializedGitPanel {
63 width: Option<Pixels>,
64}
65
66#[derive(Debug, PartialEq, Eq, Clone)]
67pub struct GitListEntry {
68 depth: usize,
69 display_name: String,
70 repo_path: RepoPath,
71 status: GitStatusPair,
72 toggle_state: ToggleState,
73}
74
75pub struct GitPanel {
76 current_modifiers: Modifiers,
77 focus_handle: FocusHandle,
78 fs: Arc<dyn Fs>,
79 hide_scrollbar_task: Option<Task<()>>,
80 pending_serialization: Task<Option<()>>,
81 project: Model<Project>,
82 scroll_handle: UniformListScrollHandle,
83 scrollbar_state: ScrollbarState,
84 selected_entry: Option<usize>,
85 show_scrollbar: bool,
86 rebuild_requested: Arc<AtomicBool>,
87 git_state: Model<GitState>,
88 commit_editor: View<Editor>,
89 /// The visible entries in the list, accounting for folding & expanded state.
90 ///
91 /// At this point it doesn't matter what repository the entry belongs to,
92 /// as only one repositories' entries are visible in the list at a time.
93 visible_entries: Vec<GitListEntry>,
94 width: Option<Pixels>,
95 reveal_in_editor: Task<()>,
96}
97
98fn status_to_toggle_state(status: &GitStatusPair) -> ToggleState {
99 match status.is_staged() {
100 Some(true) => ToggleState::Selected,
101 Some(false) => ToggleState::Unselected,
102 None => ToggleState::Indeterminate,
103 }
104}
105
106impl GitPanel {
107 pub fn load(
108 workspace: WeakView<Workspace>,
109 cx: AsyncWindowContext,
110 ) -> Task<Result<View<Self>>> {
111 cx.spawn(|mut cx| async move { workspace.update(&mut cx, Self::new) })
112 }
113
114 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
115 let fs = workspace.app_state().fs.clone();
116 let project = workspace.project().clone();
117 let language_registry = workspace.app_state().languages.clone();
118 let git_state = GitState::get_global(cx);
119 let current_commit_message = {
120 let state = git_state.read(cx);
121 state.commit_message.clone()
122 };
123
124 let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
125 let focus_handle = cx.focus_handle();
126 cx.on_focus(&focus_handle, Self::focus_in).detach();
127 cx.on_focus_out(&focus_handle, |this, _, cx| {
128 this.hide_scrollbar(cx);
129 })
130 .detach();
131 cx.subscribe(&project, move |this, project, event, cx| {
132 use project::Event;
133
134 let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| {
135 let snapshot = worktree.read(cx).snapshot();
136 snapshot.id()
137 });
138 let first_repo_in_project = first_repository_in_project(&project, cx);
139
140 // TODO: Don't get another git_state here
141 // was running into a borrow issue
142 let git_state = GitState::get_global(cx);
143
144 match event {
145 project::Event::WorktreeRemoved(id) => {
146 git_state.update(cx, |state, _| {
147 state.all_repositories.remove(id);
148 let Some((worktree_id, _, _)) = state.active_repository.as_ref() else {
149 return;
150 };
151 if worktree_id == id {
152 state.active_repository = first_repo_in_project;
153 this.schedule_update();
154 }
155 });
156 }
157 project::Event::WorktreeOrderChanged => {
158 // activate the new first worktree if the first was moved
159 let Some(first_id) = first_worktree_id else {
160 return;
161 };
162 git_state.update(cx, |state, _| {
163 if !state
164 .active_repository
165 .as_ref()
166 .is_some_and(|(id, _, _)| id == &first_id)
167 {
168 state.active_repository = first_repo_in_project;
169 this.schedule_update();
170 }
171 });
172 }
173 Event::WorktreeAdded(id) => {
174 git_state.update(cx, |state, cx| {
175 let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else {
176 return;
177 };
178 let snapshot = worktree.read(cx).snapshot();
179 state
180 .all_repositories
181 .insert(*id, snapshot.repositories().clone());
182 });
183 let Some(first_id) = first_worktree_id else {
184 return;
185 };
186 git_state.update(cx, |state, _| {
187 if !state
188 .active_repository
189 .as_ref()
190 .is_some_and(|(id, _, _)| id == &first_id)
191 {
192 state.active_repository = first_repo_in_project;
193 this.schedule_update();
194 }
195 });
196 }
197 project::Event::WorktreeUpdatedEntries(id, _) => {
198 git_state.update(cx, |state, _| {
199 if state
200 .active_repository
201 .as_ref()
202 .is_some_and(|(active_id, _, _)| active_id == id)
203 {
204 state.active_repository = first_repo_in_project;
205 this.schedule_update();
206 }
207 });
208 }
209 project::Event::WorktreeUpdatedGitRepositories(_) => {
210 let Some(first) = first_repo_in_project else {
211 return;
212 };
213 git_state.update(cx, |state, _| {
214 state.active_repository = Some(first);
215 this.schedule_update();
216 });
217 }
218 project::Event::Closed => {
219 this.reveal_in_editor = Task::ready(());
220 this.visible_entries.clear();
221 // TODO cancel/clear task?
222 }
223 _ => {}
224 };
225 })
226 .detach();
227
228 let commit_editor = cx.new_view(|cx| {
229 let theme = ThemeSettings::get_global(cx);
230
231 let mut text_style = cx.text_style();
232 let refinement = TextStyleRefinement {
233 font_family: Some(theme.buffer_font.family.clone()),
234 font_features: Some(FontFeatures::disable_ligatures()),
235 font_size: Some(px(12.).into()),
236 color: Some(cx.theme().colors().editor_foreground),
237 background_color: Some(gpui::transparent_black()),
238 ..Default::default()
239 };
240
241 text_style.refine(&refinement);
242
243 let mut commit_editor = Editor::auto_height(10, cx);
244 if let Some(message) = current_commit_message {
245 commit_editor.set_text(message, cx);
246 } else {
247 commit_editor.set_text("", cx);
248 }
249 commit_editor.set_use_autoclose(false);
250 commit_editor.set_show_gutter(false, cx);
251 commit_editor.set_show_wrap_guides(false, cx);
252 commit_editor.set_show_indent_guides(false, cx);
253 commit_editor.set_text_style_refinement(refinement);
254 commit_editor.set_placeholder_text("Enter commit message", cx);
255 commit_editor
256 });
257
258 let buffer = commit_editor
259 .read(cx)
260 .buffer()
261 .read(cx)
262 .as_singleton()
263 .expect("commit editor must be singleton");
264
265 cx.subscribe(&buffer, Self::on_buffer_event).detach();
266
267 let markdown = language_registry.language_for_name("Markdown");
268 cx.spawn(|_, mut cx| async move {
269 let markdown = markdown.await.context("failed to load Markdown language")?;
270 buffer.update(&mut cx, |buffer, cx| {
271 buffer.set_language(Some(markdown), cx)
272 })
273 })
274 .detach_and_log_err(cx);
275
276 let scroll_handle = UniformListScrollHandle::new();
277
278 git_state.update(cx, |state, cx| {
279 let mut visible_worktrees = project.read(cx).visible_worktrees(cx);
280 let Some(first_worktree) = visible_worktrees.next() else {
281 return;
282 };
283 drop(visible_worktrees);
284 let snapshot = first_worktree.read(cx).snapshot();
285
286 if let Some((repo, git_repo)) =
287 first_worktree_repository(&project, snapshot.id(), cx)
288 {
289 state.activate_repository(snapshot.id(), repo, git_repo);
290 }
291 });
292
293 let rebuild_requested = Arc::new(AtomicBool::new(false));
294 let flag = rebuild_requested.clone();
295 let handle = cx.view().downgrade();
296 cx.spawn(|_, mut cx| async move {
297 loop {
298 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
299 if flag.load(Ordering::Relaxed) {
300 if let Some(this) = handle.upgrade() {
301 this.update(&mut cx, |this, cx| {
302 this.update_visible_entries(cx);
303 })
304 .ok();
305 }
306 flag.store(false, Ordering::Relaxed);
307 }
308 }
309 })
310 .detach();
311
312 let mut git_panel = Self {
313 focus_handle: cx.focus_handle(),
314 fs,
315 pending_serialization: Task::ready(None),
316 visible_entries: Vec::new(),
317 current_modifiers: cx.modifiers(),
318 width: Some(px(360.)),
319 scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
320 scroll_handle,
321 selected_entry: None,
322 show_scrollbar: !Self::should_autohide_scrollbar(cx),
323 hide_scrollbar_task: None,
324 rebuild_requested,
325 commit_editor,
326 git_state,
327 reveal_in_editor: Task::ready(()),
328 project,
329 };
330 git_panel.schedule_update();
331 git_panel
332 });
333
334 git_panel
335 }
336
337 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
338 // TODO: we can store stage status here
339 let width = self.width;
340 self.pending_serialization = cx.background_executor().spawn(
341 async move {
342 KEY_VALUE_STORE
343 .write_kvp(
344 GIT_PANEL_KEY.into(),
345 serde_json::to_string(&SerializedGitPanel { width })?,
346 )
347 .await?;
348 anyhow::Ok(())
349 }
350 .log_err(),
351 );
352 }
353
354 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
355 let mut dispatch_context = KeyContext::new_with_defaults();
356 dispatch_context.add("GitPanel");
357
358 if self.is_focused(cx) {
359 dispatch_context.add("menu");
360 dispatch_context.add("ChangesList");
361 }
362
363 if self.commit_editor.read(cx).is_focused(cx) {
364 dispatch_context.add("CommitEditor");
365 }
366
367 dispatch_context
368 }
369
370 fn is_focused(&self, cx: &ViewContext<Self>) -> bool {
371 cx.focused()
372 .map_or(false, |focused| self.focus_handle == focused)
373 }
374
375 fn close_panel(&mut self, _: &Close, cx: &mut ViewContext<Self>) {
376 cx.emit(PanelEvent::Close);
377 }
378
379 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
380 if !self.focus_handle.contains_focused(cx) {
381 cx.emit(Event::Focus);
382 }
383 }
384
385 fn should_show_scrollbar(_cx: &AppContext) -> bool {
386 // TODO: plug into settings
387 true
388 }
389
390 fn should_autohide_scrollbar(_cx: &AppContext) -> bool {
391 // TODO: plug into settings
392 true
393 }
394
395 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
396 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
397 if !Self::should_autohide_scrollbar(cx) {
398 return;
399 }
400 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
401 cx.background_executor()
402 .timer(SCROLLBAR_SHOW_INTERVAL)
403 .await;
404 panel
405 .update(&mut cx, |panel, cx| {
406 panel.show_scrollbar = false;
407 cx.notify();
408 })
409 .log_err();
410 }))
411 }
412
413 fn handle_modifiers_changed(
414 &mut self,
415 event: &ModifiersChangedEvent,
416 cx: &mut ViewContext<Self>,
417 ) {
418 self.current_modifiers = event.modifiers;
419 cx.notify();
420 }
421
422 fn calculate_depth_and_difference(
423 repo_path: &RepoPath,
424 visible_entries: &HashSet<RepoPath>,
425 ) -> (usize, usize) {
426 let ancestors = repo_path.ancestors().skip(1);
427 for ancestor in ancestors {
428 if let Some(parent_entry) = visible_entries.get(ancestor) {
429 let entry_component_count = repo_path.components().count();
430 let parent_component_count = parent_entry.components().count();
431
432 let difference = entry_component_count - parent_component_count;
433
434 let parent_depth = parent_entry
435 .ancestors()
436 .skip(1) // Skip the parent itself
437 .filter(|ancestor| visible_entries.contains(*ancestor))
438 .count();
439
440 return (parent_depth + 1, difference);
441 }
442 }
443
444 (0, 0)
445 }
446
447 fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext<Self>) {
448 if let Some(selected_entry) = self.selected_entry {
449 self.scroll_handle
450 .scroll_to_item(selected_entry, ScrollStrategy::Center);
451 }
452
453 cx.notify();
454 }
455
456 fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
457 if self.visible_entries.first().is_some() {
458 self.selected_entry = Some(0);
459 self.scroll_to_selected_entry(cx);
460 }
461 }
462
463 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
464 let item_count = self.visible_entries.len();
465 if item_count == 0 {
466 return;
467 }
468
469 if let Some(selected_entry) = self.selected_entry {
470 let new_selected_entry = if selected_entry > 0 {
471 selected_entry - 1
472 } else {
473 self.selected_entry = Some(item_count - 1);
474 item_count - 1
475 };
476
477 self.selected_entry = Some(new_selected_entry);
478
479 self.scroll_to_selected_entry(cx);
480 }
481
482 cx.notify();
483 }
484
485 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
486 let item_count = self.visible_entries.len();
487 if item_count == 0 {
488 return;
489 }
490
491 if let Some(selected_entry) = self.selected_entry {
492 let new_selected_entry = if selected_entry < item_count - 1 {
493 selected_entry + 1
494 } else {
495 selected_entry
496 };
497
498 self.selected_entry = Some(new_selected_entry);
499
500 self.scroll_to_selected_entry(cx);
501 }
502
503 cx.notify();
504 }
505
506 fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
507 if self.visible_entries.last().is_some() {
508 self.selected_entry = Some(self.visible_entries.len() - 1);
509 self.scroll_to_selected_entry(cx);
510 }
511 }
512
513 fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
514 self.commit_editor.update(cx, |editor, cx| {
515 editor.focus(cx);
516 });
517 cx.notify();
518 }
519
520 fn select_first_entry(&mut self, cx: &mut ViewContext<Self>) {
521 if !self.no_entries() && self.selected_entry.is_none() {
522 self.selected_entry = Some(0);
523 self.scroll_to_selected_entry(cx);
524 cx.notify();
525 }
526 }
527
528 fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext<Self>) {
529 self.select_first_entry(cx);
530
531 cx.focus_self();
532 cx.notify();
533 }
534
535 fn get_selected_entry(&self) -> Option<&GitListEntry> {
536 self.selected_entry
537 .and_then(|i| self.visible_entries.get(i))
538 }
539
540 fn toggle_staged_for_entry(&self, entry: &GitListEntry, cx: &mut ViewContext<Self>) {
541 self.git_state
542 .clone()
543 .update(cx, |state, _| match entry.status.is_staged() {
544 Some(true) | None => state.unstage_entry(entry.repo_path.clone()),
545 Some(false) => state.stage_entry(entry.repo_path.clone()),
546 });
547 cx.notify();
548 }
549
550 fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext<Self>) {
551 if let Some(selected_entry) = self.get_selected_entry() {
552 self.toggle_staged_for_entry(&selected_entry, cx);
553 }
554 }
555
556 fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
557 println!("Open Selected triggered!");
558 let selected_entry = self.selected_entry;
559
560 if let Some(entry) = selected_entry.and_then(|i| self.visible_entries.get(i)) {
561 self.open_entry(entry);
562
563 cx.notify();
564 }
565 }
566
567 fn open_entry(&self, entry: &GitListEntry) {
568 // TODO: Open entry or entry's changes.
569 println!("Open {} triggered!", entry.repo_path);
570
571 // cx.emit(project_panel::Event::OpenedEntry {
572 // entry_id,
573 // focus_opened_item,
574 // allow_preview,
575 // });
576 //
577 // workspace
578 // .open_path_preview(
579 // ProjectPath {
580 // worktree_id,
581 // path: file_path.clone(),
582 // },
583 // None,
584 // focus_opened_item,
585 // allow_preview,
586 // cx,
587 // )
588 // .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
589 // match e.error_code() {
590 // ErrorCode::Disconnected => if is_via_ssh {
591 // Some("Disconnected from SSH host".to_string())
592 // } else {
593 // Some("Disconnected from remote project".to_string())
594 // },
595 // ErrorCode::UnsharedItem => Some(format!(
596 // "{} is not shared by the host. This could be because it has been marked as `private`",
597 // file_path.display()
598 // )),
599 // _ => None,
600 // }
601 // });
602 }
603
604 fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext<Self>) {
605 self.git_state.update(cx, |state, _| state.stage_all());
606 }
607
608 fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext<Self>) {
609 self.git_state.update(cx, |state, _| {
610 state.unstage_all();
611 });
612 }
613
614 fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext<Self>) {
615 // TODO: Implement discard all
616 println!("Discard all triggered");
617 }
618
619 fn clear_message(&mut self, cx: &mut ViewContext<Self>) {
620 self.git_state
621 .update(cx, |state, _cx| state.clear_commit_message());
622 self.commit_editor
623 .update(cx, |editor, cx| editor.set_text("", cx));
624 }
625
626 /// Commit all staged changes
627 fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext<Self>) {
628 self.clear_message(cx);
629
630 // TODO: Implement commit all staged
631 println!("Commit staged changes triggered");
632 }
633
634 /// Commit all changes, regardless of whether they are staged or not
635 fn commit_all_changes(&mut self, _: &CommitAllChanges, cx: &mut ViewContext<Self>) {
636 self.clear_message(cx);
637
638 // TODO: Implement commit all changes
639 println!("Commit all changes triggered");
640 }
641
642 fn all_staged(&self) -> bool {
643 // TODO: Implement all_staged
644 true
645 }
646
647 fn no_entries(&self) -> bool {
648 self.visible_entries.is_empty()
649 }
650
651 fn entry_count(&self) -> usize {
652 self.visible_entries.len()
653 }
654
655 fn for_each_visible_entry(
656 &self,
657 range: Range<usize>,
658 cx: &mut ViewContext<Self>,
659 mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext<Self>),
660 ) {
661 let visible_entries = &self.visible_entries;
662
663 for (ix, entry) in visible_entries
664 .iter()
665 .enumerate()
666 .skip(range.start)
667 .take(range.end - range.start)
668 {
669 let status = entry.status.clone();
670 let filename = entry
671 .repo_path
672 .file_name()
673 .map(|name| name.to_string_lossy().into_owned())
674 .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned());
675
676 let details = GitListEntry {
677 repo_path: entry.repo_path.clone(),
678 status,
679 depth: 0,
680 display_name: filename,
681 toggle_state: entry.toggle_state,
682 };
683
684 callback(ix, details, cx);
685 }
686 }
687
688 fn schedule_update(&mut self) {
689 self.rebuild_requested.store(true, Ordering::Relaxed);
690 }
691
692 #[track_caller]
693 fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
694 let git_state = self.git_state.read(cx);
695
696 self.visible_entries.clear();
697
698 let Some((_, repo, _)) = git_state.active_repository().as_ref() else {
699 // Just clear entries if no repository is active.
700 cx.notify();
701 return;
702 };
703
704 // First pass - collect all paths
705 let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path));
706
707 // Second pass - create entries with proper depth calculation
708 for entry in repo.status() {
709 let (depth, difference) =
710 Self::calculate_depth_and_difference(&entry.repo_path, &path_set);
711 let toggle_state = status_to_toggle_state(&entry.status);
712
713 let display_name = if difference > 1 {
714 // Show partial path for deeply nested files
715 entry
716 .repo_path
717 .as_ref()
718 .iter()
719 .skip(entry.repo_path.components().count() - difference)
720 .collect::<PathBuf>()
721 .to_string_lossy()
722 .into_owned()
723 } else {
724 // Just show filename
725 entry
726 .repo_path
727 .file_name()
728 .map(|name| name.to_string_lossy().into_owned())
729 .unwrap_or_default()
730 };
731
732 let entry = GitListEntry {
733 depth,
734 display_name,
735 repo_path: entry.repo_path,
736 status: entry.status,
737 toggle_state,
738 };
739
740 self.visible_entries.push(entry);
741 }
742
743 // Sort entries by path to maintain consistent order
744 self.visible_entries
745 .sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
746 cx.notify();
747 }
748
749 fn on_buffer_event(
750 &mut self,
751 _buffer: Model<Buffer>,
752 event: &language::BufferEvent,
753 cx: &mut ViewContext<Self>,
754 ) {
755 if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
756 let commit_message = self.commit_editor.update(cx, |editor, cx| editor.text(cx));
757
758 self.git_state.update(cx, |state, _cx| {
759 state.commit_message = Some(commit_message.into());
760 });
761
762 cx.notify();
763 }
764 }
765}
766
767// GitPanel –– Render
768impl GitPanel {
769 pub fn panel_button(
770 &self,
771 id: impl Into<SharedString>,
772 label: impl Into<SharedString>,
773 ) -> Button {
774 let id = id.into().clone();
775 let label = label.into().clone();
776
777 Button::new(id, label)
778 .label_size(LabelSize::Small)
779 .layer(ElevationIndex::ElevatedSurface)
780 .size(ButtonSize::Compact)
781 .style(ButtonStyle::Filled)
782 }
783
784 pub fn render_divider(&self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
785 h_flex()
786 .items_center()
787 .h(px(8.))
788 .child(Divider::horizontal_dashed().color(DividerColor::Border))
789 }
790
791 pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
792 let focus_handle = self.focus_handle(cx).clone();
793
794 let changes_string = match self.entry_count() {
795 0 => "No changes".to_string(),
796 1 => "1 change".to_string(),
797 n => format!("{} changes", n),
798 };
799
800 h_flex()
801 .h(px(32.))
802 .items_center()
803 .px_3()
804 .bg(ElevationIndex::Surface.bg(cx))
805 .child(
806 h_flex()
807 .gap_2()
808 .child(Checkbox::new("all-changes", true.into()).disabled(true))
809 .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
810 )
811 .child(div().flex_grow())
812 .child(
813 h_flex()
814 .gap_2()
815 .child(
816 IconButton::new("discard-changes", IconName::Undo)
817 .tooltip(move |cx| {
818 let focus_handle = focus_handle.clone();
819
820 Tooltip::for_action_in(
821 "Discard all changes",
822 &RevertAll,
823 &focus_handle,
824 cx,
825 )
826 })
827 .icon_size(IconSize::Small)
828 .disabled(true),
829 )
830 .child(if self.all_staged() {
831 self.panel_button("unstage-all", "Unstage All").on_click(
832 cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(RevertAll))),
833 )
834 } else {
835 self.panel_button("stage-all", "Stage All").on_click(
836 cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))),
837 )
838 }),
839 )
840 }
841
842 pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
843 let editor = self.commit_editor.clone();
844 let editor_focus_handle = editor.read(cx).focus_handle(cx).clone();
845
846 let focus_handle_1 = self.focus_handle(cx).clone();
847 let focus_handle_2 = self.focus_handle(cx).clone();
848
849 let commit_staged_button = self
850 .panel_button("commit-staged-changes", "Commit")
851 .tooltip(move |cx| {
852 let focus_handle = focus_handle_1.clone();
853 Tooltip::for_action_in(
854 "Commit all staged changes",
855 &CommitChanges,
856 &focus_handle,
857 cx,
858 )
859 })
860 .on_click(
861 cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)),
862 );
863
864 let commit_all_button = self
865 .panel_button("commit-all-changes", "Commit All")
866 .tooltip(move |cx| {
867 let focus_handle = focus_handle_2.clone();
868 Tooltip::for_action_in(
869 "Commit all changes, including unstaged changes",
870 &CommitAllChanges,
871 &focus_handle,
872 cx,
873 )
874 })
875 .on_click(cx.listener(|this, _: &ClickEvent, cx| {
876 this.commit_all_changes(&CommitAllChanges, cx)
877 }));
878
879 div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
880 v_flex()
881 .id("commit-editor-container")
882 .relative()
883 .h_full()
884 .py_2p5()
885 .px_3()
886 .bg(cx.theme().colors().editor_background)
887 .on_click(cx.listener(move |_, _: &ClickEvent, cx| cx.focus(&editor_focus_handle)))
888 .child(self.commit_editor.clone())
889 .child(
890 h_flex()
891 .absolute()
892 .bottom_2p5()
893 .right_3()
894 .child(div().gap_1().flex_grow())
895 .child(if self.current_modifiers.alt {
896 commit_all_button
897 } else {
898 commit_staged_button
899 }),
900 ),
901 )
902 }
903
904 fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
905 h_flex()
906 .h_full()
907 .flex_1()
908 .justify_center()
909 .items_center()
910 .child(
911 v_flex()
912 .gap_3()
913 .child("No changes to commit")
914 .text_ui_sm(cx)
915 .mx_auto()
916 .text_color(Color::Placeholder.color(cx)),
917 )
918 }
919
920 fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
921 if !Self::should_show_scrollbar(cx)
922 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
923 {
924 return None;
925 }
926 Some(
927 div()
928 .occlude()
929 .id("project-panel-vertical-scroll")
930 .on_mouse_move(cx.listener(|_, _, cx| {
931 cx.notify();
932 cx.stop_propagation()
933 }))
934 .on_hover(|_, cx| {
935 cx.stop_propagation();
936 })
937 .on_any_mouse_down(|_, cx| {
938 cx.stop_propagation();
939 })
940 .on_mouse_up(
941 MouseButton::Left,
942 cx.listener(|this, _, cx| {
943 if !this.scrollbar_state.is_dragging()
944 && !this.focus_handle.contains_focused(cx)
945 {
946 this.hide_scrollbar(cx);
947 cx.notify();
948 }
949
950 cx.stop_propagation();
951 }),
952 )
953 .on_scroll_wheel(cx.listener(|_, _, cx| {
954 cx.notify();
955 }))
956 .h_full()
957 .absolute()
958 .right_1()
959 .top_1()
960 .bottom_1()
961 .w(px(12.))
962 .cursor_default()
963 .children(Scrollbar::vertical(
964 // percentage as f32..end_offset as f32,
965 self.scrollbar_state.clone(),
966 )),
967 )
968 }
969
970 fn render_entries(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
971 let entry_count = self.entry_count();
972 h_flex()
973 .size_full()
974 .overflow_hidden()
975 .child(
976 uniform_list(cx.view().clone(), "entries", entry_count, {
977 move |git_panel, range, cx| {
978 let mut items = Vec::with_capacity(range.end - range.start);
979 git_panel.for_each_visible_entry(range, cx, |ix, details, cx| {
980 items.push(git_panel.render_entry(ix, details, cx));
981 });
982 items
983 }
984 })
985 .size_full()
986 .with_sizing_behavior(ListSizingBehavior::Infer)
987 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
988 // .with_width_from_item(self.max_width_item_index)
989 .track_scroll(self.scroll_handle.clone()),
990 )
991 .children(self.render_scrollbar(cx))
992 }
993
994 fn render_entry(
995 &self,
996 ix: usize,
997 entry_details: GitListEntry,
998 cx: &ViewContext<Self>,
999 ) -> impl IntoElement {
1000 let state = self.git_state.clone();
1001 let repo_path = entry_details.repo_path.clone();
1002 let selected = self.selected_entry == Some(ix);
1003
1004 // TODO revisit, maybe use a different status here?
1005 let status = entry_details.status.combined();
1006 let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into());
1007 let checkbox_id =
1008 ElementId::Name(format!("checkbox_{}", entry_details.display_name).into());
1009 let view_mode = state.read(cx).list_view_mode.clone();
1010 let handle = cx.view().downgrade();
1011
1012 let end_slot = h_flex()
1013 .invisible()
1014 .when(selected, |this| this.visible())
1015 .when(!selected, |this| {
1016 this.group_hover("git-panel-entry", |this| this.visible())
1017 })
1018 .gap_1()
1019 .items_center()
1020 .child(
1021 IconButton::new("more", IconName::EllipsisVertical)
1022 .icon_color(Color::Placeholder)
1023 .icon_size(IconSize::Small),
1024 );
1025
1026 let mut entry = h_flex()
1027 .id(entry_id)
1028 .group("git-panel-entry")
1029 .h(px(28.))
1030 .w_full()
1031 .pr(px(4.))
1032 .items_center()
1033 .gap_2()
1034 .font_buffer(cx)
1035 .text_ui_sm(cx)
1036 .when(!selected, |this| {
1037 this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover))
1038 });
1039
1040 if view_mode == GitViewMode::Tree {
1041 entry = entry.pl(px(12. + 12. * entry_details.depth as f32))
1042 } else {
1043 entry = entry.pl(px(12.))
1044 }
1045
1046 if selected {
1047 entry = entry.bg(cx.theme().status().info_background);
1048 }
1049
1050 entry = entry
1051 .child(
1052 Checkbox::new(checkbox_id, entry_details.toggle_state)
1053 .fill()
1054 .elevation(ElevationIndex::Surface)
1055 .on_click({
1056 let handle = handle.clone();
1057 let repo_path = repo_path.clone();
1058 move |toggle, cx| {
1059 let Some(this) = handle.upgrade() else {
1060 return;
1061 };
1062 this.update(cx, |this, _| {
1063 this.visible_entries[ix].toggle_state = *toggle;
1064 });
1065 state.update(cx, {
1066 let repo_path = repo_path.clone();
1067 move |state, _| match toggle {
1068 ToggleState::Selected | ToggleState::Indeterminate => {
1069 state.stage_entry(repo_path);
1070 }
1071 ToggleState::Unselected => state.unstage_entry(repo_path),
1072 }
1073 });
1074 }
1075 }),
1076 )
1077 .child(git_status_icon(status))
1078 .child(
1079 h_flex()
1080 .when(status == GitFileStatus::Deleted, |this| {
1081 this.text_color(cx.theme().colors().text_disabled)
1082 .line_through()
1083 })
1084 .when_some(repo_path.parent(), |this, parent| {
1085 let parent_str = parent.to_string_lossy();
1086 if !parent_str.is_empty() {
1087 this.child(
1088 div()
1089 .when(status != GitFileStatus::Deleted, |this| {
1090 this.text_color(cx.theme().colors().text_muted)
1091 })
1092 .child(format!("{}/", parent_str)),
1093 )
1094 } else {
1095 this
1096 }
1097 })
1098 .child(div().child(entry_details.display_name.clone())),
1099 )
1100 .child(div().flex_1())
1101 .child(end_slot)
1102 .on_click(move |_, cx| {
1103 // TODO: add `select_entry` method then do after that
1104 cx.dispatch_action(Box::new(OpenSelected));
1105
1106 handle
1107 .update(cx, |git_panel, _| {
1108 git_panel.selected_entry = Some(ix);
1109 })
1110 .ok();
1111 });
1112
1113 entry
1114 }
1115}
1116
1117impl Render for GitPanel {
1118 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1119 let project = self.project.read(cx);
1120
1121 v_flex()
1122 .id("git_panel")
1123 .key_context(self.dispatch_context(cx))
1124 .track_focus(&self.focus_handle)
1125 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1126 .when(!project.is_read_only(cx), |this| {
1127 this.on_action(cx.listener(|this, &ToggleStaged, cx| {
1128 this.toggle_staged_for_selected(&ToggleStaged, cx)
1129 }))
1130 .on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx)))
1131 .on_action(cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)))
1132 .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx)))
1133 .on_action(
1134 cx.listener(|this, &CommitChanges, cx| this.commit_changes(&CommitChanges, cx)),
1135 )
1136 .on_action(cx.listener(|this, &CommitAllChanges, cx| {
1137 this.commit_all_changes(&CommitAllChanges, cx)
1138 }))
1139 })
1140 .when(self.is_focused(cx), |this| {
1141 this.on_action(cx.listener(Self::select_first))
1142 .on_action(cx.listener(Self::select_next))
1143 .on_action(cx.listener(Self::select_prev))
1144 .on_action(cx.listener(Self::select_last))
1145 .on_action(cx.listener(Self::close_panel))
1146 })
1147 .on_action(cx.listener(Self::open_selected))
1148 .on_action(cx.listener(Self::focus_changes_list))
1149 .on_action(cx.listener(Self::focus_editor))
1150 .on_action(cx.listener(Self::toggle_staged_for_selected))
1151 // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx)))
1152 .on_hover(cx.listener(|this, hovered, cx| {
1153 if *hovered {
1154 this.show_scrollbar = true;
1155 this.hide_scrollbar_task.take();
1156 cx.notify();
1157 } else if !this.focus_handle.contains_focused(cx) {
1158 this.hide_scrollbar(cx);
1159 }
1160 }))
1161 .size_full()
1162 .overflow_hidden()
1163 .font_buffer(cx)
1164 .py_1()
1165 .bg(ElevationIndex::Surface.bg(cx))
1166 .child(self.render_panel_header(cx))
1167 .child(self.render_divider(cx))
1168 .child(if !self.no_entries() {
1169 self.render_entries(cx).into_any_element()
1170 } else {
1171 self.render_empty_state(cx).into_any_element()
1172 })
1173 .child(self.render_divider(cx))
1174 .child(self.render_commit_editor(cx))
1175 }
1176}
1177
1178impl FocusableView for GitPanel {
1179 fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
1180 self.focus_handle.clone()
1181 }
1182}
1183
1184impl EventEmitter<Event> for GitPanel {}
1185
1186impl EventEmitter<PanelEvent> for GitPanel {}
1187
1188impl Panel for GitPanel {
1189 fn persistent_name() -> &'static str {
1190 "GitPanel"
1191 }
1192
1193 fn position(&self, cx: &WindowContext) -> DockPosition {
1194 GitPanelSettings::get_global(cx).dock
1195 }
1196
1197 fn position_is_valid(&self, position: DockPosition) -> bool {
1198 matches!(position, DockPosition::Left | DockPosition::Right)
1199 }
1200
1201 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1202 settings::update_settings_file::<GitPanelSettings>(
1203 self.fs.clone(),
1204 cx,
1205 move |settings, _| settings.dock = Some(position),
1206 );
1207 }
1208
1209 fn size(&self, cx: &WindowContext) -> Pixels {
1210 self.width
1211 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1212 }
1213
1214 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1215 self.width = size;
1216 self.serialize(cx);
1217 cx.notify();
1218 }
1219
1220 fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
1221 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1222 }
1223
1224 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1225 Some("Git Panel")
1226 }
1227
1228 fn toggle_action(&self) -> Box<dyn Action> {
1229 Box::new(ToggleFocus)
1230 }
1231
1232 fn activation_priority(&self) -> u32 {
1233 2
1234 }
1235}