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