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