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