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