1use anyhow::{Context as _, Result};
2use collections::HashMap;
3use db::kvp::KEY_VALUE_STORE;
4use editor::{
5 scroll::{Autoscroll, AutoscrollStrategy},
6 Editor, MultiBuffer, DEFAULT_MULTIBUFFER_CONTEXT,
7};
8use git::{diff::DiffHunk, repository::GitFileStatus};
9use gpui::{
10 actions, prelude::*, uniform_list, Action, AppContext, AsyncWindowContext, ClickEvent,
11 CursorStyle, EventEmitter, FocusHandle, FocusableView, KeyContext,
12 ListHorizontalSizingBehavior, ListSizingBehavior, Model, Modifiers, ModifiersChangedEvent,
13 MouseButton, ScrollStrategy, Stateful, Task, UniformListScrollHandle, View, WeakView,
14};
15use language::{Buffer, BufferRow, OffsetRangeExt};
16use menu::{SelectNext, SelectPrev};
17use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, WorktreeId};
18use serde::{Deserialize, Serialize};
19use settings::Settings as _;
20use std::{
21 cell::OnceCell,
22 collections::HashSet,
23 ffi::OsStr,
24 ops::{Deref, Range},
25 path::{Path, PathBuf},
26 rc::Rc,
27 sync::Arc,
28 time::Duration,
29 usize,
30};
31use ui::{
32 prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, ListItem, Scrollbar,
33 ScrollbarState, Tooltip,
34};
35use util::{ResultExt, TryFutureExt};
36use workspace::{
37 dock::{DockPosition, Panel, PanelEvent},
38 ItemHandle, Workspace,
39};
40
41use crate::{git_status_icon, settings::GitPanelSettings};
42use crate::{CommitAllChanges, CommitStagedChanges, DiscardAll, StageAll, UnstageAll};
43
44actions!(git_panel, [ToggleFocus]);
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)]
62pub enum Event {
63 Focus,
64}
65
66pub struct GitStatusEntry {}
67
68#[derive(Debug, PartialEq, Eq, Clone)]
69struct EntryDetails {
70 filename: String,
71 display_name: String,
72 path: Arc<Path>,
73 kind: EntryKind,
74 depth: usize,
75 is_expanded: bool,
76 status: Option<GitFileStatus>,
77 hunks: Rc<OnceCell<Vec<DiffHunk>>>,
78 index: usize,
79}
80
81impl EntryDetails {
82 pub fn is_dir(&self) -> bool {
83 self.kind.is_dir()
84 }
85}
86
87#[derive(Serialize, Deserialize)]
88struct SerializedGitPanel {
89 width: Option<Pixels>,
90}
91
92pub struct GitPanel {
93 workspace: WeakView<Workspace>,
94 current_modifiers: Modifiers,
95 focus_handle: FocusHandle,
96 fs: Arc<dyn Fs>,
97 hide_scrollbar_task: Option<Task<()>>,
98 pending_serialization: Task<Option<()>>,
99 project: Model<Project>,
100 scroll_handle: UniformListScrollHandle,
101 scrollbar_state: ScrollbarState,
102 selected_item: Option<usize>,
103 show_scrollbar: bool,
104 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
105
106 // The entries that are currently shown in the panel, aka
107 // not hidden by folding or such
108 visible_entries: Vec<WorktreeEntries>,
109 width: Option<Pixels>,
110 git_diff_editor: View<Editor>,
111 git_diff_editor_updates: Task<()>,
112 reveal_in_editor: Task<()>,
113}
114
115#[derive(Debug, Clone)]
116struct WorktreeEntries {
117 worktree_id: WorktreeId,
118 visible_entries: Vec<GitPanelEntry>,
119 paths: Rc<OnceCell<HashSet<Arc<Path>>>>,
120}
121
122#[derive(Debug, Clone)]
123struct GitPanelEntry {
124 entry: Entry,
125 hunks: Rc<OnceCell<Vec<DiffHunk>>>,
126}
127
128impl Deref for GitPanelEntry {
129 type Target = Entry;
130
131 fn deref(&self) -> &Self::Target {
132 &self.entry
133 }
134}
135
136impl WorktreeEntries {
137 fn paths(&self) -> &HashSet<Arc<Path>> {
138 self.paths.get_or_init(|| {
139 self.visible_entries
140 .iter()
141 .map(|e| (e.entry.path.clone()))
142 .collect()
143 })
144 }
145}
146
147impl GitPanel {
148 pub fn load(
149 workspace: WeakView<Workspace>,
150 cx: AsyncWindowContext,
151 ) -> Task<Result<View<Self>>> {
152 cx.spawn(|mut cx| async move {
153 // Clippy incorrectly classifies this as a redundant closure
154 #[allow(clippy::redundant_closure)]
155 workspace.update(&mut cx, |workspace, cx| Self::new(workspace, cx))
156 })
157 }
158
159 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
160 let fs = workspace.app_state().fs.clone();
161 let weak_workspace = workspace.weak_handle();
162 let project = workspace.project().clone();
163
164 let git_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
165 let focus_handle = cx.focus_handle();
166 cx.on_focus(&focus_handle, Self::focus_in).detach();
167 cx.on_focus_out(&focus_handle, |this, _, cx| {
168 this.hide_scrollbar(cx);
169 })
170 .detach();
171 cx.subscribe(&project, |this, project, event, cx| match event {
172 project::Event::WorktreeRemoved(id) => {
173 this.expanded_dir_ids.remove(id);
174 this.update_visible_entries(None, None, cx);
175 cx.notify();
176 }
177 project::Event::WorktreeOrderChanged => {
178 this.update_visible_entries(None, None, cx);
179 cx.notify();
180 }
181 project::Event::WorktreeUpdatedEntries(id, _)
182 | project::Event::WorktreeAdded(id)
183 | project::Event::WorktreeUpdatedGitRepositories(id) => {
184 this.update_visible_entries(Some(*id), None, cx);
185 cx.notify();
186 }
187 project::Event::Closed => {
188 this.git_diff_editor_updates = Task::ready(());
189 this.expanded_dir_ids.clear();
190 this.visible_entries.clear();
191 this.git_diff_editor = diff_display_editor(project.clone(), cx);
192 }
193 _ => {}
194 })
195 .detach();
196
197 let scroll_handle = UniformListScrollHandle::new();
198
199 let mut this = Self {
200 workspace: weak_workspace,
201 focus_handle: cx.focus_handle(),
202 fs,
203 pending_serialization: Task::ready(None),
204 visible_entries: Vec::new(),
205 current_modifiers: cx.modifiers(),
206 expanded_dir_ids: Default::default(),
207
208 width: Some(px(360.)),
209 scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()),
210 scroll_handle,
211 selected_item: None,
212 show_scrollbar: !Self::should_autohide_scrollbar(cx),
213 hide_scrollbar_task: None,
214 git_diff_editor: diff_display_editor(project.clone(), cx),
215 git_diff_editor_updates: Task::ready(()),
216 reveal_in_editor: Task::ready(()),
217 project,
218 };
219 this.update_visible_entries(None, None, cx);
220 this
221 });
222
223 git_panel
224 }
225
226 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
227 let width = self.width;
228 self.pending_serialization = cx.background_executor().spawn(
229 async move {
230 KEY_VALUE_STORE
231 .write_kvp(
232 GIT_PANEL_KEY.into(),
233 serde_json::to_string(&SerializedGitPanel { width })?,
234 )
235 .await?;
236 anyhow::Ok(())
237 }
238 .log_err(),
239 );
240 }
241
242 fn dispatch_context(&self) -> KeyContext {
243 let mut dispatch_context = KeyContext::new_with_defaults();
244 dispatch_context.add("GitPanel");
245 dispatch_context.add("menu");
246
247 dispatch_context
248 }
249
250 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
251 if !self.focus_handle.contains_focused(cx) {
252 cx.emit(Event::Focus);
253 }
254 }
255
256 fn should_show_scrollbar(_cx: &AppContext) -> bool {
257 // TODO: plug into settings
258 true
259 }
260
261 fn should_autohide_scrollbar(_cx: &AppContext) -> bool {
262 // TODO: plug into settings
263 true
264 }
265
266 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
267 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
268 if !Self::should_autohide_scrollbar(cx) {
269 return;
270 }
271 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
272 cx.background_executor()
273 .timer(SCROLLBAR_SHOW_INTERVAL)
274 .await;
275 panel
276 .update(&mut cx, |panel, cx| {
277 panel.show_scrollbar = false;
278 cx.notify();
279 })
280 .log_err();
281 }))
282 }
283
284 fn handle_modifiers_changed(
285 &mut self,
286 event: &ModifiersChangedEvent,
287 cx: &mut ViewContext<Self>,
288 ) {
289 self.current_modifiers = event.modifiers;
290 cx.notify();
291 }
292
293 fn calculate_depth_and_difference(
294 entry: &Entry,
295 visible_worktree_entries: &HashSet<Arc<Path>>,
296 ) -> (usize, usize) {
297 let (depth, difference) = entry
298 .path
299 .ancestors()
300 .skip(1) // Skip the entry itself
301 .find_map(|ancestor| {
302 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
303 let entry_path_components_count = entry.path.components().count();
304 let parent_path_components_count = parent_entry.components().count();
305 let difference = entry_path_components_count - parent_path_components_count;
306 let depth = parent_entry
307 .ancestors()
308 .skip(1)
309 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
310 .count();
311 Some((depth + 1, difference))
312 } else {
313 None
314 }
315 })
316 .unwrap_or((0, 0));
317
318 (depth, difference)
319 }
320
321 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
322 let item_count = self
323 .visible_entries
324 .iter()
325 .map(|worktree_entries| worktree_entries.visible_entries.len())
326 .sum::<usize>();
327 if item_count == 0 {
328 return;
329 }
330 let selection = match self.selected_item {
331 Some(i) => {
332 if i < item_count - 1 {
333 self.selected_item = Some(i + 1);
334 i + 1
335 } else {
336 self.selected_item = Some(0);
337 0
338 }
339 }
340 None => {
341 self.selected_item = Some(0);
342 0
343 }
344 };
345 self.scroll_handle
346 .scroll_to_item(selection, ScrollStrategy::Center);
347
348 let mut hunks = None;
349 self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
350 hunks = Some(entry.hunks.clone());
351 });
352 if let Some(hunks) = hunks {
353 self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
354 }
355
356 cx.notify();
357 }
358
359 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
360 let item_count = self
361 .visible_entries
362 .iter()
363 .map(|worktree_entries| worktree_entries.visible_entries.len())
364 .sum::<usize>();
365 if item_count == 0 {
366 return;
367 }
368 let selection = match self.selected_item {
369 Some(i) => {
370 if i > 0 {
371 self.selected_item = Some(i - 1);
372 i - 1
373 } else {
374 self.selected_item = Some(item_count - 1);
375 item_count - 1
376 }
377 }
378 None => {
379 self.selected_item = Some(0);
380 0
381 }
382 };
383 self.scroll_handle
384 .scroll_to_item(selection, ScrollStrategy::Center);
385
386 let mut hunks = None;
387 self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| {
388 hunks = Some(entry.hunks.clone());
389 });
390 if let Some(hunks) = hunks {
391 self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx);
392 }
393
394 cx.notify();
395 }
396}
397
398impl GitPanel {
399 fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext<Self>) {
400 // TODO: Implement stage all
401 println!("Stage all triggered");
402 }
403
404 fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext<Self>) {
405 // TODO: Implement unstage all
406 println!("Unstage all triggered");
407 }
408
409 fn discard_all(&mut self, _: &DiscardAll, _cx: &mut ViewContext<Self>) {
410 // TODO: Implement discard all
411 println!("Discard all triggered");
412 }
413
414 /// Commit all staged changes
415 fn commit_staged_changes(&mut self, _: &CommitStagedChanges, _cx: &mut ViewContext<Self>) {
416 // TODO: Implement commit all staged
417 println!("Commit staged changes triggered");
418 }
419
420 /// Commit all changes, regardless of whether they are staged or not
421 fn commit_all_changes(&mut self, _: &CommitAllChanges, _cx: &mut ViewContext<Self>) {
422 // TODO: Implement commit all changes
423 println!("Commit all changes triggered");
424 }
425
426 fn all_staged(&self) -> bool {
427 // TODO: Implement all_staged
428 true
429 }
430
431 fn no_entries(&self) -> bool {
432 self.visible_entries.is_empty()
433 }
434
435 fn entry_count(&self) -> usize {
436 self.visible_entries
437 .iter()
438 .map(|worktree_entries| {
439 worktree_entries
440 .visible_entries
441 .iter()
442 .filter(|entry| entry.git_status.is_some())
443 .count()
444 })
445 .sum()
446 }
447
448 fn for_each_visible_entry(
449 &self,
450 range: Range<usize>,
451 cx: &mut ViewContext<Self>,
452 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<Self>),
453 ) {
454 let mut ix = 0;
455 for worktree_entries in &self.visible_entries {
456 if ix >= range.end {
457 return;
458 }
459
460 if ix + worktree_entries.visible_entries.len() <= range.start {
461 ix += worktree_entries.visible_entries.len();
462 continue;
463 }
464
465 let end_ix = range.end.min(ix + worktree_entries.visible_entries.len());
466 // let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
467 if let Some(worktree) = self
468 .project
469 .read(cx)
470 .worktree_for_id(worktree_entries.worktree_id, cx)
471 {
472 let snapshot = worktree.read(cx).snapshot();
473 let root_name = OsStr::new(snapshot.root_name());
474 let expanded_entry_ids = self
475 .expanded_dir_ids
476 .get(&snapshot.id())
477 .map(Vec::as_slice)
478 .unwrap_or(&[]);
479
480 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
481 let entries = worktree_entries.paths();
482
483 let index_start = entry_range.start;
484 for (i, entry) in worktree_entries.visible_entries[entry_range]
485 .iter()
486 .enumerate()
487 {
488 let index = index_start + i;
489 let status = entry.git_status;
490 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
491
492 let (depth, difference) = Self::calculate_depth_and_difference(entry, entries);
493
494 let filename = match difference {
495 diff if diff > 1 => entry
496 .path
497 .iter()
498 .skip(entry.path.components().count() - diff)
499 .collect::<PathBuf>()
500 .to_str()
501 .unwrap_or_default()
502 .to_string(),
503 _ => entry
504 .path
505 .file_name()
506 .map(|name| name.to_string_lossy().into_owned())
507 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
508 };
509
510 let details = EntryDetails {
511 filename,
512 display_name: entry.path.to_string_lossy().into_owned(),
513 kind: entry.kind,
514 is_expanded,
515 path: entry.path.clone(),
516 status,
517 hunks: entry.hunks.clone(),
518 depth,
519 index,
520 };
521 callback(entry.id, details, cx);
522 }
523 }
524 ix = end_ix;
525 }
526 }
527
528 // TODO: Update expanded directory state
529 // TODO: Updates happen in the main loop, could be long for large workspaces
530 fn update_visible_entries(
531 &mut self,
532 for_worktree: Option<WorktreeId>,
533 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
534 cx: &mut ViewContext<Self>,
535 ) {
536 let project = self.project.read(cx);
537 let mut old_entries_removed = false;
538 let mut after_update = Vec::new();
539 self.visible_entries
540 .retain(|worktree_entries| match for_worktree {
541 Some(for_worktree) => {
542 if worktree_entries.worktree_id == for_worktree {
543 old_entries_removed = true;
544 false
545 } else if old_entries_removed {
546 after_update.push(worktree_entries.clone());
547 false
548 } else {
549 true
550 }
551 }
552 None => false,
553 });
554 for worktree in project.visible_worktrees(cx) {
555 let worktree_id = worktree.read(cx).id();
556 if for_worktree.is_some() && for_worktree != Some(worktree_id) {
557 continue;
558 }
559 let snapshot = worktree.read(cx).snapshot();
560
561 let mut visible_worktree_entries = snapshot
562 .entries(false, 0)
563 .filter(|entry| !entry.is_external)
564 .filter(|entry| entry.git_status.is_some())
565 .cloned()
566 .collect::<Vec<_>>();
567 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
568 project::sort_worktree_entries(&mut visible_worktree_entries);
569
570 if !visible_worktree_entries.is_empty() {
571 self.visible_entries.push(WorktreeEntries {
572 worktree_id,
573 visible_entries: visible_worktree_entries
574 .into_iter()
575 .map(|entry| GitPanelEntry {
576 entry,
577 hunks: Rc::default(),
578 })
579 .collect(),
580 paths: Rc::default(),
581 });
582 }
583 }
584 self.visible_entries.extend(after_update);
585
586 if let Some((worktree_id, entry_id)) = new_selected_entry {
587 self.selected_item = self.visible_entries.iter().enumerate().find_map(
588 |(worktree_index, worktree_entries)| {
589 if worktree_entries.worktree_id == worktree_id {
590 worktree_entries
591 .visible_entries
592 .iter()
593 .position(|entry| entry.id == entry_id)
594 .map(|entry_index| {
595 worktree_index * worktree_entries.visible_entries.len()
596 + entry_index
597 })
598 } else {
599 None
600 }
601 },
602 );
603 }
604
605 let project = self.project.clone();
606 self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move {
607 cx.background_executor()
608 .timer(UPDATE_DEBOUNCE)
609 .await;
610 let Some(project_buffers) = git_panel
611 .update(&mut cx, |git_panel, cx| {
612 futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map(
613 move |worktree_entries| {
614 worktree_entries
615 .visible_entries
616 .iter()
617 .filter_map(|entry| {
618 let git_status = entry.git_status()?;
619 let entry_hunks = entry.hunks.clone();
620 let (entry_path, unstaged_changes_task) =
621 project.update(cx, |project, cx| {
622 let entry_path =
623 project.path_for_entry(entry.id, cx)?;
624 let open_task =
625 project.open_path(entry_path.clone(), cx);
626 let unstaged_changes_task =
627 cx.spawn(|project, mut cx| async move {
628 let (_, opened_model) = open_task
629 .await
630 .context("opening buffer")?;
631 let buffer = opened_model
632 .downcast::<Buffer>()
633 .map_err(|_| {
634 anyhow::anyhow!(
635 "accessing buffer for entry"
636 )
637 })?;
638 // TODO added files have noop changes and those are not expanded properly in the multi buffer
639 let unstaged_changes = project
640 .update(&mut cx, |project, cx| {
641 project.open_unstaged_changes(
642 buffer.clone(),
643 cx,
644 )
645 })?
646 .await
647 .context("opening unstaged changes")?;
648
649 let hunks = cx.update(|cx| {
650 entry_hunks
651 .get_or_init(|| {
652 match git_status {
653 GitFileStatus::Added => {
654 let buffer_snapshot = buffer.read(cx).snapshot();
655 let entire_buffer_range =
656 buffer_snapshot.anchor_after(0)
657 ..buffer_snapshot
658 .anchor_before(
659 buffer_snapshot.len(),
660 );
661 let entire_buffer_point_range =
662 entire_buffer_range
663 .clone()
664 .to_point(&buffer_snapshot);
665
666 vec![DiffHunk {
667 row_range: entire_buffer_point_range
668 .start
669 .row
670 ..entire_buffer_point_range
671 .end
672 .row,
673 buffer_range: entire_buffer_range,
674 diff_base_byte_range: 0..0,
675 }]
676 }
677 GitFileStatus::Modified => {
678 let buffer_snapshot =
679 buffer.read(cx).snapshot();
680 unstaged_changes.read(cx)
681 .diff_to_buffer
682 .hunks_in_row_range(
683 0..BufferRow::MAX,
684 &buffer_snapshot,
685 )
686 .collect()
687 }
688 // TODO support conflicts display
689 GitFileStatus::Conflict => Vec::new(),
690 }
691 }).clone()
692 })?;
693
694 anyhow::Ok((buffer, unstaged_changes, hunks))
695 });
696 Some((entry_path, unstaged_changes_task))
697 })?;
698 Some((entry_path, unstaged_changes_task))
699 })
700 .map(|(entry_path, open_task)| async move {
701 (entry_path, open_task.await)
702 })
703 .collect::<Vec<_>>()
704 },
705 ))
706 })
707 .ok()
708 else {
709 return;
710 };
711
712 let project_buffers = project_buffers.await;
713 if project_buffers.is_empty() {
714 return;
715 }
716 let mut change_sets = Vec::with_capacity(project_buffers.len());
717 if let Some(buffer_update_task) = git_panel
718 .update(&mut cx, |git_panel, cx| {
719 let editor = git_panel.git_diff_editor.clone();
720 let multi_buffer = editor.read(cx).buffer().clone();
721 let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len());
722 for (buffer_path, open_result) in project_buffers {
723 if let Some((buffer, unstaged_changes, diff_hunks)) = open_result
724 .with_context(|| format!("opening buffer {buffer_path:?}"))
725 .log_err()
726 {
727 change_sets.push(unstaged_changes);
728 buffers_with_ranges.push((
729 buffer,
730 diff_hunks
731 .into_iter()
732 .map(|hunk| hunk.buffer_range)
733 .collect(),
734 ));
735 }
736 }
737
738 multi_buffer.update(cx, |multi_buffer, cx| {
739 multi_buffer.clear(cx);
740 multi_buffer.push_multiple_excerpts_with_context_lines(
741 buffers_with_ranges,
742 DEFAULT_MULTIBUFFER_CONTEXT,
743 cx,
744 )
745 })
746 })
747 .ok()
748 {
749 buffer_update_task.await;
750 git_panel
751 .update(&mut cx, |git_panel, cx| {
752 git_panel.git_diff_editor.update(cx, |editor, cx| {
753 for change_set in change_sets {
754 editor.add_change_set(change_set, cx);
755 }
756 })
757 })
758 .ok();
759 }
760 });
761
762 cx.notify();
763 }
764}
765
766impl GitPanel {
767 pub fn panel_button(
768 &self,
769 id: impl Into<SharedString>,
770 label: impl Into<SharedString>,
771 ) -> Button {
772 let id = id.into().clone();
773 let label = label.into().clone();
774
775 Button::new(id, label)
776 .label_size(LabelSize::Small)
777 .layer(ElevationIndex::ElevatedSurface)
778 .size(ButtonSize::Compact)
779 .style(ButtonStyle::Filled)
780 }
781
782 pub fn render_divider(&self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
783 h_flex()
784 .items_center()
785 .h(px(8.))
786 .child(Divider::horizontal_dashed().color(DividerColor::Border))
787 }
788
789 pub fn render_panel_header(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
790 let focus_handle = self.focus_handle(cx).clone();
791
792 let changes_string = format!("{} changes", self.entry_count());
793
794 h_flex()
795 .h(px(32.))
796 .items_center()
797 .px_3()
798 .bg(ElevationIndex::Surface.bg(cx))
799 .child(
800 h_flex()
801 .gap_2()
802 .child(Checkbox::new("all-changes", true.into()).disabled(true))
803 .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)),
804 )
805 .child(div().flex_grow())
806 .child(
807 h_flex()
808 .gap_2()
809 .child(
810 IconButton::new("discard-changes", IconName::Undo)
811 .tooltip(move |cx| {
812 let focus_handle = focus_handle.clone();
813
814 Tooltip::for_action_in(
815 "Discard all changes",
816 &DiscardAll,
817 &focus_handle,
818 cx,
819 )
820 })
821 .icon_size(IconSize::Small)
822 .disabled(true),
823 )
824 .child(if self.all_staged() {
825 self.panel_button("unstage-all", "Unstage All").on_click(
826 cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(DiscardAll))),
827 )
828 } else {
829 self.panel_button("stage-all", "Stage All").on_click(
830 cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))),
831 )
832 }),
833 )
834 }
835
836 pub fn render_commit_editor(&self, cx: &ViewContext<Self>) -> impl IntoElement {
837 let focus_handle_1 = self.focus_handle(cx).clone();
838 let focus_handle_2 = self.focus_handle(cx).clone();
839
840 let commit_staged_button = self
841 .panel_button("commit-staged-changes", "Commit")
842 .tooltip(move |cx| {
843 let focus_handle = focus_handle_1.clone();
844 Tooltip::for_action_in(
845 "Commit all staged changes",
846 &CommitStagedChanges,
847 &focus_handle,
848 cx,
849 )
850 })
851 .on_click(cx.listener(|this, _: &ClickEvent, cx| {
852 this.commit_staged_changes(&CommitStagedChanges, cx)
853 }));
854
855 let commit_all_button = self
856 .panel_button("commit-all-changes", "Commit All")
857 .tooltip(move |cx| {
858 let focus_handle = focus_handle_2.clone();
859 Tooltip::for_action_in(
860 "Commit all changes, including unstaged changes",
861 &CommitAllChanges,
862 &focus_handle,
863 cx,
864 )
865 })
866 .on_click(cx.listener(|this, _: &ClickEvent, cx| {
867 this.commit_all_changes(&CommitAllChanges, cx)
868 }));
869
870 div().w_full().h(px(140.)).px_2().pt_1().pb_2().child(
871 v_flex()
872 .h_full()
873 .py_2p5()
874 .px_3()
875 .bg(cx.theme().colors().editor_background)
876 .font_buffer(cx)
877 .text_ui_sm(cx)
878 .text_color(cx.theme().colors().text_muted)
879 .child("Add a message")
880 .gap_1()
881 .child(div().flex_grow())
882 .child(h_flex().child(div().gap_1().flex_grow()).child(
883 if self.current_modifiers.alt {
884 commit_all_button
885 } else {
886 commit_staged_button
887 },
888 ))
889 .cursor(CursorStyle::OperationNotAllowed)
890 .opacity(0.5),
891 )
892 }
893
894 fn render_empty_state(&self, cx: &ViewContext<Self>) -> impl IntoElement {
895 h_flex()
896 .h_full()
897 .flex_1()
898 .justify_center()
899 .items_center()
900 .child(
901 v_flex()
902 .gap_3()
903 .child("No changes to commit")
904 .text_ui_sm(cx)
905 .mx_auto()
906 .text_color(Color::Placeholder.color(cx)),
907 )
908 }
909
910 fn render_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
911 if !Self::should_show_scrollbar(cx)
912 || !(self.show_scrollbar || self.scrollbar_state.is_dragging())
913 {
914 return None;
915 }
916 Some(
917 div()
918 .occlude()
919 .id("project-panel-vertical-scroll")
920 .on_mouse_move(cx.listener(|_, _, cx| {
921 cx.notify();
922 cx.stop_propagation()
923 }))
924 .on_hover(|_, cx| {
925 cx.stop_propagation();
926 })
927 .on_any_mouse_down(|_, cx| {
928 cx.stop_propagation();
929 })
930 .on_mouse_up(
931 MouseButton::Left,
932 cx.listener(|this, _, cx| {
933 if !this.scrollbar_state.is_dragging()
934 && !this.focus_handle.contains_focused(cx)
935 {
936 this.hide_scrollbar(cx);
937 cx.notify();
938 }
939
940 cx.stop_propagation();
941 }),
942 )
943 .on_scroll_wheel(cx.listener(|_, _, cx| {
944 cx.notify();
945 }))
946 .h_full()
947 .absolute()
948 .right_1()
949 .top_1()
950 .bottom_1()
951 .w(px(12.))
952 .cursor_default()
953 .children(Scrollbar::vertical(
954 // percentage as f32..end_offset as f32,
955 self.scrollbar_state.clone(),
956 )),
957 )
958 }
959
960 fn render_entries(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
961 let item_count = self
962 .visible_entries
963 .iter()
964 .map(|worktree_entries| worktree_entries.visible_entries.len())
965 .sum();
966 let selected_entry = self.selected_item;
967 h_flex()
968 .size_full()
969 .overflow_hidden()
970 .child(
971 uniform_list(cx.view().clone(), "entries", item_count, {
972 move |git_panel, range, cx| {
973 let mut items = Vec::with_capacity(range.end - range.start);
974 git_panel.for_each_visible_entry(range, cx, |id, details, cx| {
975 items.push(git_panel.render_entry(
976 id,
977 Some(details.index) == selected_entry,
978 details,
979 cx,
980 ));
981 });
982 items
983 }
984 })
985 .size_full()
986 .with_sizing_behavior(ListSizingBehavior::Infer)
987 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
988 // .with_width_from_item(self.max_width_item_index)
989 .track_scroll(self.scroll_handle.clone()),
990 )
991 .children(self.render_scrollbar(cx))
992 }
993
994 fn render_entry(
995 &self,
996 id: ProjectEntryId,
997 selected: bool,
998 details: EntryDetails,
999 cx: &ViewContext<Self>,
1000 ) -> impl IntoElement {
1001 let id = id.to_proto() as usize;
1002 let checkbox_id = ElementId::Name(format!("checkbox_{}", id).into());
1003 let is_staged = ToggleState::Selected;
1004 let handle = cx.view().clone();
1005
1006 h_flex()
1007 .id(id)
1008 .h(px(28.))
1009 .w_full()
1010 .pl(px(12. + 12. * details.depth as f32))
1011 .pr(px(4.))
1012 .items_center()
1013 .gap_2()
1014 .font_buffer(cx)
1015 .text_ui_sm(cx)
1016 .when(!details.is_dir(), |this| {
1017 this.child(Checkbox::new(checkbox_id, is_staged))
1018 })
1019 .when_some(details.status, |this, status| {
1020 this.child(git_status_icon(status))
1021 })
1022 .child(
1023 ListItem::new(("label", id))
1024 .toggle_state(selected)
1025 .child(h_flex().gap_1p5().child(details.display_name.clone()))
1026 .on_click(move |e, cx| {
1027 handle.update(cx, |git_panel, cx| {
1028 git_panel.selected_item = Some(details.index);
1029 let change_focus = e.down.click_count > 1;
1030 git_panel.reveal_entry_in_git_editor(
1031 details.hunks.clone(),
1032 change_focus,
1033 None,
1034 cx,
1035 );
1036 });
1037 }),
1038 )
1039 }
1040
1041 fn reveal_entry_in_git_editor(
1042 &mut self,
1043 hunks: Rc<OnceCell<Vec<DiffHunk>>>,
1044 change_focus: bool,
1045 debounce: Option<Duration>,
1046 cx: &mut ViewContext<'_, Self>,
1047 ) {
1048 let workspace = self.workspace.clone();
1049 let diff_editor = self.git_diff_editor.clone();
1050 self.reveal_in_editor = cx.spawn(|_, mut cx| async move {
1051 if let Some(debounce) = debounce {
1052 cx.background_executor().timer(debounce).await;
1053 }
1054
1055 let Some(editor) = workspace
1056 .update(&mut cx, |workspace, cx| {
1057 let git_diff_editor = workspace
1058 .items_of_type::<Editor>(cx)
1059 .find(|editor| &diff_editor == editor);
1060 match git_diff_editor {
1061 Some(existing_editor) => {
1062 workspace.activate_item(&existing_editor, true, change_focus, cx);
1063 existing_editor
1064 }
1065 None => {
1066 workspace.active_pane().update(cx, |pane, cx| {
1067 pane.add_item(
1068 diff_editor.boxed_clone(),
1069 true,
1070 change_focus,
1071 None,
1072 cx,
1073 )
1074 });
1075 diff_editor.clone()
1076 }
1077 }
1078 })
1079 .ok()
1080 else {
1081 return;
1082 };
1083
1084 if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) {
1085 let hunk_buffer_range = &first_hunk.buffer_range;
1086 if let Some(buffer_id) = hunk_buffer_range
1087 .start
1088 .buffer_id
1089 .or_else(|| first_hunk.buffer_range.end.buffer_id)
1090 {
1091 editor
1092 .update(&mut cx, |editor, cx| {
1093 let multi_buffer = editor.buffer().read(cx);
1094 let buffer = multi_buffer.buffer(buffer_id)?;
1095 let buffer_snapshot = buffer.read(cx).snapshot();
1096 let (excerpt_id, _) = multi_buffer
1097 .excerpts_for_buffer(&buffer, cx)
1098 .into_iter()
1099 .find(|(_, excerpt)| {
1100 hunk_buffer_range
1101 .start
1102 .cmp(&excerpt.context.start, &buffer_snapshot)
1103 .is_ge()
1104 && hunk_buffer_range
1105 .end
1106 .cmp(&excerpt.context.end, &buffer_snapshot)
1107 .is_le()
1108 })?;
1109 let multi_buffer_hunk_start = multi_buffer
1110 .snapshot(cx)
1111 .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?;
1112 editor.change_selections(
1113 Some(Autoscroll::Strategy(AutoscrollStrategy::Center)),
1114 cx,
1115 |s| {
1116 s.select_ranges(Some(
1117 multi_buffer_hunk_start..multi_buffer_hunk_start,
1118 ))
1119 },
1120 );
1121 cx.notify();
1122 Some(())
1123 })
1124 .ok()
1125 .flatten();
1126 }
1127 }
1128 });
1129 }
1130}
1131
1132impl Render for GitPanel {
1133 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1134 let project = self.project.read(cx);
1135
1136 v_flex()
1137 .id("git_panel")
1138 .key_context(self.dispatch_context())
1139 .track_focus(&self.focus_handle)
1140 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
1141 .when(!project.is_read_only(cx), |this| {
1142 this.on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx)))
1143 .on_action(
1144 cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)),
1145 )
1146 .on_action(
1147 cx.listener(|this, &DiscardAll, cx| this.discard_all(&DiscardAll, cx)),
1148 )
1149 .on_action(cx.listener(|this, &CommitStagedChanges, cx| {
1150 this.commit_staged_changes(&CommitStagedChanges, cx)
1151 }))
1152 .on_action(cx.listener(|this, &CommitAllChanges, cx| {
1153 this.commit_all_changes(&CommitAllChanges, cx)
1154 }))
1155 })
1156 .on_action(cx.listener(Self::select_next))
1157 .on_action(cx.listener(Self::select_prev))
1158 .on_hover(cx.listener(|this, hovered, cx| {
1159 if *hovered {
1160 this.show_scrollbar = true;
1161 this.hide_scrollbar_task.take();
1162 cx.notify();
1163 } else if !this.focus_handle.contains_focused(cx) {
1164 this.hide_scrollbar(cx);
1165 }
1166 }))
1167 .size_full()
1168 .overflow_hidden()
1169 .font_buffer(cx)
1170 .py_1()
1171 .bg(ElevationIndex::Surface.bg(cx))
1172 .child(self.render_panel_header(cx))
1173 .child(self.render_divider(cx))
1174 .child(if !self.no_entries() {
1175 self.render_entries(cx).into_any_element()
1176 } else {
1177 self.render_empty_state(cx).into_any_element()
1178 })
1179 .child(self.render_divider(cx))
1180 .child(self.render_commit_editor(cx))
1181 }
1182}
1183
1184impl FocusableView for GitPanel {
1185 fn focus_handle(&self, _: &AppContext) -> gpui::FocusHandle {
1186 self.focus_handle.clone()
1187 }
1188}
1189
1190impl EventEmitter<Event> for GitPanel {}
1191
1192impl EventEmitter<PanelEvent> for GitPanel {}
1193
1194impl Panel for GitPanel {
1195 fn persistent_name() -> &'static str {
1196 "GitPanel"
1197 }
1198
1199 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
1200 GitPanelSettings::get_global(cx).dock
1201 }
1202
1203 fn position_is_valid(&self, position: DockPosition) -> bool {
1204 matches!(position, DockPosition::Left | DockPosition::Right)
1205 }
1206
1207 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1208 settings::update_settings_file::<GitPanelSettings>(
1209 self.fs.clone(),
1210 cx,
1211 move |settings, _| settings.dock = Some(position),
1212 );
1213 }
1214
1215 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
1216 self.width
1217 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
1218 }
1219
1220 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1221 self.width = size;
1222 self.serialize(cx);
1223 cx.notify();
1224 }
1225
1226 fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
1227 Some(ui::IconName::GitBranch).filter(|_| GitPanelSettings::get_global(cx).button)
1228 }
1229
1230 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1231 Some("Git Panel")
1232 }
1233
1234 fn toggle_action(&self) -> Box<dyn Action> {
1235 Box::new(ToggleFocus)
1236 }
1237}
1238
1239fn diff_display_editor(project: Model<Project>, cx: &mut WindowContext) -> View<Editor> {
1240 cx.new_view(|cx| {
1241 let multi_buffer = cx.new_model(|cx| {
1242 MultiBuffer::new(project.read(cx).capability()).with_title("Project diff".to_string())
1243 });
1244 let mut editor = Editor::for_multibuffer(multi_buffer, Some(project), true, cx);
1245 editor.set_expand_all_diff_hunks();
1246 editor
1247 })
1248}