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