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