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