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