1use crate::askpass_modal::AskPassModal;
2use crate::commit_modal::CommitModal;
3use crate::commit_tooltip::CommitTooltip;
4use crate::commit_view::CommitView;
5use crate::project_diff::{self, Diff, ProjectDiff};
6use crate::remote_output::{self, RemoteAction, SuccessMessage};
7use crate::{branch_picker, picker_prompt, render_remote_button};
8use crate::{
9 file_history_view::FileHistoryView, git_panel_settings::GitPanelSettings, git_status_icon,
10 repository_selector::RepositorySelector,
11};
12use agent_settings::AgentSettings;
13use anyhow::Context as _;
14use askpass::AskPassDelegate;
15use cloud_llm_client::CompletionIntent;
16use collections::{BTreeMap, HashMap, HashSet};
17use db::kvp::KEY_VALUE_STORE;
18use editor::RewrapOptions;
19use editor::{
20 Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
21 actions::ExpandAllDiffHunks,
22};
23use futures::StreamExt as _;
24use git::commit::ParsedCommitMessage;
25use git::repository::{
26 Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
27 PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
28 UpstreamTrackingStatus, get_git_committer,
29};
30use git::stash::GitStash;
31use git::status::StageStatus;
32use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
33use git::{
34 ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
35 StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
36};
37use gpui::{
38 Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
39 EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
40 PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
41 anchored, deferred, point, size, uniform_list,
42};
43use itertools::Itertools;
44use language::{Buffer, File};
45use language_model::{
46 ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
47 Role, ZED_CLOUD_PROVIDER_ID,
48};
49use menu;
50use multi_buffer::ExcerptInfo;
51use notifications::status_toast::{StatusToast, ToastIcon};
52use panel::{
53 PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
54 panel_icon_button,
55};
56use project::{
57 Fs, Project, ProjectPath,
58 git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
59 project_settings::{GitPathStyle, ProjectSettings},
60};
61use prompt_store::{BuiltInPrompt, PromptId, PromptStore, RULES_FILE_NAMES};
62use serde::{Deserialize, Serialize};
63use settings::{Settings, SettingsStore, StatusStyle};
64use std::future::Future;
65use std::ops::Range;
66use std::path::Path;
67use std::{sync::Arc, time::Duration, usize};
68use strum::{IntoEnumIterator, VariantNames};
69use time::OffsetDateTime;
70use ui::{
71 ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors,
72 PopoverMenu, RenderedIndentGuide, ScrollAxes, Scrollbars, SplitButton, Tooltip, WithScrollbar,
73 prelude::*,
74};
75use util::paths::PathStyle;
76use util::{ResultExt, TryFutureExt, maybe, rel_path::RelPath};
77use workspace::SERIALIZATION_THROTTLE_TIME;
78use workspace::{
79 Workspace,
80 dock::{DockPosition, Panel, PanelEvent},
81 notifications::{DetachAndPromptErr, ErrorMessagePrompt, NotificationId, NotifyResultExt},
82};
83actions!(
84 git_panel,
85 [
86 /// Closes the git panel.
87 Close,
88 /// Toggles focus on the git panel.
89 ToggleFocus,
90 /// Opens the git panel menu.
91 OpenMenu,
92 /// Focuses on the commit message editor.
93 FocusEditor,
94 /// Focuses on the changes list.
95 FocusChanges,
96 /// Select next git panel menu item, and show it in the diff view
97 NextEntry,
98 /// Select previous git panel menu item, and show it in the diff view
99 PreviousEntry,
100 /// Select first git panel menu item, and show it in the diff view
101 FirstEntry,
102 /// Select last git panel menu item, and show it in the diff view
103 LastEntry,
104 /// Toggles automatic co-author suggestions.
105 ToggleFillCoAuthors,
106 /// Toggles sorting entries by path vs status.
107 ToggleSortByPath,
108 /// Toggles showing entries in tree vs flat view.
109 ToggleTreeView,
110 /// Expands the selected entry to show its children.
111 ExpandSelectedEntry,
112 /// Collapses the selected entry to hide its children.
113 CollapseSelectedEntry,
114 ]
115);
116
117fn prompt<T>(
118 msg: &str,
119 detail: Option<&str>,
120 window: &mut Window,
121 cx: &mut App,
122) -> Task<anyhow::Result<T>>
123where
124 T: IntoEnumIterator + VariantNames + 'static,
125{
126 let rx = window.prompt(PromptLevel::Info, msg, detail, T::VARIANTS, cx);
127 cx.spawn(async move |_| Ok(T::iter().nth(rx.await?).unwrap()))
128}
129
130#[derive(strum::EnumIter, strum::VariantNames)]
131#[strum(serialize_all = "title_case")]
132enum TrashCancel {
133 Trash,
134 Cancel,
135}
136
137struct GitMenuState {
138 has_tracked_changes: bool,
139 has_staged_changes: bool,
140 has_unstaged_changes: bool,
141 has_new_changes: bool,
142 sort_by_path: bool,
143 has_stash_items: bool,
144 tree_view: bool,
145}
146
147fn git_panel_context_menu(
148 focus_handle: FocusHandle,
149 state: GitMenuState,
150 window: &mut Window,
151 cx: &mut App,
152) -> Entity<ContextMenu> {
153 ContextMenu::build(window, cx, move |context_menu, _, _| {
154 context_menu
155 .context(focus_handle)
156 .action_disabled_when(
157 !state.has_unstaged_changes,
158 "Stage All",
159 StageAll.boxed_clone(),
160 )
161 .action_disabled_when(
162 !state.has_staged_changes,
163 "Unstage All",
164 UnstageAll.boxed_clone(),
165 )
166 .separator()
167 .action_disabled_when(
168 !(state.has_new_changes || state.has_tracked_changes),
169 "Stash All",
170 StashAll.boxed_clone(),
171 )
172 .action_disabled_when(!state.has_stash_items, "Stash Pop", StashPop.boxed_clone())
173 .action("View Stash", zed_actions::git::ViewStash.boxed_clone())
174 .separator()
175 .action("Open Diff", project_diff::Diff.boxed_clone())
176 .separator()
177 .action_disabled_when(
178 !state.has_tracked_changes,
179 "Discard Tracked Changes",
180 RestoreTrackedFiles.boxed_clone(),
181 )
182 .action_disabled_when(
183 !state.has_new_changes,
184 "Trash Untracked Files",
185 TrashUntrackedFiles.boxed_clone(),
186 )
187 .separator()
188 .entry(
189 if state.tree_view {
190 "Flat View"
191 } else {
192 "Tree View"
193 },
194 Some(Box::new(ToggleTreeView)),
195 move |window, cx| window.dispatch_action(Box::new(ToggleTreeView), cx),
196 )
197 .when(!state.tree_view, |this| {
198 this.entry(
199 if state.sort_by_path {
200 "Sort by Status"
201 } else {
202 "Sort by Path"
203 },
204 Some(Box::new(ToggleSortByPath)),
205 move |window, cx| window.dispatch_action(Box::new(ToggleSortByPath), cx),
206 )
207 })
208 })
209}
210
211const GIT_PANEL_KEY: &str = "GitPanel";
212
213const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
214// TODO: We should revise this part. It seems the indentation width is not aligned with the one in project panel
215const TREE_INDENT: f32 = 16.0;
216
217pub fn register(workspace: &mut Workspace) {
218 workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
219 workspace.toggle_panel_focus::<GitPanel>(window, cx);
220 });
221 workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| {
222 CommitModal::toggle(workspace, None, window, cx)
223 });
224}
225
226#[derive(Debug, Clone)]
227pub enum Event {
228 Focus,
229}
230
231#[derive(Serialize, Deserialize)]
232struct SerializedGitPanel {
233 width: Option<Pixels>,
234 #[serde(default)]
235 amend_pending: bool,
236 #[serde(default)]
237 signoff_enabled: bool,
238}
239
240#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
241enum Section {
242 Conflict,
243 Tracked,
244 New,
245}
246
247#[derive(Debug, PartialEq, Eq, Clone)]
248struct GitHeaderEntry {
249 header: Section,
250}
251
252impl GitHeaderEntry {
253 pub fn contains(&self, status_entry: &GitStatusEntry, repo: &Repository) -> bool {
254 let this = &self.header;
255 let status = status_entry.status;
256 match this {
257 Section::Conflict => {
258 repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path)
259 }
260 Section::Tracked => !status.is_created(),
261 Section::New => status.is_created(),
262 }
263 }
264 pub fn title(&self) -> &'static str {
265 match self.header {
266 Section::Conflict => "Conflicts",
267 Section::Tracked => "Tracked",
268 Section::New => "Untracked",
269 }
270 }
271}
272
273#[derive(Debug, PartialEq, Eq, Clone)]
274enum GitListEntry {
275 Status(GitStatusEntry),
276 TreeStatus(GitTreeStatusEntry),
277 Directory(GitTreeDirEntry),
278 Header(GitHeaderEntry),
279}
280
281impl GitListEntry {
282 fn status_entry(&self) -> Option<&GitStatusEntry> {
283 match self {
284 GitListEntry::Status(entry) => Some(entry),
285 GitListEntry::TreeStatus(entry) => Some(&entry.entry),
286 _ => None,
287 }
288 }
289
290 fn directory_entry(&self) -> Option<&GitTreeDirEntry> {
291 match self {
292 GitListEntry::Directory(entry) => Some(entry),
293 _ => None,
294 }
295 }
296}
297
298enum GitPanelViewMode {
299 Flat,
300 Tree(TreeViewState),
301}
302
303impl GitPanelViewMode {
304 fn from_settings(cx: &App) -> Self {
305 if GitPanelSettings::get_global(cx).tree_view {
306 GitPanelViewMode::Tree(TreeViewState::default())
307 } else {
308 GitPanelViewMode::Flat
309 }
310 }
311
312 fn tree_state(&self) -> Option<&TreeViewState> {
313 match self {
314 GitPanelViewMode::Tree(state) => Some(state),
315 GitPanelViewMode::Flat => None,
316 }
317 }
318
319 fn tree_state_mut(&mut self) -> Option<&mut TreeViewState> {
320 match self {
321 GitPanelViewMode::Tree(state) => Some(state),
322 GitPanelViewMode::Flat => None,
323 }
324 }
325}
326
327#[derive(Default)]
328struct TreeViewState {
329 // Maps visible index to actual entry index.
330 // Length equals the number of visible entries.
331 // This is needed because some entries (like collapsed directories) may be hidden.
332 logical_indices: Vec<usize>,
333 expanded_dirs: HashMap<TreeKey, bool>,
334 directory_descendants: HashMap<TreeKey, Vec<GitStatusEntry>>,
335}
336
337impl TreeViewState {
338 fn build_tree_entries(
339 &mut self,
340 section: Section,
341 mut entries: Vec<GitStatusEntry>,
342 seen_directories: &mut HashSet<TreeKey>,
343 ) -> Vec<(GitListEntry, bool)> {
344 if entries.is_empty() {
345 return Vec::new();
346 }
347
348 entries.sort_by(|a, b| a.repo_path.cmp(&b.repo_path));
349
350 let mut root = TreeNode::default();
351 for entry in entries {
352 let components: Vec<&str> = entry.repo_path.components().collect();
353 if components.is_empty() {
354 root.files.push(entry);
355 continue;
356 }
357
358 let mut current = &mut root;
359 let mut current_path = String::new();
360
361 for (ix, component) in components.iter().enumerate() {
362 if ix == components.len() - 1 {
363 current.files.push(entry.clone());
364 } else {
365 if !current_path.is_empty() {
366 current_path.push('/');
367 }
368 current_path.push_str(component);
369 let dir_path = RepoPath::new(¤t_path)
370 .expect("repo path from status entry component");
371
372 let component = SharedString::from(component.to_string());
373
374 current = current
375 .children
376 .entry(component.clone())
377 .or_insert_with(|| TreeNode {
378 name: component,
379 path: Some(dir_path),
380 ..Default::default()
381 });
382 }
383 }
384 }
385
386 let (flattened, _) = self.flatten_tree(&root, section, 0, seen_directories);
387 flattened
388 }
389
390 fn flatten_tree(
391 &mut self,
392 node: &TreeNode,
393 section: Section,
394 depth: usize,
395 seen_directories: &mut HashSet<TreeKey>,
396 ) -> (Vec<(GitListEntry, bool)>, Vec<GitStatusEntry>) {
397 let mut all_statuses = Vec::new();
398 let mut flattened = Vec::new();
399
400 for child in node.children.values() {
401 let (terminal, name) = Self::compact_directory_chain(child);
402 let Some(path) = terminal.path.clone().or_else(|| child.path.clone()) else {
403 continue;
404 };
405 let (child_flattened, mut child_statuses) =
406 self.flatten_tree(terminal, section, depth + 1, seen_directories);
407 let key = TreeKey { section, path };
408 let expanded = *self.expanded_dirs.get(&key).unwrap_or(&true);
409 self.expanded_dirs.entry(key.clone()).or_insert(true);
410 seen_directories.insert(key.clone());
411
412 self.directory_descendants
413 .insert(key.clone(), child_statuses.clone());
414
415 flattened.push((
416 GitListEntry::Directory(GitTreeDirEntry {
417 key,
418 name,
419 depth,
420 expanded,
421 }),
422 true,
423 ));
424
425 if expanded {
426 flattened.extend(child_flattened);
427 } else {
428 flattened.extend(child_flattened.into_iter().map(|(child, _)| (child, false)));
429 }
430
431 all_statuses.append(&mut child_statuses);
432 }
433
434 for file in &node.files {
435 all_statuses.push(file.clone());
436 flattened.push((
437 GitListEntry::TreeStatus(GitTreeStatusEntry {
438 entry: file.clone(),
439 depth,
440 }),
441 true,
442 ));
443 }
444
445 (flattened, all_statuses)
446 }
447
448 fn compact_directory_chain(mut node: &TreeNode) -> (&TreeNode, SharedString) {
449 let mut parts = vec![node.name.clone()];
450 while node.files.is_empty() && node.children.len() == 1 {
451 let Some(child) = node.children.values().next() else {
452 continue;
453 };
454 if child.path.is_none() {
455 break;
456 }
457 parts.push(child.name.clone());
458 node = child;
459 }
460 let name = parts.join("/");
461 (node, SharedString::from(name))
462 }
463}
464
465#[derive(Debug, PartialEq, Eq, Clone)]
466struct GitTreeStatusEntry {
467 entry: GitStatusEntry,
468 depth: usize,
469}
470
471#[derive(Debug, PartialEq, Eq, Clone, Hash)]
472struct TreeKey {
473 section: Section,
474 path: RepoPath,
475}
476
477#[derive(Debug, PartialEq, Eq, Clone)]
478struct GitTreeDirEntry {
479 key: TreeKey,
480 name: SharedString,
481 depth: usize,
482 // staged_state: ToggleState,
483 expanded: bool,
484}
485
486#[derive(Default)]
487struct TreeNode {
488 name: SharedString,
489 path: Option<RepoPath>,
490 children: BTreeMap<SharedString, TreeNode>,
491 files: Vec<GitStatusEntry>,
492}
493
494#[derive(Debug, PartialEq, Eq, Clone)]
495pub struct GitStatusEntry {
496 pub(crate) repo_path: RepoPath,
497 pub(crate) status: FileStatus,
498 pub(crate) staging: StageStatus,
499}
500
501impl GitStatusEntry {
502 fn display_name(&self, path_style: PathStyle) -> String {
503 self.repo_path
504 .file_name()
505 .map(|name| name.to_owned())
506 .unwrap_or_else(|| self.repo_path.display(path_style).to_string())
507 }
508
509 fn parent_dir(&self, path_style: PathStyle) -> Option<String> {
510 self.repo_path
511 .parent()
512 .map(|parent| parent.display(path_style).to_string())
513 }
514}
515
516struct TruncatedPatch {
517 header: String,
518 hunks: Vec<String>,
519 hunks_to_keep: usize,
520}
521
522impl TruncatedPatch {
523 fn from_unified_diff(patch_str: &str) -> Option<Self> {
524 let lines: Vec<&str> = patch_str.lines().collect();
525 if lines.len() < 2 {
526 return None;
527 }
528 let header = format!("{}\n{}\n", lines[0], lines[1]);
529 let mut hunks = Vec::new();
530 let mut current_hunk = String::new();
531 for line in &lines[2..] {
532 if line.starts_with("@@") {
533 if !current_hunk.is_empty() {
534 hunks.push(current_hunk);
535 }
536 current_hunk = format!("{}\n", line);
537 } else if !current_hunk.is_empty() {
538 current_hunk.push_str(line);
539 current_hunk.push('\n');
540 }
541 }
542 if !current_hunk.is_empty() {
543 hunks.push(current_hunk);
544 }
545 if hunks.is_empty() {
546 return None;
547 }
548 let hunks_to_keep = hunks.len();
549 Some(TruncatedPatch {
550 header,
551 hunks,
552 hunks_to_keep,
553 })
554 }
555 fn calculate_size(&self) -> usize {
556 let mut size = self.header.len();
557 for (i, hunk) in self.hunks.iter().enumerate() {
558 if i < self.hunks_to_keep {
559 size += hunk.len();
560 }
561 }
562 size
563 }
564 fn to_string(&self) -> String {
565 let mut out = self.header.clone();
566 for (i, hunk) in self.hunks.iter().enumerate() {
567 if i < self.hunks_to_keep {
568 out.push_str(hunk);
569 }
570 }
571 let skipped_hunks = self.hunks.len() - self.hunks_to_keep;
572 if skipped_hunks > 0 {
573 out.push_str(&format!("[...skipped {} hunks...]\n", skipped_hunks));
574 }
575 out
576 }
577}
578
579pub struct GitPanel {
580 pub(crate) active_repository: Option<Entity<Repository>>,
581 pub(crate) commit_editor: Entity<Editor>,
582 conflicted_count: usize,
583 conflicted_staged_count: usize,
584 add_coauthors: bool,
585 generate_commit_message_task: Option<Task<Option<()>>>,
586 entries: Vec<GitListEntry>,
587 view_mode: GitPanelViewMode,
588 entries_indices: HashMap<RepoPath, usize>,
589 single_staged_entry: Option<GitStatusEntry>,
590 single_tracked_entry: Option<GitStatusEntry>,
591 focus_handle: FocusHandle,
592 fs: Arc<dyn Fs>,
593 new_count: usize,
594 entry_count: usize,
595 changes_count: usize,
596 new_staged_count: usize,
597 pending_commit: Option<Task<()>>,
598 amend_pending: bool,
599 original_commit_message: Option<String>,
600 signoff_enabled: bool,
601 pending_serialization: Task<()>,
602 pub(crate) project: Entity<Project>,
603 scroll_handle: UniformListScrollHandle,
604 max_width_item_index: Option<usize>,
605 selected_entry: Option<usize>,
606 marked_entries: Vec<usize>,
607 tracked_count: usize,
608 tracked_staged_count: usize,
609 update_visible_entries_task: Task<()>,
610 width: Option<Pixels>,
611 pub(crate) workspace: WeakEntity<Workspace>,
612 context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
613 modal_open: bool,
614 show_placeholders: bool,
615 local_committer: Option<GitCommitter>,
616 local_committer_task: Option<Task<()>>,
617 bulk_staging: Option<BulkStaging>,
618 stash_entries: GitStash,
619 _settings_subscription: Subscription,
620}
621
622#[derive(Clone, Debug, PartialEq, Eq)]
623struct BulkStaging {
624 repo_id: RepositoryId,
625 anchor: RepoPath,
626}
627
628const MAX_PANEL_EDITOR_LINES: usize = 6;
629
630pub(crate) fn commit_message_editor(
631 commit_message_buffer: Entity<Buffer>,
632 placeholder: Option<SharedString>,
633 project: Entity<Project>,
634 in_panel: bool,
635 window: &mut Window,
636 cx: &mut Context<Editor>,
637) -> Editor {
638 let buffer = cx.new(|cx| MultiBuffer::singleton(commit_message_buffer, cx));
639 let max_lines = if in_panel { MAX_PANEL_EDITOR_LINES } else { 18 };
640 let mut commit_editor = Editor::new(
641 EditorMode::AutoHeight {
642 min_lines: max_lines,
643 max_lines: Some(max_lines),
644 },
645 buffer,
646 None,
647 window,
648 cx,
649 );
650 commit_editor.set_collaboration_hub(Box::new(project));
651 commit_editor.set_use_autoclose(false);
652 commit_editor.set_show_gutter(false, cx);
653 commit_editor.set_use_modal_editing(true);
654 commit_editor.set_show_wrap_guides(false, cx);
655 commit_editor.set_show_indent_guides(false, cx);
656 let placeholder = placeholder.unwrap_or("Enter commit message".into());
657 commit_editor.set_placeholder_text(&placeholder, window, cx);
658 commit_editor
659}
660
661impl GitPanel {
662 fn new(
663 workspace: &mut Workspace,
664 window: &mut Window,
665 cx: &mut Context<Workspace>,
666 ) -> Entity<Self> {
667 let project = workspace.project().clone();
668 let app_state = workspace.app_state().clone();
669 let fs = app_state.fs.clone();
670 let git_store = project.read(cx).git_store().clone();
671 let active_repository = project.read(cx).active_repository(cx);
672
673 cx.new(|cx| {
674 let focus_handle = cx.focus_handle();
675 cx.on_focus(&focus_handle, window, Self::focus_in).detach();
676
677 let mut was_sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
678 let mut was_tree_view = GitPanelSettings::get_global(cx).tree_view;
679 cx.observe_global_in::<SettingsStore>(window, move |this, window, cx| {
680 let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
681 let tree_view = GitPanelSettings::get_global(cx).tree_view;
682 if tree_view != was_tree_view {
683 this.view_mode = GitPanelViewMode::from_settings(cx);
684 }
685 if sort_by_path != was_sort_by_path || tree_view != was_tree_view {
686 this.bulk_staging.take();
687 this.update_visible_entries(window, cx);
688 }
689 was_sort_by_path = sort_by_path;
690 was_tree_view = tree_view;
691 })
692 .detach();
693
694 // just to let us render a placeholder editor.
695 // Once the active git repo is set, this buffer will be replaced.
696 let temporary_buffer = cx.new(|cx| Buffer::local("", cx));
697 let commit_editor = cx.new(|cx| {
698 commit_message_editor(temporary_buffer, None, project.clone(), true, window, cx)
699 });
700
701 commit_editor.update(cx, |editor, cx| {
702 editor.clear(window, cx);
703 });
704
705 let scroll_handle = UniformListScrollHandle::new();
706
707 let mut was_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
708 let _settings_subscription = cx.observe_global::<SettingsStore>(move |_, cx| {
709 let is_ai_enabled = AgentSettings::get_global(cx).enabled(cx);
710 if was_ai_enabled != is_ai_enabled {
711 was_ai_enabled = is_ai_enabled;
712 cx.notify();
713 }
714 });
715
716 cx.subscribe_in(
717 &git_store,
718 window,
719 move |this, _git_store, event, window, cx| match event {
720 GitStoreEvent::ActiveRepositoryChanged(_) => {
721 this.active_repository = this.project.read(cx).active_repository(cx);
722 this.schedule_update(window, cx);
723 }
724 GitStoreEvent::RepositoryUpdated(
725 _,
726 RepositoryEvent::StatusesChanged
727 | RepositoryEvent::BranchChanged
728 | RepositoryEvent::MergeHeadsChanged,
729 true,
730 )
731 | GitStoreEvent::RepositoryAdded
732 | GitStoreEvent::RepositoryRemoved(_) => {
733 this.schedule_update(window, cx);
734 }
735 GitStoreEvent::IndexWriteError(error) => {
736 this.workspace
737 .update(cx, |workspace, cx| {
738 workspace.show_error(error, cx);
739 })
740 .ok();
741 }
742 GitStoreEvent::RepositoryUpdated(_, _, _) => {}
743 GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
744 },
745 )
746 .detach();
747
748 let mut this = Self {
749 active_repository,
750 commit_editor,
751 conflicted_count: 0,
752 conflicted_staged_count: 0,
753 add_coauthors: true,
754 generate_commit_message_task: None,
755 entries: Vec::new(),
756 view_mode: GitPanelViewMode::from_settings(cx),
757 entries_indices: HashMap::default(),
758 focus_handle: cx.focus_handle(),
759 fs,
760 new_count: 0,
761 new_staged_count: 0,
762 changes_count: 0,
763 pending_commit: None,
764 amend_pending: false,
765 original_commit_message: None,
766 signoff_enabled: false,
767 pending_serialization: Task::ready(()),
768 single_staged_entry: None,
769 single_tracked_entry: None,
770 project,
771 scroll_handle,
772 max_width_item_index: None,
773 selected_entry: None,
774 marked_entries: Vec::new(),
775 tracked_count: 0,
776 tracked_staged_count: 0,
777 update_visible_entries_task: Task::ready(()),
778 width: None,
779 show_placeholders: false,
780 local_committer: None,
781 local_committer_task: None,
782 context_menu: None,
783 workspace: workspace.weak_handle(),
784 modal_open: false,
785 entry_count: 0,
786 bulk_staging: None,
787 stash_entries: Default::default(),
788 _settings_subscription,
789 };
790
791 this.schedule_update(window, cx);
792 this
793 })
794 }
795
796 pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
797 self.entries_indices.get(path).copied()
798 }
799
800 pub fn select_entry_by_path(
801 &mut self,
802 path: ProjectPath,
803 window: &mut Window,
804 cx: &mut Context<Self>,
805 ) {
806 let Some(git_repo) = self.active_repository.as_ref() else {
807 return;
808 };
809
810 let (repo_path, section) = {
811 let repo = git_repo.read(cx);
812 let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
813 return;
814 };
815
816 let section = repo
817 .status_for_path(&repo_path)
818 .map(|status| status.status)
819 .map(|status| {
820 if repo.had_conflict_on_last_merge_head_change(&repo_path) {
821 Section::Conflict
822 } else if status.is_created() {
823 Section::New
824 } else {
825 Section::Tracked
826 }
827 });
828
829 (repo_path, section)
830 };
831
832 let mut needs_rebuild = false;
833 if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
834 let mut current_dir = repo_path.parent();
835 while let Some(dir) = current_dir {
836 let key = TreeKey {
837 section,
838 path: RepoPath::from_rel_path(dir),
839 };
840
841 if tree_state.expanded_dirs.get(&key) == Some(&false) {
842 tree_state.expanded_dirs.insert(key, true);
843 needs_rebuild = true;
844 }
845
846 current_dir = dir.parent();
847 }
848 }
849
850 if needs_rebuild {
851 self.update_visible_entries(window, cx);
852 }
853
854 let Some(ix) = self.entry_by_path(&repo_path) else {
855 return;
856 };
857
858 self.selected_entry = Some(ix);
859 self.scroll_to_selected_entry(cx);
860 }
861
862 fn serialization_key(workspace: &Workspace) -> Option<String> {
863 workspace
864 .database_id()
865 .map(|id| i64::from(id).to_string())
866 .or(workspace.session_id())
867 .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
868 }
869
870 fn serialize(&mut self, cx: &mut Context<Self>) {
871 let width = self.width;
872 let amend_pending = self.amend_pending;
873 let signoff_enabled = self.signoff_enabled;
874
875 self.pending_serialization = cx.spawn(async move |git_panel, cx| {
876 cx.background_executor()
877 .timer(SERIALIZATION_THROTTLE_TIME)
878 .await;
879 let Some(serialization_key) = git_panel
880 .update(cx, |git_panel, cx| {
881 git_panel
882 .workspace
883 .read_with(cx, |workspace, _| Self::serialization_key(workspace))
884 .ok()
885 .flatten()
886 })
887 .ok()
888 .flatten()
889 else {
890 return;
891 };
892 cx.background_spawn(
893 async move {
894 KEY_VALUE_STORE
895 .write_kvp(
896 serialization_key,
897 serde_json::to_string(&SerializedGitPanel {
898 width,
899 amend_pending,
900 signoff_enabled,
901 })?,
902 )
903 .await?;
904 anyhow::Ok(())
905 }
906 .log_err(),
907 )
908 .await;
909 });
910 }
911
912 pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
913 self.modal_open = open;
914 cx.notify();
915 }
916
917 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
918 let mut dispatch_context = KeyContext::new_with_defaults();
919 dispatch_context.add("GitPanel");
920
921 if window
922 .focused(cx)
923 .is_some_and(|focused| self.focus_handle == focused)
924 {
925 dispatch_context.add("menu");
926 dispatch_context.add("ChangesList");
927 }
928
929 if self.commit_editor.read(cx).is_focused(window) {
930 dispatch_context.add("CommitEditor");
931 }
932
933 dispatch_context
934 }
935
936 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
937 cx.emit(PanelEvent::Close);
938 }
939
940 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
941 if !self.focus_handle.contains_focused(window, cx) {
942 cx.emit(Event::Focus);
943 }
944 }
945
946 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
947 let Some(selected_entry) = self.selected_entry else {
948 cx.notify();
949 return;
950 };
951
952 let visible_index = match &self.view_mode {
953 GitPanelViewMode::Flat => Some(selected_entry),
954 GitPanelViewMode::Tree(state) => state
955 .logical_indices
956 .iter()
957 .position(|&ix| ix == selected_entry),
958 };
959
960 if let Some(visible_index) = visible_index {
961 self.scroll_handle
962 .scroll_to_item(visible_index, ScrollStrategy::Center);
963 }
964
965 cx.notify();
966 }
967
968 fn expand_selected_entry(
969 &mut self,
970 _: &ExpandSelectedEntry,
971 window: &mut Window,
972 cx: &mut Context<Self>,
973 ) {
974 let Some(entry) = self.get_selected_entry().cloned() else {
975 return;
976 };
977
978 if let GitListEntry::Directory(dir_entry) = entry {
979 if dir_entry.expanded {
980 self.select_next(&menu::SelectNext, window, cx);
981 } else {
982 self.toggle_directory(&dir_entry.key, window, cx);
983 }
984 } else {
985 self.select_next(&menu::SelectNext, window, cx);
986 }
987 }
988
989 fn collapse_selected_entry(
990 &mut self,
991 _: &CollapseSelectedEntry,
992 window: &mut Window,
993 cx: &mut Context<Self>,
994 ) {
995 let Some(entry) = self.get_selected_entry().cloned() else {
996 return;
997 };
998
999 if let GitListEntry::Directory(dir_entry) = entry {
1000 if dir_entry.expanded {
1001 self.toggle_directory(&dir_entry.key, window, cx);
1002 } else {
1003 self.select_previous(&menu::SelectPrevious, window, cx);
1004 }
1005 } else {
1006 self.select_previous(&menu::SelectPrevious, window, cx);
1007 }
1008 }
1009
1010 fn select_first(
1011 &mut self,
1012 _: &menu::SelectFirst,
1013 _window: &mut Window,
1014 cx: &mut Context<Self>,
1015 ) {
1016 let first_entry = match &self.view_mode {
1017 GitPanelViewMode::Flat => self
1018 .entries
1019 .iter()
1020 .position(|entry| entry.status_entry().is_some()),
1021 GitPanelViewMode::Tree(state) => {
1022 let index = self.entries.iter().position(|entry| {
1023 entry.status_entry().is_some() || entry.directory_entry().is_some()
1024 });
1025
1026 index.map(|index| state.logical_indices[index])
1027 }
1028 };
1029
1030 if let Some(first_entry) = first_entry {
1031 self.selected_entry = Some(first_entry);
1032 self.scroll_to_selected_entry(cx);
1033 }
1034 }
1035
1036 fn select_previous(
1037 &mut self,
1038 _: &menu::SelectPrevious,
1039 _window: &mut Window,
1040 cx: &mut Context<Self>,
1041 ) {
1042 let item_count = self.entries.len();
1043 if item_count == 0 {
1044 return;
1045 }
1046
1047 let Some(selected_entry) = self.selected_entry else {
1048 return;
1049 };
1050
1051 let new_index = match &self.view_mode {
1052 GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
1053 GitPanelViewMode::Tree(state) => {
1054 let Some(current_logical_index) = state
1055 .logical_indices
1056 .iter()
1057 .position(|&i| i == selected_entry)
1058 else {
1059 return;
1060 };
1061
1062 state.logical_indices[current_logical_index.saturating_sub(1)]
1063 }
1064 };
1065
1066 if selected_entry == 0 && new_index == 0 {
1067 return;
1068 }
1069
1070 if matches!(
1071 self.entries.get(new_index.saturating_sub(1)),
1072 Some(GitListEntry::Header(..))
1073 ) && new_index == 0
1074 {
1075 return;
1076 }
1077
1078 if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
1079 self.selected_entry = Some(new_index.saturating_sub(1));
1080 } else {
1081 self.selected_entry = Some(new_index);
1082 }
1083
1084 self.scroll_to_selected_entry(cx);
1085 }
1086
1087 fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1088 let item_count = self.entries.len();
1089 if item_count == 0 {
1090 return;
1091 }
1092
1093 let Some(selected_entry) = self.selected_entry else {
1094 return;
1095 };
1096
1097 if selected_entry == item_count - 1 {
1098 return;
1099 }
1100
1101 let new_index = match &self.view_mode {
1102 GitPanelViewMode::Flat => selected_entry.saturating_add(1),
1103 GitPanelViewMode::Tree(state) => {
1104 let Some(current_logical_index) = state
1105 .logical_indices
1106 .iter()
1107 .position(|&i| i == selected_entry)
1108 else {
1109 return;
1110 };
1111
1112 state.logical_indices[current_logical_index.saturating_add(1)]
1113 }
1114 };
1115
1116 if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
1117 self.selected_entry = Some(new_index.saturating_add(1));
1118 } else {
1119 self.selected_entry = Some(new_index);
1120 }
1121
1122 self.scroll_to_selected_entry(cx);
1123 }
1124
1125 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1126 if self.entries.last().is_some() {
1127 self.selected_entry = Some(self.entries.len() - 1);
1128 self.scroll_to_selected_entry(cx);
1129 }
1130 }
1131
1132 /// Show diff view at selected entry, only if the diff view is open
1133 fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1134 maybe!({
1135 let workspace = self.workspace.upgrade()?;
1136
1137 if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
1138 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1139
1140 project_diff.update(cx, |project_diff, cx| {
1141 project_diff.move_to_entry(entry.clone(), window, cx);
1142 });
1143 }
1144
1145 Some(())
1146 });
1147 }
1148
1149 fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
1150 self.select_first(&menu::SelectFirst, window, cx);
1151 self.move_diff_to_entry(window, cx);
1152 }
1153
1154 fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
1155 self.select_last(&menu::SelectLast, window, cx);
1156 self.move_diff_to_entry(window, cx);
1157 }
1158
1159 fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
1160 self.select_next(&menu::SelectNext, window, cx);
1161 self.move_diff_to_entry(window, cx);
1162 }
1163
1164 fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
1165 self.select_previous(&menu::SelectPrevious, window, cx);
1166 self.move_diff_to_entry(window, cx);
1167 }
1168
1169 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
1170 self.commit_editor.update(cx, |editor, cx| {
1171 window.focus(&editor.focus_handle(cx), cx);
1172 });
1173 cx.notify();
1174 }
1175
1176 fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1177 let have_entries = self
1178 .active_repository
1179 .as_ref()
1180 .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
1181 if have_entries && self.selected_entry.is_none() {
1182 self.select_first(&menu::SelectFirst, window, cx);
1183 }
1184 }
1185
1186 fn focus_changes_list(
1187 &mut self,
1188 _: &FocusChanges,
1189 window: &mut Window,
1190 cx: &mut Context<Self>,
1191 ) {
1192 self.focus_handle.focus(window, cx);
1193 self.select_first_entry_if_none(window, cx);
1194 }
1195
1196 fn get_selected_entry(&self) -> Option<&GitListEntry> {
1197 self.selected_entry.and_then(|i| self.entries.get(i))
1198 }
1199
1200 fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1201 maybe!({
1202 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1203 let workspace = self.workspace.upgrade()?;
1204 let git_repo = self.active_repository.as_ref()?;
1205
1206 if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
1207 && let Some(project_path) = project_diff.read(cx).active_path(cx)
1208 && Some(&entry.repo_path)
1209 == git_repo
1210 .read(cx)
1211 .project_path_to_repo_path(&project_path, cx)
1212 .as_ref()
1213 {
1214 project_diff.focus_handle(cx).focus(window, cx);
1215 project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
1216 return None;
1217 };
1218
1219 self.workspace
1220 .update(cx, |workspace, cx| {
1221 ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
1222 })
1223 .ok();
1224 self.focus_handle.focus(window, cx);
1225
1226 Some(())
1227 });
1228 }
1229
1230 fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context<Self>) {
1231 maybe!({
1232 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1233 let active_repo = self.active_repository.as_ref()?;
1234 let repo_path = entry.repo_path.clone();
1235 let git_store = self.project.read(cx).git_store();
1236
1237 FileHistoryView::open(
1238 repo_path,
1239 git_store.downgrade(),
1240 active_repo.downgrade(),
1241 self.workspace.clone(),
1242 window,
1243 cx,
1244 );
1245
1246 Some(())
1247 });
1248 }
1249
1250 fn open_file(
1251 &mut self,
1252 _: &menu::SecondaryConfirm,
1253 window: &mut Window,
1254 cx: &mut Context<Self>,
1255 ) {
1256 maybe!({
1257 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1258 let active_repo = self.active_repository.as_ref()?;
1259 let path = active_repo
1260 .read(cx)
1261 .repo_path_to_project_path(&entry.repo_path, cx)?;
1262 if entry.status.is_deleted() {
1263 return None;
1264 }
1265
1266 let open_task = self
1267 .workspace
1268 .update(cx, |workspace, cx| {
1269 workspace.open_path_preview(path, None, false, false, true, window, cx)
1270 })
1271 .ok()?;
1272
1273 cx.spawn_in(window, async move |_, mut cx| {
1274 let item = open_task
1275 .await
1276 .notify_async_err(&mut cx)
1277 .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
1278 if let Some(active_editor) = item.downcast::<Editor>() {
1279 if let Some(diff_task) =
1280 active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())?
1281 {
1282 diff_task.await;
1283 }
1284
1285 cx.update(|window, cx| {
1286 active_editor.update(cx, |editor, cx| {
1287 editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
1288
1289 let snapshot = editor.snapshot(window, cx);
1290 editor.go_to_hunk_before_or_after_position(
1291 &snapshot,
1292 language::Point::new(0, 0),
1293 Direction::Next,
1294 window,
1295 cx,
1296 );
1297 })
1298 })?;
1299 }
1300
1301 anyhow::Ok(())
1302 })
1303 .detach();
1304
1305 Some(())
1306 });
1307 }
1308
1309 fn revert_selected(
1310 &mut self,
1311 action: &git::RestoreFile,
1312 window: &mut Window,
1313 cx: &mut Context<Self>,
1314 ) {
1315 let path_style = self.project.read(cx).path_style(cx);
1316 maybe!({
1317 let list_entry = self.entries.get(self.selected_entry?)?.clone();
1318 let entry = list_entry.status_entry()?.to_owned();
1319 let skip_prompt = action.skip_prompt || entry.status.is_created();
1320
1321 let prompt = if skip_prompt {
1322 Task::ready(Ok(0))
1323 } else {
1324 let prompt = window.prompt(
1325 PromptLevel::Warning,
1326 &format!(
1327 "Are you sure you want to discard changes to {}?",
1328 entry
1329 .repo_path
1330 .file_name()
1331 .unwrap_or(entry.repo_path.display(path_style).as_ref()),
1332 ),
1333 None,
1334 &["Discard Changes", "Cancel"],
1335 cx,
1336 );
1337 cx.background_spawn(prompt)
1338 };
1339
1340 let this = cx.weak_entity();
1341 window
1342 .spawn(cx, async move |cx| {
1343 if prompt.await? != 0 {
1344 return anyhow::Ok(());
1345 }
1346
1347 this.update_in(cx, |this, window, cx| {
1348 this.revert_entry(&entry, window, cx);
1349 })?;
1350
1351 Ok(())
1352 })
1353 .detach();
1354 Some(())
1355 });
1356 }
1357
1358 fn add_to_gitignore(
1359 &mut self,
1360 _: &git::AddToGitignore,
1361 _window: &mut Window,
1362 cx: &mut Context<Self>,
1363 ) {
1364 maybe!({
1365 let list_entry = self.entries.get(self.selected_entry?)?.clone();
1366 let entry = list_entry.status_entry()?.to_owned();
1367
1368 if !entry.status.is_created() {
1369 return Some(());
1370 }
1371
1372 let project = self.project.downgrade();
1373 let repo_path = entry.repo_path;
1374 let active_repository = self.active_repository.as_ref()?.downgrade();
1375
1376 cx.spawn(async move |_, cx| {
1377 let file_path_str = repo_path.as_ref().display(PathStyle::Posix);
1378
1379 let repo_root = active_repository.read_with(cx, |repository, _| {
1380 repository.snapshot().work_directory_abs_path
1381 })?;
1382
1383 let gitignore_abs_path = repo_root.join(".gitignore");
1384
1385 let buffer = project
1386 .update(cx, |project, cx| {
1387 project.open_local_buffer(gitignore_abs_path, cx)
1388 })?
1389 .await?;
1390
1391 let mut should_save = false;
1392 buffer.update(cx, |buffer, cx| {
1393 let existing_content = buffer.text();
1394
1395 if existing_content
1396 .lines()
1397 .any(|line| line.trim() == file_path_str)
1398 {
1399 return;
1400 }
1401
1402 let insert_position = existing_content.len();
1403 let new_entry = if existing_content.is_empty() {
1404 format!("{}\n", file_path_str)
1405 } else if existing_content.ends_with('\n') {
1406 format!("{}\n", file_path_str)
1407 } else {
1408 format!("\n{}\n", file_path_str)
1409 };
1410
1411 buffer.edit([(insert_position..insert_position, new_entry)], None, cx);
1412 should_save = true;
1413 })?;
1414
1415 if should_save {
1416 project
1417 .update(cx, |project, cx| project.save_buffer(buffer, cx))?
1418 .await?;
1419 }
1420
1421 anyhow::Ok(())
1422 })
1423 .detach_and_log_err(cx);
1424
1425 Some(())
1426 });
1427 }
1428
1429 fn revert_entry(
1430 &mut self,
1431 entry: &GitStatusEntry,
1432 window: &mut Window,
1433 cx: &mut Context<Self>,
1434 ) {
1435 maybe!({
1436 let active_repo = self.active_repository.clone()?;
1437 let path = active_repo
1438 .read(cx)
1439 .repo_path_to_project_path(&entry.repo_path, cx)?;
1440 let workspace = self.workspace.clone();
1441
1442 if entry.status.staging().has_staged() {
1443 self.change_file_stage(false, vec![entry.clone()], cx);
1444 }
1445 let filename = path.path.file_name()?.to_string();
1446
1447 if !entry.status.is_created() {
1448 self.perform_checkout(vec![entry.clone()], window, cx);
1449 } else {
1450 let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
1451 cx.spawn_in(window, async move |_, cx| {
1452 match prompt.await? {
1453 TrashCancel::Trash => {}
1454 TrashCancel::Cancel => return Ok(()),
1455 }
1456 let task = workspace.update(cx, |workspace, cx| {
1457 workspace
1458 .project()
1459 .update(cx, |project, cx| project.delete_file(path, true, cx))
1460 })?;
1461 if let Some(task) = task {
1462 task.await?;
1463 }
1464 Ok(())
1465 })
1466 .detach_and_prompt_err(
1467 "Failed to trash file",
1468 window,
1469 cx,
1470 |e, _, _| Some(format!("{e}")),
1471 );
1472 }
1473 Some(())
1474 });
1475 }
1476
1477 fn perform_checkout(
1478 &mut self,
1479 entries: Vec<GitStatusEntry>,
1480 window: &mut Window,
1481 cx: &mut Context<Self>,
1482 ) {
1483 let workspace = self.workspace.clone();
1484 let Some(active_repository) = self.active_repository.clone() else {
1485 return;
1486 };
1487
1488 let task = cx.spawn_in(window, async move |this, cx| {
1489 let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
1490 workspace.project().update(cx, |project, cx| {
1491 entries
1492 .iter()
1493 .filter_map(|entry| {
1494 let path = active_repository
1495 .read(cx)
1496 .repo_path_to_project_path(&entry.repo_path, cx)?;
1497 Some(project.open_buffer(path, cx))
1498 })
1499 .collect()
1500 })
1501 })?;
1502
1503 let buffers = futures::future::join_all(tasks).await;
1504
1505 this.update_in(cx, |this, window, cx| {
1506 let task = active_repository.update(cx, |repo, cx| {
1507 repo.checkout_files(
1508 "HEAD",
1509 entries
1510 .into_iter()
1511 .map(|entries| entries.repo_path)
1512 .collect(),
1513 cx,
1514 )
1515 });
1516 this.update_visible_entries(window, cx);
1517 cx.notify();
1518 task
1519 })?
1520 .await?;
1521
1522 let tasks: Vec<_> = cx.update(|_, cx| {
1523 buffers
1524 .iter()
1525 .filter_map(|buffer| {
1526 buffer.as_ref().ok()?.update(cx, |buffer, cx| {
1527 buffer.is_dirty().then(|| buffer.reload(cx))
1528 })
1529 })
1530 .collect()
1531 })?;
1532
1533 futures::future::join_all(tasks).await;
1534
1535 Ok(())
1536 });
1537
1538 cx.spawn_in(window, async move |this, cx| {
1539 let result = task.await;
1540
1541 this.update_in(cx, |this, window, cx| {
1542 if let Err(err) = result {
1543 this.update_visible_entries(window, cx);
1544 this.show_error_toast("checkout", err, cx);
1545 }
1546 })
1547 .ok();
1548 })
1549 .detach();
1550 }
1551
1552 fn restore_tracked_files(
1553 &mut self,
1554 _: &RestoreTrackedFiles,
1555 window: &mut Window,
1556 cx: &mut Context<Self>,
1557 ) {
1558 let entries = self
1559 .entries
1560 .iter()
1561 .filter_map(|entry| entry.status_entry().cloned())
1562 .filter(|status_entry| !status_entry.status.is_created())
1563 .collect::<Vec<_>>();
1564
1565 match entries.len() {
1566 0 => return,
1567 1 => return self.revert_entry(&entries[0], window, cx),
1568 _ => {}
1569 }
1570 let mut details = entries
1571 .iter()
1572 .filter_map(|entry| entry.repo_path.as_ref().file_name())
1573 .map(|filename| filename.to_string())
1574 .take(5)
1575 .join("\n");
1576 if entries.len() > 5 {
1577 details.push_str(&format!("\nand {} more…", entries.len() - 5))
1578 }
1579
1580 #[derive(strum::EnumIter, strum::VariantNames)]
1581 #[strum(serialize_all = "title_case")]
1582 enum RestoreCancel {
1583 RestoreTrackedFiles,
1584 Cancel,
1585 }
1586 let prompt = prompt(
1587 "Discard changes to these files?",
1588 Some(&details),
1589 window,
1590 cx,
1591 );
1592 cx.spawn_in(window, async move |this, cx| {
1593 if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
1594 this.update_in(cx, |this, window, cx| {
1595 this.perform_checkout(entries, window, cx);
1596 })
1597 .ok();
1598 }
1599 })
1600 .detach();
1601 }
1602
1603 fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
1604 let workspace = self.workspace.clone();
1605 let Some(active_repo) = self.active_repository.clone() else {
1606 return;
1607 };
1608 let to_delete = self
1609 .entries
1610 .iter()
1611 .filter_map(|entry| entry.status_entry())
1612 .filter(|status_entry| status_entry.status.is_created())
1613 .cloned()
1614 .collect::<Vec<_>>();
1615
1616 match to_delete.len() {
1617 0 => return,
1618 1 => return self.revert_entry(&to_delete[0], window, cx),
1619 _ => {}
1620 };
1621
1622 let mut details = to_delete
1623 .iter()
1624 .map(|entry| {
1625 entry
1626 .repo_path
1627 .as_ref()
1628 .file_name()
1629 .map(|f| f.to_string())
1630 .unwrap_or_default()
1631 })
1632 .take(5)
1633 .join("\n");
1634
1635 if to_delete.len() > 5 {
1636 details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
1637 }
1638
1639 let prompt = prompt("Trash these files?", Some(&details), window, cx);
1640 cx.spawn_in(window, async move |this, cx| {
1641 match prompt.await? {
1642 TrashCancel::Trash => {}
1643 TrashCancel::Cancel => return Ok(()),
1644 }
1645 let tasks = workspace.update(cx, |workspace, cx| {
1646 to_delete
1647 .iter()
1648 .filter_map(|entry| {
1649 workspace.project().update(cx, |project, cx| {
1650 let project_path = active_repo
1651 .read(cx)
1652 .repo_path_to_project_path(&entry.repo_path, cx)?;
1653 project.delete_file(project_path, true, cx)
1654 })
1655 })
1656 .collect::<Vec<_>>()
1657 })?;
1658 let to_unstage = to_delete
1659 .into_iter()
1660 .filter(|entry| !entry.status.staging().is_fully_unstaged())
1661 .collect();
1662 this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
1663 for task in tasks {
1664 task.await?;
1665 }
1666 Ok(())
1667 })
1668 .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1669 Some(format!("{e}"))
1670 });
1671 }
1672
1673 fn change_all_files_stage(&mut self, stage: bool, cx: &mut Context<Self>) {
1674 let Some(active_repository) = self.active_repository.clone() else {
1675 return;
1676 };
1677 cx.spawn({
1678 async move |this, cx| {
1679 let result = this
1680 .update(cx, |this, cx| {
1681 let task = active_repository.update(cx, |repo, cx| {
1682 if stage {
1683 repo.stage_all(cx)
1684 } else {
1685 repo.unstage_all(cx)
1686 }
1687 });
1688 this.update_counts(active_repository.read(cx));
1689 cx.notify();
1690 task
1691 })?
1692 .await;
1693
1694 this.update(cx, |this, cx| {
1695 if let Err(err) = result {
1696 this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
1697 }
1698 cx.notify()
1699 })
1700 }
1701 })
1702 .detach();
1703 }
1704
1705 fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus {
1706 // Checking for current staged/unstaged file status is a chained operation:
1707 // 1. first, we check for any pending operation recorded in repository
1708 // 2. if there are no pending ops either running or finished, we then ask the repository
1709 // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry`
1710 // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on
1711 // the checkbox's state (or flickering) which is undesirable.
1712 // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded
1713 // in `entry` arg.
1714 repo.pending_ops_for_path(&entry.repo_path)
1715 .map(|ops| {
1716 if ops.staging() || ops.staged() {
1717 StageStatus::Staged
1718 } else {
1719 StageStatus::Unstaged
1720 }
1721 })
1722 .or_else(|| {
1723 repo.status_for_path(&entry.repo_path)
1724 .map(|status| status.status.staging())
1725 })
1726 .unwrap_or(entry.staging)
1727 }
1728
1729 fn stage_status_for_directory(
1730 &self,
1731 entry: &GitTreeDirEntry,
1732 repo: &Repository,
1733 ) -> StageStatus {
1734 let GitPanelViewMode::Tree(tree_state) = &self.view_mode else {
1735 util::debug_panic!("We should never render a directory entry while in flat view mode");
1736 return StageStatus::Unstaged;
1737 };
1738
1739 let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else {
1740 return StageStatus::Unstaged;
1741 };
1742
1743 let mut fully_staged_count = 0usize;
1744 let mut any_staged_or_partially_staged = false;
1745
1746 for descendant in descendants {
1747 match GitPanel::stage_status_for_entry(descendant, repo) {
1748 StageStatus::Staged => {
1749 fully_staged_count += 1;
1750 any_staged_or_partially_staged = true;
1751 }
1752 StageStatus::PartiallyStaged => {
1753 any_staged_or_partially_staged = true;
1754 }
1755 StageStatus::Unstaged => {}
1756 }
1757 }
1758
1759 if descendants.is_empty() {
1760 StageStatus::Unstaged
1761 } else if fully_staged_count == descendants.len() {
1762 StageStatus::Staged
1763 } else if any_staged_or_partially_staged {
1764 StageStatus::PartiallyStaged
1765 } else {
1766 StageStatus::Unstaged
1767 }
1768 }
1769
1770 pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1771 self.change_all_files_stage(true, cx);
1772 }
1773
1774 pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1775 self.change_all_files_stage(false, cx);
1776 }
1777
1778 fn toggle_staged_for_entry(
1779 &mut self,
1780 entry: &GitListEntry,
1781 _window: &mut Window,
1782 cx: &mut Context<Self>,
1783 ) {
1784 let Some(active_repository) = self.active_repository.clone() else {
1785 return;
1786 };
1787 let mut set_anchor: Option<RepoPath> = None;
1788 let mut clear_anchor = None;
1789
1790 let (stage, repo_paths) = {
1791 let repo = active_repository.read(cx);
1792 match entry {
1793 GitListEntry::Status(status_entry) => {
1794 let repo_paths = vec![status_entry.clone()];
1795 let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) {
1796 StageStatus::Staged => {
1797 if let Some(op) = self.bulk_staging.clone()
1798 && op.anchor == status_entry.repo_path
1799 {
1800 clear_anchor = Some(op.anchor);
1801 }
1802 false
1803 }
1804 StageStatus::Unstaged | StageStatus::PartiallyStaged => {
1805 set_anchor = Some(status_entry.repo_path.clone());
1806 true
1807 }
1808 };
1809 (stage, repo_paths)
1810 }
1811 GitListEntry::TreeStatus(status_entry) => {
1812 let repo_paths = vec![status_entry.entry.clone()];
1813 let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) {
1814 StageStatus::Staged => {
1815 if let Some(op) = self.bulk_staging.clone()
1816 && op.anchor == status_entry.entry.repo_path
1817 {
1818 clear_anchor = Some(op.anchor);
1819 }
1820 false
1821 }
1822 StageStatus::Unstaged | StageStatus::PartiallyStaged => {
1823 set_anchor = Some(status_entry.entry.repo_path.clone());
1824 true
1825 }
1826 };
1827 (stage, repo_paths)
1828 }
1829 GitListEntry::Header(section) => {
1830 let goal_staged_state = !self.header_state(section.header).selected();
1831 let entries = self
1832 .entries
1833 .iter()
1834 .filter_map(|entry| entry.status_entry())
1835 .filter(|status_entry| {
1836 section.contains(status_entry, &repo)
1837 && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool()
1838 != Some(goal_staged_state)
1839 })
1840 .cloned()
1841 .collect::<Vec<_>>();
1842
1843 (goal_staged_state, entries)
1844 }
1845 GitListEntry::Directory(entry) => {
1846 let goal_staged_state = match self.stage_status_for_directory(entry, repo) {
1847 StageStatus::Staged => StageStatus::Unstaged,
1848 StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged,
1849 };
1850 let goal_stage = goal_staged_state == StageStatus::Staged;
1851
1852 let entries = self
1853 .view_mode
1854 .tree_state()
1855 .and_then(|state| state.directory_descendants.get(&entry.key))
1856 .cloned()
1857 .unwrap_or_default()
1858 .into_iter()
1859 .filter(|status_entry| {
1860 GitPanel::stage_status_for_entry(status_entry, &repo)
1861 != goal_staged_state
1862 })
1863 .collect::<Vec<_>>();
1864 (goal_stage, entries)
1865 }
1866 }
1867 };
1868 if let Some(anchor) = clear_anchor {
1869 if let Some(op) = self.bulk_staging.clone()
1870 && op.anchor == anchor
1871 {
1872 self.bulk_staging = None;
1873 }
1874 }
1875 if let Some(anchor) = set_anchor {
1876 self.set_bulk_staging_anchor(anchor, cx);
1877 }
1878
1879 self.change_file_stage(stage, repo_paths, cx);
1880 }
1881
1882 fn change_file_stage(
1883 &mut self,
1884 stage: bool,
1885 entries: Vec<GitStatusEntry>,
1886 cx: &mut Context<Self>,
1887 ) {
1888 let Some(active_repository) = self.active_repository.clone() else {
1889 return;
1890 };
1891 cx.spawn({
1892 async move |this, cx| {
1893 let result = this
1894 .update(cx, |this, cx| {
1895 let task = active_repository.update(cx, |repo, cx| {
1896 let repo_paths = entries
1897 .iter()
1898 .map(|entry| entry.repo_path.clone())
1899 .collect();
1900 if stage {
1901 repo.stage_entries(repo_paths, cx)
1902 } else {
1903 repo.unstage_entries(repo_paths, cx)
1904 }
1905 });
1906 this.update_counts(active_repository.read(cx));
1907 cx.notify();
1908 task
1909 })?
1910 .await;
1911
1912 this.update(cx, |this, cx| {
1913 if let Err(err) = result {
1914 this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
1915 }
1916 cx.notify();
1917 })
1918 }
1919 })
1920 .detach();
1921 }
1922
1923 pub fn total_staged_count(&self) -> usize {
1924 self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1925 }
1926
1927 pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
1928 let Some(active_repository) = self.active_repository.clone() else {
1929 return;
1930 };
1931
1932 cx.spawn({
1933 async move |this, cx| {
1934 let stash_task = active_repository
1935 .update(cx, |repo, cx| repo.stash_pop(None, cx))?
1936 .await;
1937 this.update(cx, |this, cx| {
1938 stash_task
1939 .map_err(|e| {
1940 this.show_error_toast("stash pop", e, cx);
1941 })
1942 .ok();
1943 cx.notify();
1944 })
1945 }
1946 })
1947 .detach();
1948 }
1949
1950 pub fn stash_apply(&mut self, _: &StashApply, _window: &mut Window, cx: &mut Context<Self>) {
1951 let Some(active_repository) = self.active_repository.clone() else {
1952 return;
1953 };
1954
1955 cx.spawn({
1956 async move |this, cx| {
1957 let stash_task = active_repository
1958 .update(cx, |repo, cx| repo.stash_apply(None, cx))?
1959 .await;
1960 this.update(cx, |this, cx| {
1961 stash_task
1962 .map_err(|e| {
1963 this.show_error_toast("stash apply", e, cx);
1964 })
1965 .ok();
1966 cx.notify();
1967 })
1968 }
1969 })
1970 .detach();
1971 }
1972
1973 pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
1974 let Some(active_repository) = self.active_repository.clone() else {
1975 return;
1976 };
1977
1978 cx.spawn({
1979 async move |this, cx| {
1980 let stash_task = active_repository
1981 .update(cx, |repo, cx| repo.stash_all(cx))?
1982 .await;
1983 this.update(cx, |this, cx| {
1984 stash_task
1985 .map_err(|e| {
1986 this.show_error_toast("stash", e, cx);
1987 })
1988 .ok();
1989 cx.notify();
1990 })
1991 }
1992 })
1993 .detach();
1994 }
1995
1996 pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1997 self.commit_editor
1998 .read(cx)
1999 .buffer()
2000 .read(cx)
2001 .as_singleton()
2002 .unwrap()
2003 }
2004
2005 fn toggle_staged_for_selected(
2006 &mut self,
2007 _: &git::ToggleStaged,
2008 window: &mut Window,
2009 cx: &mut Context<Self>,
2010 ) {
2011 if let Some(selected_entry) = self.get_selected_entry().cloned() {
2012 self.toggle_staged_for_entry(&selected_entry, window, cx);
2013 }
2014 }
2015
2016 fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
2017 let Some(index) = self.selected_entry else {
2018 return;
2019 };
2020 self.stage_bulk(index, cx);
2021 }
2022
2023 fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
2024 let Some(selected_entry) = self.get_selected_entry() else {
2025 return;
2026 };
2027 let Some(status_entry) = selected_entry.status_entry() else {
2028 return;
2029 };
2030 if status_entry.staging != StageStatus::Staged {
2031 self.change_file_stage(true, vec![status_entry.clone()], cx);
2032 }
2033 }
2034
2035 fn unstage_selected(
2036 &mut self,
2037 _: &git::UnstageFile,
2038 _window: &mut Window,
2039 cx: &mut Context<Self>,
2040 ) {
2041 let Some(selected_entry) = self.get_selected_entry() else {
2042 return;
2043 };
2044 let Some(status_entry) = selected_entry.status_entry() else {
2045 return;
2046 };
2047 if status_entry.staging != StageStatus::Unstaged {
2048 self.change_file_stage(false, vec![status_entry.clone()], cx);
2049 }
2050 }
2051
2052 fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
2053 if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
2054 telemetry::event!("Git Committed", source = "Git Panel");
2055 }
2056 }
2057
2058 /// Commits staged changes with the current commit message.
2059 ///
2060 /// Returns `true` if the commit was executed, `false` otherwise.
2061 pub(crate) fn commit(
2062 &mut self,
2063 commit_editor_focus_handle: &FocusHandle,
2064 window: &mut Window,
2065 cx: &mut Context<Self>,
2066 ) -> bool {
2067 if self.amend_pending {
2068 return false;
2069 }
2070
2071 if commit_editor_focus_handle.contains_focused(window, cx) {
2072 self.commit_changes(
2073 CommitOptions {
2074 amend: false,
2075 signoff: self.signoff_enabled,
2076 },
2077 window,
2078 cx,
2079 );
2080 true
2081 } else {
2082 cx.propagate();
2083 false
2084 }
2085 }
2086
2087 fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
2088 if self.amend(&self.commit_editor.focus_handle(cx), window, cx) {
2089 telemetry::event!("Git Amended", source = "Git Panel");
2090 }
2091 }
2092
2093 /// Amends the most recent commit with staged changes and/or an updated commit message.
2094 ///
2095 /// Uses a two-stage workflow where the first invocation loads the commit
2096 /// message for editing, second invocation performs the amend. Returns
2097 /// `true` if the amend was executed, `false` otherwise.
2098 pub(crate) fn amend(
2099 &mut self,
2100 commit_editor_focus_handle: &FocusHandle,
2101 window: &mut Window,
2102 cx: &mut Context<Self>,
2103 ) -> bool {
2104 if commit_editor_focus_handle.contains_focused(window, cx) {
2105 if self.head_commit(cx).is_some() {
2106 if !self.amend_pending {
2107 self.set_amend_pending(true, cx);
2108 self.load_last_commit_message(cx);
2109
2110 return false;
2111 } else {
2112 self.commit_changes(
2113 CommitOptions {
2114 amend: true,
2115 signoff: self.signoff_enabled,
2116 },
2117 window,
2118 cx,
2119 );
2120
2121 return true;
2122 }
2123 }
2124 return false;
2125 } else {
2126 cx.propagate();
2127 return false;
2128 }
2129 }
2130 pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
2131 self.active_repository
2132 .as_ref()
2133 .and_then(|repo| repo.read(cx).head_commit.as_ref())
2134 .cloned()
2135 }
2136
2137 pub fn load_last_commit_message(&mut self, cx: &mut Context<Self>) {
2138 let Some(head_commit) = self.head_commit(cx) else {
2139 return;
2140 };
2141
2142 let recent_sha = head_commit.sha.to_string();
2143 let detail_task = self.load_commit_details(recent_sha, cx);
2144 cx.spawn(async move |this, cx| {
2145 if let Ok(message) = detail_task.await.map(|detail| detail.message) {
2146 this.update(cx, |this, cx| {
2147 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2148 let start = buffer.anchor_before(0);
2149 let end = buffer.anchor_after(buffer.len());
2150 buffer.edit([(start..end, message)], None, cx);
2151 });
2152 })
2153 .log_err();
2154 }
2155 })
2156 .detach();
2157 }
2158
2159 fn custom_or_suggested_commit_message(
2160 &self,
2161 window: &mut Window,
2162 cx: &mut Context<Self>,
2163 ) -> Option<String> {
2164 let git_commit_language = self
2165 .commit_editor
2166 .read(cx)
2167 .language_at(MultiBufferOffset(0), cx);
2168 let message = self.commit_editor.read(cx).text(cx);
2169 if message.is_empty() {
2170 return self
2171 .suggest_commit_message(cx)
2172 .filter(|message| !message.trim().is_empty());
2173 } else if message.trim().is_empty() {
2174 return None;
2175 }
2176 let buffer = cx.new(|cx| {
2177 let mut buffer = Buffer::local(message, cx);
2178 buffer.set_language(git_commit_language, cx);
2179 buffer
2180 });
2181 let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
2182 let wrapped_message = editor.update(cx, |editor, cx| {
2183 editor.select_all(&Default::default(), window, cx);
2184 editor.rewrap_impl(
2185 RewrapOptions {
2186 override_language_settings: false,
2187 preserve_existing_whitespace: true,
2188 },
2189 cx,
2190 );
2191 editor.text(cx)
2192 });
2193 if wrapped_message.trim().is_empty() {
2194 return None;
2195 }
2196 Some(wrapped_message)
2197 }
2198
2199 fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
2200 let text = self.commit_editor.read(cx).text(cx);
2201 if !text.trim().is_empty() {
2202 true
2203 } else if text.is_empty() {
2204 self.suggest_commit_message(cx)
2205 .is_some_and(|text| !text.trim().is_empty())
2206 } else {
2207 false
2208 }
2209 }
2210
2211 pub(crate) fn commit_changes(
2212 &mut self,
2213 options: CommitOptions,
2214 window: &mut Window,
2215 cx: &mut Context<Self>,
2216 ) {
2217 let Some(active_repository) = self.active_repository.clone() else {
2218 return;
2219 };
2220 let error_spawn = |message, window: &mut Window, cx: &mut App| {
2221 let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
2222 cx.spawn(async move |_| {
2223 prompt.await.ok();
2224 })
2225 .detach();
2226 };
2227
2228 if self.has_unstaged_conflicts() {
2229 error_spawn(
2230 "There are still conflicts. You must stage these before committing",
2231 window,
2232 cx,
2233 );
2234 return;
2235 }
2236
2237 let askpass = self.askpass_delegate("git commit", window, cx);
2238 let commit_message = self.custom_or_suggested_commit_message(window, cx);
2239
2240 let Some(mut message) = commit_message else {
2241 self.commit_editor
2242 .read(cx)
2243 .focus_handle(cx)
2244 .focus(window, cx);
2245 return;
2246 };
2247
2248 if self.add_coauthors {
2249 self.fill_co_authors(&mut message, cx);
2250 }
2251
2252 let task = if self.has_staged_changes() {
2253 // Repository serializes all git operations, so we can just send a commit immediately
2254 let commit_task = active_repository.update(cx, |repo, cx| {
2255 repo.commit(message.into(), None, options, askpass, cx)
2256 });
2257 cx.background_spawn(async move { commit_task.await? })
2258 } else {
2259 let changed_files = self
2260 .entries
2261 .iter()
2262 .filter_map(|entry| entry.status_entry())
2263 .filter(|status_entry| !status_entry.status.is_created())
2264 .map(|status_entry| status_entry.repo_path.clone())
2265 .collect::<Vec<_>>();
2266
2267 if changed_files.is_empty() && !options.amend {
2268 error_spawn("No changes to commit", window, cx);
2269 return;
2270 }
2271
2272 let stage_task =
2273 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
2274 cx.spawn(async move |_, cx| {
2275 stage_task.await?;
2276 let commit_task = active_repository.update(cx, |repo, cx| {
2277 repo.commit(message.into(), None, options, askpass, cx)
2278 })?;
2279 commit_task.await?
2280 })
2281 };
2282 let task = cx.spawn_in(window, async move |this, cx| {
2283 let result = task.await;
2284 this.update_in(cx, |this, window, cx| {
2285 this.pending_commit.take();
2286
2287 match result {
2288 Ok(()) => {
2289 if options.amend {
2290 this.set_amend_pending(false, cx);
2291 } else {
2292 this.commit_editor
2293 .update(cx, |editor, cx| editor.clear(window, cx));
2294 this.original_commit_message = None;
2295 }
2296 }
2297 Err(e) => this.show_error_toast("commit", e, cx),
2298 }
2299 })
2300 .ok();
2301 });
2302
2303 self.pending_commit = Some(task);
2304 }
2305
2306 pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2307 let Some(repo) = self.active_repository.clone() else {
2308 return;
2309 };
2310 telemetry::event!("Git Uncommitted");
2311
2312 let confirmation = self.check_for_pushed_commits(window, cx);
2313 let prior_head = self.load_commit_details("HEAD".to_string(), cx);
2314
2315 let task = cx.spawn_in(window, async move |this, cx| {
2316 let result = maybe!(async {
2317 if let Ok(true) = confirmation.await {
2318 let prior_head = prior_head.await?;
2319
2320 repo.update(cx, |repo, cx| {
2321 repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
2322 })?
2323 .await??;
2324
2325 Ok(Some(prior_head))
2326 } else {
2327 Ok(None)
2328 }
2329 })
2330 .await;
2331
2332 this.update_in(cx, |this, window, cx| {
2333 this.pending_commit.take();
2334 match result {
2335 Ok(None) => {}
2336 Ok(Some(prior_commit)) => {
2337 this.commit_editor.update(cx, |editor, cx| {
2338 editor.set_text(prior_commit.message, window, cx)
2339 });
2340 }
2341 Err(e) => this.show_error_toast("reset", e, cx),
2342 }
2343 })
2344 .ok();
2345 });
2346
2347 self.pending_commit = Some(task);
2348 }
2349
2350 fn check_for_pushed_commits(
2351 &mut self,
2352 window: &mut Window,
2353 cx: &mut Context<Self>,
2354 ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
2355 let repo = self.active_repository.clone();
2356 let mut cx = window.to_async(cx);
2357
2358 async move {
2359 let repo = repo.context("No active repository")?;
2360
2361 let pushed_to: Vec<SharedString> = repo
2362 .update(&mut cx, |repo, _| repo.check_for_pushed_commits())?
2363 .await??;
2364
2365 if pushed_to.is_empty() {
2366 Ok(true)
2367 } else {
2368 #[derive(strum::EnumIter, strum::VariantNames)]
2369 #[strum(serialize_all = "title_case")]
2370 enum CancelUncommit {
2371 Uncommit,
2372 Cancel,
2373 }
2374 let detail = format!(
2375 "This commit was already pushed to {}.",
2376 pushed_to.into_iter().join(", ")
2377 );
2378 let result = cx
2379 .update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
2380 .await?;
2381
2382 match result {
2383 CancelUncommit::Cancel => Ok(false),
2384 CancelUncommit::Uncommit => Ok(true),
2385 }
2386 }
2387 }
2388 }
2389
2390 /// Suggests a commit message based on the changed files and their statuses
2391 pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
2392 if let Some(merge_message) = self
2393 .active_repository
2394 .as_ref()
2395 .and_then(|repo| repo.read(cx).merge.message.as_ref())
2396 {
2397 return Some(merge_message.to_string());
2398 }
2399
2400 let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
2401 Some(staged_entry)
2402 } else if self.total_staged_count() == 0
2403 && let Some(single_tracked_entry) = &self.single_tracked_entry
2404 {
2405 Some(single_tracked_entry)
2406 } else {
2407 None
2408 }?;
2409
2410 let action_text = if git_status_entry.status.is_deleted() {
2411 Some("Delete")
2412 } else if git_status_entry.status.is_created() {
2413 Some("Create")
2414 } else if git_status_entry.status.is_modified() {
2415 Some("Update")
2416 } else {
2417 None
2418 }?;
2419
2420 let file_name = git_status_entry
2421 .repo_path
2422 .file_name()
2423 .unwrap_or_default()
2424 .to_string();
2425
2426 Some(format!("{} {}", action_text, file_name))
2427 }
2428
2429 fn generate_commit_message_action(
2430 &mut self,
2431 _: &git::GenerateCommitMessage,
2432 _window: &mut Window,
2433 cx: &mut Context<Self>,
2434 ) {
2435 self.generate_commit_message(cx);
2436 }
2437
2438 fn split_patch(patch: &str) -> Vec<String> {
2439 let mut result = Vec::new();
2440 let mut current_patch = String::new();
2441
2442 for line in patch.lines() {
2443 if line.starts_with("---") && !current_patch.is_empty() {
2444 result.push(current_patch.trim_end_matches('\n').into());
2445 current_patch = String::new();
2446 }
2447 current_patch.push_str(line);
2448 current_patch.push('\n');
2449 }
2450
2451 if !current_patch.is_empty() {
2452 result.push(current_patch.trim_end_matches('\n').into());
2453 }
2454
2455 result
2456 }
2457 fn truncate_iteratively(patch: &str, max_bytes: usize) -> String {
2458 let mut current_size = patch.len();
2459 if current_size <= max_bytes {
2460 return patch.to_string();
2461 }
2462 let file_patches = Self::split_patch(patch);
2463 let mut file_infos: Vec<TruncatedPatch> = file_patches
2464 .iter()
2465 .filter_map(|patch| TruncatedPatch::from_unified_diff(patch))
2466 .collect();
2467
2468 if file_infos.is_empty() {
2469 return patch.to_string();
2470 }
2471
2472 current_size = file_infos.iter().map(|f| f.calculate_size()).sum::<usize>();
2473 while current_size > max_bytes {
2474 let file_idx = file_infos
2475 .iter()
2476 .enumerate()
2477 .filter(|(_, f)| f.hunks_to_keep > 1)
2478 .max_by_key(|(_, f)| f.hunks_to_keep)
2479 .map(|(idx, _)| idx);
2480 match file_idx {
2481 Some(idx) => {
2482 let file = &mut file_infos[idx];
2483 let size_before = file.calculate_size();
2484 file.hunks_to_keep -= 1;
2485 let size_after = file.calculate_size();
2486 let saved = size_before.saturating_sub(size_after);
2487 current_size = current_size.saturating_sub(saved);
2488 }
2489 None => {
2490 break;
2491 }
2492 }
2493 }
2494
2495 file_infos
2496 .iter()
2497 .map(|info| info.to_string())
2498 .collect::<Vec<_>>()
2499 .join("\n")
2500 }
2501
2502 pub fn compress_commit_diff(diff_text: &str, max_bytes: usize) -> String {
2503 if diff_text.len() <= max_bytes {
2504 return diff_text.to_string();
2505 }
2506
2507 let mut compressed = diff_text
2508 .lines()
2509 .map(|line| {
2510 if line.len() > 256 {
2511 format!("{}...[truncated]\n", &line[..line.floor_char_boundary(256)])
2512 } else {
2513 format!("{}\n", line)
2514 }
2515 })
2516 .collect::<Vec<_>>()
2517 .join("");
2518
2519 if compressed.len() <= max_bytes {
2520 return compressed;
2521 }
2522
2523 compressed = Self::truncate_iteratively(&compressed, max_bytes);
2524
2525 compressed
2526 }
2527
2528 async fn load_project_rules(
2529 project: &Entity<Project>,
2530 repo_work_dir: &Arc<Path>,
2531 cx: &mut AsyncApp,
2532 ) -> Option<String> {
2533 let rules_path = cx
2534 .update(|cx| {
2535 for worktree in project.read(cx).worktrees(cx) {
2536 let worktree_abs_path = worktree.read(cx).abs_path();
2537 if !worktree_abs_path.starts_with(&repo_work_dir) {
2538 continue;
2539 }
2540
2541 let worktree_snapshot = worktree.read(cx).snapshot();
2542 for rules_name in RULES_FILE_NAMES {
2543 if let Ok(rel_path) = RelPath::unix(rules_name) {
2544 if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) {
2545 if entry.is_file() {
2546 return Some(ProjectPath {
2547 worktree_id: worktree.read(cx).id(),
2548 path: entry.path.clone(),
2549 });
2550 }
2551 }
2552 }
2553 }
2554 }
2555 None
2556 })
2557 .ok()??;
2558
2559 let buffer = project
2560 .update(cx, |project, cx| project.open_buffer(rules_path, cx))
2561 .ok()?
2562 .await
2563 .ok()?;
2564
2565 let content = buffer
2566 .read_with(cx, |buffer, _| buffer.text())
2567 .ok()?
2568 .trim()
2569 .to_string();
2570
2571 if content.is_empty() {
2572 None
2573 } else {
2574 Some(content)
2575 }
2576 }
2577
2578 async fn load_commit_message_prompt(
2579 is_using_legacy_zed_pro: bool,
2580 cx: &mut AsyncApp,
2581 ) -> String {
2582 // Remove this once we stop supporting legacy Zed Pro
2583 // In legacy Zed Pro, Git commit summary generation did not count as a
2584 // prompt. If the user changes the prompt, our classification will fail,
2585 // meaning that users will be charged for generating commit messages.
2586 if is_using_legacy_zed_pro {
2587 return BuiltInPrompt::CommitMessage.default_content().to_string();
2588 }
2589
2590 let load = async {
2591 let store = cx.update(|cx| PromptStore::global(cx)).ok()?.await.ok()?;
2592 store
2593 .update(cx, |s, cx| {
2594 s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
2595 })
2596 .ok()?
2597 .await
2598 .ok()
2599 };
2600 load.await
2601 .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
2602 }
2603
2604 /// Generates a commit message using an LLM.
2605 pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
2606 if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
2607 return;
2608 }
2609
2610 let Some(ConfiguredModel { provider, model }) =
2611 LanguageModelRegistry::read_global(cx).commit_message_model()
2612 else {
2613 return;
2614 };
2615
2616 let Some(repo) = self.active_repository.as_ref() else {
2617 return;
2618 };
2619
2620 telemetry::event!("Git Commit Message Generated");
2621
2622 let diff = repo.update(cx, |repo, cx| {
2623 if self.has_staged_changes() {
2624 repo.diff(DiffType::HeadToIndex, cx)
2625 } else {
2626 repo.diff(DiffType::HeadToWorktree, cx)
2627 }
2628 });
2629
2630 let temperature = AgentSettings::temperature_for_model(&model, cx);
2631 let project = self.project.clone();
2632 let repo_work_dir = repo.read(cx).work_directory_abs_path.clone();
2633
2634 // Remove this once we stop supporting legacy Zed Pro
2635 let is_using_legacy_zed_pro = provider.id() == ZED_CLOUD_PROVIDER_ID
2636 && self.workspace.upgrade().map_or(false, |workspace| {
2637 workspace.read(cx).user_store().read(cx).plan()
2638 == Some(cloud_llm_client::Plan::V1(cloud_llm_client::PlanV1::ZedPro))
2639 });
2640
2641 self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| {
2642 async move {
2643 let _defer = cx.on_drop(&this, |this, _cx| {
2644 this.generate_commit_message_task.take();
2645 });
2646
2647 if let Some(task) = cx.update(|cx| {
2648 if !provider.is_authenticated(cx) {
2649 Some(provider.authenticate(cx))
2650 } else {
2651 None
2652 }
2653 })? {
2654 task.await.log_err();
2655 };
2656
2657 let mut diff_text = match diff.await {
2658 Ok(result) => match result {
2659 Ok(text) => text,
2660 Err(e) => {
2661 Self::show_commit_message_error(&this, &e, cx);
2662 return anyhow::Ok(());
2663 }
2664 },
2665 Err(e) => {
2666 Self::show_commit_message_error(&this, &e, cx);
2667 return anyhow::Ok(());
2668 }
2669 };
2670
2671 const MAX_DIFF_BYTES: usize = 20_000;
2672 diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES);
2673
2674 let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await;
2675
2676 let prompt = Self::load_commit_message_prompt(is_using_legacy_zed_pro, &mut cx).await;
2677
2678 let subject = this.update(cx, |this, cx| {
2679 this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
2680 })?;
2681
2682 let text_empty = subject.trim().is_empty();
2683
2684 let rules_section = match &rules_content {
2685 Some(rules) => format!(
2686 "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\
2687 <project_rules>\n{rules}\n</project_rules>\n"
2688 ),
2689 None => String::new(),
2690 };
2691
2692 let subject_section = if text_empty {
2693 String::new()
2694 } else {
2695 format!("\nHere is the user's subject line:\n{subject}")
2696 };
2697
2698 let content = format!(
2699 "{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
2700 );
2701
2702 let request = LanguageModelRequest {
2703 thread_id: None,
2704 prompt_id: None,
2705 intent: Some(CompletionIntent::GenerateGitCommitMessage),
2706 mode: None,
2707 messages: vec![LanguageModelRequestMessage {
2708 role: Role::User,
2709 content: vec![content.into()],
2710 cache: false,
2711 reasoning_details: None,
2712 }],
2713 tools: Vec::new(),
2714 tool_choice: None,
2715 stop: Vec::new(),
2716 temperature,
2717 thinking_allowed: false,
2718 };
2719
2720 let stream = model.stream_completion_text(request, cx);
2721 match stream.await {
2722 Ok(mut messages) => {
2723 if !text_empty {
2724 this.update(cx, |this, cx| {
2725 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2726 let insert_position = buffer.anchor_before(buffer.len());
2727 buffer.edit([(insert_position..insert_position, "\n")], None, cx)
2728 });
2729 })?;
2730 }
2731
2732 while let Some(message) = messages.stream.next().await {
2733 match message {
2734 Ok(text) => {
2735 this.update(cx, |this, cx| {
2736 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2737 let insert_position = buffer.anchor_before(buffer.len());
2738 buffer.edit([(insert_position..insert_position, text)], None, cx);
2739 });
2740 })?;
2741 }
2742 Err(e) => {
2743 Self::show_commit_message_error(&this, &e, cx);
2744 break;
2745 }
2746 }
2747 }
2748 }
2749 Err(e) => {
2750 Self::show_commit_message_error(&this, &e, cx);
2751 }
2752 }
2753
2754 anyhow::Ok(())
2755 }
2756 .log_err().await
2757 }));
2758 }
2759
2760 fn get_fetch_options(
2761 &self,
2762 window: &mut Window,
2763 cx: &mut Context<Self>,
2764 ) -> Task<Option<FetchOptions>> {
2765 let repo = self.active_repository.clone();
2766 let workspace = self.workspace.clone();
2767
2768 cx.spawn_in(window, async move |_, cx| {
2769 let repo = repo?;
2770 let remotes = repo
2771 .update(cx, |repo, _| repo.get_remotes(None, false))
2772 .ok()?
2773 .await
2774 .ok()?
2775 .log_err()?;
2776
2777 let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
2778 if remotes.len() > 1 {
2779 remotes.push(FetchOptions::All);
2780 }
2781 let selection = cx
2782 .update(|window, cx| {
2783 picker_prompt::prompt(
2784 "Pick which remote to fetch",
2785 remotes.iter().map(|r| r.name()).collect(),
2786 workspace,
2787 window,
2788 cx,
2789 )
2790 })
2791 .ok()?
2792 .await?;
2793 remotes.get(selection).cloned()
2794 })
2795 }
2796
2797 pub(crate) fn fetch(
2798 &mut self,
2799 is_fetch_all: bool,
2800 window: &mut Window,
2801 cx: &mut Context<Self>,
2802 ) {
2803 if !self.can_push_and_pull(cx) {
2804 return;
2805 }
2806
2807 let Some(repo) = self.active_repository.clone() else {
2808 return;
2809 };
2810 telemetry::event!("Git Fetched");
2811 let askpass = self.askpass_delegate("git fetch", window, cx);
2812 let this = cx.weak_entity();
2813
2814 let fetch_options = if is_fetch_all {
2815 Task::ready(Some(FetchOptions::All))
2816 } else {
2817 self.get_fetch_options(window, cx)
2818 };
2819
2820 window
2821 .spawn(cx, async move |cx| {
2822 let Some(fetch_options) = fetch_options.await else {
2823 return Ok(());
2824 };
2825 let fetch = repo.update(cx, |repo, cx| {
2826 repo.fetch(fetch_options.clone(), askpass, cx)
2827 })?;
2828
2829 let remote_message = fetch.await?;
2830 this.update(cx, |this, cx| {
2831 let action = match fetch_options {
2832 FetchOptions::All => RemoteAction::Fetch(None),
2833 FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
2834 };
2835 match remote_message {
2836 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2837 Err(e) => {
2838 log::error!("Error while fetching {:?}", e);
2839 this.show_error_toast(action.name(), e, cx)
2840 }
2841 }
2842
2843 anyhow::Ok(())
2844 })
2845 .ok();
2846 anyhow::Ok(())
2847 })
2848 .detach_and_log_err(cx);
2849 }
2850
2851 pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
2852 let path = cx.prompt_for_paths(gpui::PathPromptOptions {
2853 files: false,
2854 directories: true,
2855 multiple: false,
2856 prompt: Some("Select as Repository Destination".into()),
2857 });
2858
2859 let workspace = self.workspace.clone();
2860
2861 cx.spawn_in(window, async move |this, cx| {
2862 let mut paths = path.await.ok()?.ok()??;
2863 let mut path = paths.pop()?;
2864 let repo_name = repo.split("/").last()?.strip_suffix(".git")?.to_owned();
2865
2866 let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
2867
2868 let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
2869 Ok(_) => cx.update(|window, cx| {
2870 window.prompt(
2871 PromptLevel::Info,
2872 &format!("Git Clone: {}", repo_name),
2873 None,
2874 &["Add repo to project", "Open repo in new project"],
2875 cx,
2876 )
2877 }),
2878 Err(e) => {
2879 this.update(cx, |this: &mut GitPanel, cx| {
2880 let toast = StatusToast::new(e.to_string(), cx, |this, _| {
2881 this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
2882 .dismiss_button(true)
2883 });
2884
2885 this.workspace
2886 .update(cx, |workspace, cx| {
2887 workspace.toggle_status_toast(toast, cx);
2888 })
2889 .ok();
2890 })
2891 .ok()?;
2892
2893 return None;
2894 }
2895 }
2896 .ok()?;
2897
2898 path.push(repo_name);
2899 match prompt_answer.await.ok()? {
2900 0 => {
2901 workspace
2902 .update(cx, |workspace, cx| {
2903 workspace
2904 .project()
2905 .update(cx, |project, cx| {
2906 project.create_worktree(path.as_path(), true, cx)
2907 })
2908 .detach();
2909 })
2910 .ok();
2911 }
2912 1 => {
2913 workspace
2914 .update(cx, move |workspace, cx| {
2915 workspace::open_new(
2916 Default::default(),
2917 workspace.app_state().clone(),
2918 cx,
2919 move |workspace, _, cx| {
2920 cx.activate(true);
2921 workspace
2922 .project()
2923 .update(cx, |project, cx| {
2924 project.create_worktree(&path, true, cx)
2925 })
2926 .detach();
2927 },
2928 )
2929 .detach();
2930 })
2931 .ok();
2932 }
2933 _ => {}
2934 }
2935
2936 Some(())
2937 })
2938 .detach();
2939 }
2940
2941 pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2942 let worktrees = self
2943 .project
2944 .read(cx)
2945 .visible_worktrees(cx)
2946 .collect::<Vec<_>>();
2947
2948 let worktree = if worktrees.len() == 1 {
2949 Task::ready(Some(worktrees.first().unwrap().clone()))
2950 } else if worktrees.is_empty() {
2951 let result = window.prompt(
2952 PromptLevel::Warning,
2953 "Unable to initialize a git repository",
2954 Some("Open a directory first"),
2955 &["Ok"],
2956 cx,
2957 );
2958 cx.background_executor()
2959 .spawn(async move {
2960 result.await.ok();
2961 })
2962 .detach();
2963 return;
2964 } else {
2965 let worktree_directories = worktrees
2966 .iter()
2967 .map(|worktree| worktree.read(cx).abs_path())
2968 .map(|worktree_abs_path| {
2969 if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2970 Path::new("~")
2971 .join(path)
2972 .to_string_lossy()
2973 .to_string()
2974 .into()
2975 } else {
2976 worktree_abs_path.to_string_lossy().into_owned().into()
2977 }
2978 })
2979 .collect_vec();
2980 let prompt = picker_prompt::prompt(
2981 "Where would you like to initialize this git repository?",
2982 worktree_directories,
2983 self.workspace.clone(),
2984 window,
2985 cx,
2986 );
2987
2988 cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2989 };
2990
2991 cx.spawn_in(window, async move |this, cx| {
2992 let worktree = match worktree.await {
2993 Some(worktree) => worktree,
2994 None => {
2995 return;
2996 }
2997 };
2998
2999 let Ok(result) = this.update(cx, |this, cx| {
3000 let fallback_branch_name = GitPanelSettings::get_global(cx)
3001 .fallback_branch_name
3002 .clone();
3003 this.project.read(cx).git_init(
3004 worktree.read(cx).abs_path(),
3005 fallback_branch_name,
3006 cx,
3007 )
3008 }) else {
3009 return;
3010 };
3011
3012 let result = result.await;
3013
3014 this.update_in(cx, |this, _, cx| match result {
3015 Ok(()) => {}
3016 Err(e) => this.show_error_toast("init", e, cx),
3017 })
3018 .ok();
3019 })
3020 .detach();
3021 }
3022
3023 pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context<Self>) {
3024 if !self.can_push_and_pull(cx) {
3025 return;
3026 }
3027 let Some(repo) = self.active_repository.clone() else {
3028 return;
3029 };
3030 let Some(branch) = repo.read(cx).branch.as_ref() else {
3031 return;
3032 };
3033 telemetry::event!("Git Pulled");
3034 let branch = branch.clone();
3035 let remote = self.get_remote(false, false, window, cx);
3036 cx.spawn_in(window, async move |this, cx| {
3037 let remote = match remote.await {
3038 Ok(Some(remote)) => remote,
3039 Ok(None) => {
3040 return Ok(());
3041 }
3042 Err(e) => {
3043 log::error!("Failed to get current remote: {}", e);
3044 this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
3045 .ok();
3046 return Ok(());
3047 }
3048 };
3049
3050 let askpass = this.update_in(cx, |this, window, cx| {
3051 this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
3052 })?;
3053
3054 let branch_name = branch
3055 .upstream
3056 .is_none()
3057 .then(|| branch.name().to_owned().into());
3058
3059 let pull = repo.update(cx, |repo, cx| {
3060 repo.pull(branch_name, remote.name.clone(), rebase, askpass, cx)
3061 })?;
3062
3063 let remote_message = pull.await?;
3064
3065 let action = RemoteAction::Pull(remote);
3066 this.update(cx, |this, cx| match remote_message {
3067 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3068 Err(e) => {
3069 log::error!("Error while pulling {:?}", e);
3070 this.show_error_toast(action.name(), e, cx)
3071 }
3072 })
3073 .ok();
3074
3075 anyhow::Ok(())
3076 })
3077 .detach_and_log_err(cx);
3078 }
3079
3080 pub(crate) fn push(
3081 &mut self,
3082 force_push: bool,
3083 select_remote: bool,
3084 window: &mut Window,
3085 cx: &mut Context<Self>,
3086 ) {
3087 if !self.can_push_and_pull(cx) {
3088 return;
3089 }
3090 let Some(repo) = self.active_repository.clone() else {
3091 return;
3092 };
3093 let Some(branch) = repo.read(cx).branch.as_ref() else {
3094 return;
3095 };
3096 telemetry::event!("Git Pushed");
3097 let branch = branch.clone();
3098
3099 let options = if force_push {
3100 Some(PushOptions::Force)
3101 } else {
3102 match branch.upstream {
3103 Some(Upstream {
3104 tracking: UpstreamTracking::Gone,
3105 ..
3106 })
3107 | None => Some(PushOptions::SetUpstream),
3108 _ => None,
3109 }
3110 };
3111 let remote = self.get_remote(select_remote, true, window, cx);
3112
3113 cx.spawn_in(window, async move |this, cx| {
3114 let remote = match remote.await {
3115 Ok(Some(remote)) => remote,
3116 Ok(None) => {
3117 return Ok(());
3118 }
3119 Err(e) => {
3120 log::error!("Failed to get current remote: {}", e);
3121 this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
3122 .ok();
3123 return Ok(());
3124 }
3125 };
3126
3127 let askpass_delegate = this.update_in(cx, |this, window, cx| {
3128 this.askpass_delegate(format!("git push {}", remote.name), window, cx)
3129 })?;
3130
3131 let push = repo.update(cx, |repo, cx| {
3132 repo.push(
3133 branch.name().to_owned().into(),
3134 remote.name.clone(),
3135 options,
3136 askpass_delegate,
3137 cx,
3138 )
3139 })?;
3140
3141 let remote_output = push.await?;
3142
3143 let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
3144 this.update(cx, |this, cx| match remote_output {
3145 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3146 Err(e) => {
3147 log::error!("Error while pushing {:?}", e);
3148 this.show_error_toast(action.name(), e, cx)
3149 }
3150 })?;
3151
3152 anyhow::Ok(())
3153 })
3154 .detach_and_log_err(cx);
3155 }
3156
3157 fn askpass_delegate(
3158 &self,
3159 operation: impl Into<SharedString>,
3160 window: &mut Window,
3161 cx: &mut Context<Self>,
3162 ) -> AskPassDelegate {
3163 let this = cx.weak_entity();
3164 let operation = operation.into();
3165 let window = window.window_handle();
3166 AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
3167 window
3168 .update(cx, |_, window, cx| {
3169 this.update(cx, |this, cx| {
3170 this.workspace.update(cx, |workspace, cx| {
3171 workspace.toggle_modal(window, cx, |window, cx| {
3172 AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
3173 });
3174 })
3175 })
3176 })
3177 .ok();
3178 })
3179 }
3180
3181 fn can_push_and_pull(&self, cx: &App) -> bool {
3182 !self.project.read(cx).is_via_collab()
3183 }
3184
3185 fn get_remote(
3186 &mut self,
3187 always_select: bool,
3188 is_push: bool,
3189 window: &mut Window,
3190 cx: &mut Context<Self>,
3191 ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
3192 let repo = self.active_repository.clone();
3193 let workspace = self.workspace.clone();
3194 let mut cx = window.to_async(cx);
3195
3196 async move {
3197 let repo = repo.context("No active repository")?;
3198 let current_remotes: Vec<Remote> = repo
3199 .update(&mut cx, |repo, _| {
3200 let current_branch = if always_select {
3201 None
3202 } else {
3203 let current_branch = repo.branch.as_ref().context("No active branch")?;
3204 Some(current_branch.name().to_string())
3205 };
3206 anyhow::Ok(repo.get_remotes(current_branch, is_push))
3207 })??
3208 .await??;
3209
3210 let current_remotes: Vec<_> = current_remotes
3211 .into_iter()
3212 .map(|remotes| remotes.name)
3213 .collect();
3214 let selection = cx
3215 .update(|window, cx| {
3216 picker_prompt::prompt(
3217 "Pick which remote to push to",
3218 current_remotes.clone(),
3219 workspace,
3220 window,
3221 cx,
3222 )
3223 })?
3224 .await;
3225
3226 Ok(selection.map(|selection| Remote {
3227 name: current_remotes[selection].clone(),
3228 }))
3229 }
3230 }
3231
3232 pub fn load_local_committer(&mut self, cx: &Context<Self>) {
3233 if self.local_committer_task.is_none() {
3234 self.local_committer_task = Some(cx.spawn(async move |this, cx| {
3235 let committer = get_git_committer(cx).await;
3236 this.update(cx, |this, cx| {
3237 this.local_committer = Some(committer);
3238 cx.notify()
3239 })
3240 .ok();
3241 }));
3242 }
3243 }
3244
3245 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
3246 let mut new_co_authors = Vec::new();
3247 let project = self.project.read(cx);
3248
3249 let Some(room) = self
3250 .workspace
3251 .upgrade()
3252 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
3253 else {
3254 return Vec::default();
3255 };
3256
3257 let room = room.read(cx);
3258
3259 for (peer_id, collaborator) in project.collaborators() {
3260 if collaborator.is_host {
3261 continue;
3262 }
3263
3264 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
3265 continue;
3266 };
3267 if !participant.can_write() {
3268 continue;
3269 }
3270 if let Some(email) = &collaborator.committer_email {
3271 let name = collaborator
3272 .committer_name
3273 .clone()
3274 .or_else(|| participant.user.name.clone())
3275 .unwrap_or_else(|| participant.user.github_login.clone().to_string());
3276 new_co_authors.push((name.clone(), email.clone()))
3277 }
3278 }
3279 if !project.is_local()
3280 && !project.is_read_only(cx)
3281 && let Some(local_committer) = self.local_committer(room, cx)
3282 {
3283 new_co_authors.push(local_committer);
3284 }
3285 new_co_authors
3286 }
3287
3288 fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
3289 let user = room.local_participant_user(cx)?;
3290 let committer = self.local_committer.as_ref()?;
3291 let email = committer.email.clone()?;
3292 let name = committer
3293 .name
3294 .clone()
3295 .or_else(|| user.name.clone())
3296 .unwrap_or_else(|| user.github_login.clone().to_string());
3297 Some((name, email))
3298 }
3299
3300 fn toggle_fill_co_authors(
3301 &mut self,
3302 _: &ToggleFillCoAuthors,
3303 _: &mut Window,
3304 cx: &mut Context<Self>,
3305 ) {
3306 self.add_coauthors = !self.add_coauthors;
3307 cx.notify();
3308 }
3309
3310 fn toggle_sort_by_path(
3311 &mut self,
3312 _: &ToggleSortByPath,
3313 _: &mut Window,
3314 cx: &mut Context<Self>,
3315 ) {
3316 let current_setting = GitPanelSettings::get_global(cx).sort_by_path;
3317 if let Some(workspace) = self.workspace.upgrade() {
3318 let workspace = workspace.read(cx);
3319 let fs = workspace.app_state().fs.clone();
3320 cx.update_global::<SettingsStore, _>(|store, _cx| {
3321 store.update_settings_file(fs, move |settings, _cx| {
3322 settings.git_panel.get_or_insert_default().sort_by_path =
3323 Some(!current_setting);
3324 });
3325 });
3326 }
3327 }
3328
3329 fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context<Self>) {
3330 let current_setting = GitPanelSettings::get_global(cx).tree_view;
3331 if let Some(workspace) = self.workspace.upgrade() {
3332 let workspace = workspace.read(cx);
3333 let fs = workspace.app_state().fs.clone();
3334 cx.update_global::<SettingsStore, _>(|store, _cx| {
3335 store.update_settings_file(fs, move |settings, _cx| {
3336 settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting);
3337 });
3338 })
3339 }
3340 }
3341
3342 fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context<Self>) {
3343 if let Some(state) = self.view_mode.tree_state_mut() {
3344 let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
3345 *expanded = !*expanded;
3346 self.update_visible_entries(window, cx);
3347 } else {
3348 util::debug_panic!("Attempted to toggle directory in flat Git Panel state");
3349 }
3350 }
3351
3352 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
3353 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
3354
3355 let existing_text = message.to_ascii_lowercase();
3356 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
3357 let mut ends_with_co_authors = false;
3358 let existing_co_authors = existing_text
3359 .lines()
3360 .filter_map(|line| {
3361 let line = line.trim();
3362 if line.starts_with(&lowercase_co_author_prefix) {
3363 ends_with_co_authors = true;
3364 Some(line)
3365 } else {
3366 ends_with_co_authors = false;
3367 None
3368 }
3369 })
3370 .collect::<HashSet<_>>();
3371
3372 let new_co_authors = self
3373 .potential_co_authors(cx)
3374 .into_iter()
3375 .filter(|(_, email)| {
3376 !existing_co_authors
3377 .iter()
3378 .any(|existing| existing.contains(email.as_str()))
3379 })
3380 .collect::<Vec<_>>();
3381
3382 if new_co_authors.is_empty() {
3383 return;
3384 }
3385
3386 if !ends_with_co_authors {
3387 message.push('\n');
3388 }
3389 for (name, email) in new_co_authors {
3390 message.push('\n');
3391 message.push_str(CO_AUTHOR_PREFIX);
3392 message.push_str(&name);
3393 message.push_str(" <");
3394 message.push_str(&email);
3395 message.push('>');
3396 }
3397 message.push('\n');
3398 }
3399
3400 fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3401 let handle = cx.entity().downgrade();
3402 self.reopen_commit_buffer(window, cx);
3403 self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
3404 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
3405 if let Some(git_panel) = handle.upgrade() {
3406 git_panel
3407 .update_in(cx, |git_panel, window, cx| {
3408 git_panel.update_visible_entries(window, cx);
3409 })
3410 .ok();
3411 }
3412 });
3413 }
3414
3415 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3416 let Some(active_repo) = self.active_repository.as_ref() else {
3417 return;
3418 };
3419 let load_buffer = active_repo.update(cx, |active_repo, cx| {
3420 let project = self.project.read(cx);
3421 active_repo.open_commit_buffer(
3422 Some(project.languages().clone()),
3423 project.buffer_store().clone(),
3424 cx,
3425 )
3426 });
3427
3428 cx.spawn_in(window, async move |git_panel, cx| {
3429 let buffer = load_buffer.await?;
3430 git_panel.update_in(cx, |git_panel, window, cx| {
3431 if git_panel
3432 .commit_editor
3433 .read(cx)
3434 .buffer()
3435 .read(cx)
3436 .as_singleton()
3437 .as_ref()
3438 != Some(&buffer)
3439 {
3440 git_panel.commit_editor = cx.new(|cx| {
3441 commit_message_editor(
3442 buffer,
3443 git_panel.suggest_commit_message(cx).map(SharedString::from),
3444 git_panel.project.clone(),
3445 true,
3446 window,
3447 cx,
3448 )
3449 });
3450 }
3451 })
3452 })
3453 .detach_and_log_err(cx);
3454 }
3455
3456 fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3457 let path_style = self.project.read(cx).path_style(cx);
3458 let bulk_staging = self.bulk_staging.take();
3459 let last_staged_path_prev_index = bulk_staging
3460 .as_ref()
3461 .and_then(|op| self.entry_by_path(&op.anchor));
3462
3463 self.entries.clear();
3464 self.entries_indices.clear();
3465 self.single_staged_entry.take();
3466 self.single_tracked_entry.take();
3467 self.conflicted_count = 0;
3468 self.conflicted_staged_count = 0;
3469 self.changes_count = 0;
3470 self.new_count = 0;
3471 self.tracked_count = 0;
3472 self.new_staged_count = 0;
3473 self.tracked_staged_count = 0;
3474 self.entry_count = 0;
3475 self.max_width_item_index = None;
3476
3477 let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
3478 let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
3479 let group_by_status = is_tree_view || !sort_by_path;
3480
3481 let mut changed_entries = Vec::new();
3482 let mut new_entries = Vec::new();
3483 let mut conflict_entries = Vec::new();
3484 let mut single_staged_entry = None;
3485 let mut staged_count = 0;
3486 let mut seen_directories = HashSet::default();
3487 let mut max_width_estimate = 0usize;
3488 let mut max_width_item_index = None;
3489
3490 let Some(repo) = self.active_repository.as_ref() else {
3491 // Just clear entries if no repository is active.
3492 cx.notify();
3493 return;
3494 };
3495
3496 let repo = repo.read(cx);
3497
3498 self.stash_entries = repo.cached_stash();
3499
3500 for entry in repo.cached_status() {
3501 self.changes_count += 1;
3502 let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
3503 let is_new = entry.status.is_created();
3504 let staging = entry.status.staging();
3505
3506 if let Some(pending) = repo.pending_ops_for_path(&entry.repo_path)
3507 && pending
3508 .ops
3509 .iter()
3510 .any(|op| op.git_status == pending_op::GitStatus::Reverted && op.finished())
3511 {
3512 continue;
3513 }
3514
3515 let entry = GitStatusEntry {
3516 repo_path: entry.repo_path.clone(),
3517 status: entry.status,
3518 staging,
3519 };
3520
3521 if staging.has_staged() {
3522 staged_count += 1;
3523 single_staged_entry = Some(entry.clone());
3524 }
3525
3526 if group_by_status && is_conflict {
3527 conflict_entries.push(entry);
3528 } else if group_by_status && is_new {
3529 new_entries.push(entry);
3530 } else {
3531 changed_entries.push(entry);
3532 }
3533 }
3534
3535 if conflict_entries.is_empty() {
3536 if staged_count == 1
3537 && let Some(entry) = single_staged_entry.as_ref()
3538 {
3539 if let Some(ops) = repo.pending_ops_for_path(&entry.repo_path) {
3540 if ops.staged() {
3541 self.single_staged_entry = single_staged_entry;
3542 }
3543 } else {
3544 self.single_staged_entry = single_staged_entry;
3545 }
3546 } else if repo.pending_ops_summary().item_summary.staging_count == 1
3547 && let Some(ops) = repo.pending_ops().find(|ops| ops.staging())
3548 {
3549 self.single_staged_entry =
3550 repo.status_for_path(&ops.repo_path)
3551 .map(|status| GitStatusEntry {
3552 repo_path: ops.repo_path.clone(),
3553 status: status.status,
3554 staging: StageStatus::Staged,
3555 });
3556 }
3557 }
3558
3559 if conflict_entries.is_empty() && changed_entries.len() == 1 {
3560 self.single_tracked_entry = changed_entries.first().cloned();
3561 }
3562
3563 let mut push_entry =
3564 |this: &mut Self,
3565 entry: GitListEntry,
3566 is_visible: bool,
3567 logical_indices: Option<&mut Vec<usize>>| {
3568 if let Some(estimate) =
3569 this.width_estimate_for_list_entry(is_tree_view, &entry, path_style)
3570 {
3571 if estimate > max_width_estimate {
3572 max_width_estimate = estimate;
3573 max_width_item_index = Some(this.entries.len());
3574 }
3575 }
3576
3577 if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
3578 {
3579 this.entries_indices.insert(repo_path, this.entries.len());
3580 }
3581
3582 if let (Some(indices), true) = (logical_indices, is_visible) {
3583 indices.push(this.entries.len());
3584 }
3585
3586 this.entries.push(entry);
3587 };
3588
3589 macro_rules! take_section_entries {
3590 () => {
3591 [
3592 (Section::Conflict, std::mem::take(&mut conflict_entries)),
3593 (Section::Tracked, std::mem::take(&mut changed_entries)),
3594 (Section::New, std::mem::take(&mut new_entries)),
3595 ]
3596 };
3597 }
3598
3599 match &mut self.view_mode {
3600 GitPanelViewMode::Tree(tree_state) => {
3601 tree_state.logical_indices.clear();
3602 tree_state.directory_descendants.clear();
3603
3604 // This is just to get around the borrow checker
3605 // because push_entry mutably borrows self
3606 let mut tree_state = std::mem::take(tree_state);
3607
3608 for (section, entries) in take_section_entries!() {
3609 if entries.is_empty() {
3610 continue;
3611 }
3612
3613 push_entry(
3614 self,
3615 GitListEntry::Header(GitHeaderEntry { header: section }),
3616 true,
3617 Some(&mut tree_state.logical_indices),
3618 );
3619
3620 for (entry, is_visible) in
3621 tree_state.build_tree_entries(section, entries, &mut seen_directories)
3622 {
3623 push_entry(
3624 self,
3625 entry,
3626 is_visible,
3627 Some(&mut tree_state.logical_indices),
3628 );
3629 }
3630 }
3631
3632 tree_state
3633 .expanded_dirs
3634 .retain(|key, _| seen_directories.contains(key));
3635 self.view_mode = GitPanelViewMode::Tree(tree_state);
3636 }
3637 GitPanelViewMode::Flat => {
3638 for (section, entries) in take_section_entries!() {
3639 if entries.is_empty() {
3640 continue;
3641 }
3642
3643 if section != Section::Tracked || !sort_by_path {
3644 push_entry(
3645 self,
3646 GitListEntry::Header(GitHeaderEntry { header: section }),
3647 true,
3648 None,
3649 );
3650 }
3651
3652 for entry in entries {
3653 push_entry(self, GitListEntry::Status(entry), true, None);
3654 }
3655 }
3656 }
3657 }
3658
3659 self.max_width_item_index = max_width_item_index;
3660
3661 self.update_counts(repo);
3662
3663 let bulk_staging_anchor_new_index = bulk_staging
3664 .as_ref()
3665 .filter(|op| op.repo_id == repo.id)
3666 .and_then(|op| self.entry_by_path(&op.anchor));
3667 if bulk_staging_anchor_new_index == last_staged_path_prev_index
3668 && let Some(index) = bulk_staging_anchor_new_index
3669 && let Some(entry) = self.entries.get(index)
3670 && let Some(entry) = entry.status_entry()
3671 && GitPanel::stage_status_for_entry(entry, &repo)
3672 .as_bool()
3673 .unwrap_or(false)
3674 {
3675 self.bulk_staging = bulk_staging;
3676 }
3677
3678 self.select_first_entry_if_none(window, cx);
3679
3680 let suggested_commit_message = self.suggest_commit_message(cx);
3681 let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
3682
3683 self.commit_editor.update(cx, |editor, cx| {
3684 editor.set_placeholder_text(&placeholder_text, window, cx)
3685 });
3686
3687 cx.notify();
3688 }
3689
3690 fn header_state(&self, header_type: Section) -> ToggleState {
3691 let (staged_count, count) = match header_type {
3692 Section::New => (self.new_staged_count, self.new_count),
3693 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
3694 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
3695 };
3696 if staged_count == 0 {
3697 ToggleState::Unselected
3698 } else if count == staged_count {
3699 ToggleState::Selected
3700 } else {
3701 ToggleState::Indeterminate
3702 }
3703 }
3704
3705 fn update_counts(&mut self, repo: &Repository) {
3706 self.show_placeholders = false;
3707 self.conflicted_count = 0;
3708 self.conflicted_staged_count = 0;
3709 self.new_count = 0;
3710 self.tracked_count = 0;
3711 self.new_staged_count = 0;
3712 self.tracked_staged_count = 0;
3713 self.entry_count = 0;
3714
3715 for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) {
3716 self.entry_count += 1;
3717 let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo)
3718 .as_bool()
3719 .unwrap_or(false);
3720
3721 if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
3722 self.conflicted_count += 1;
3723 if is_staging_or_staged {
3724 self.conflicted_staged_count += 1;
3725 }
3726 } else if status_entry.status.is_created() {
3727 self.new_count += 1;
3728 if is_staging_or_staged {
3729 self.new_staged_count += 1;
3730 }
3731 } else {
3732 self.tracked_count += 1;
3733 if is_staging_or_staged {
3734 self.tracked_staged_count += 1;
3735 }
3736 }
3737 }
3738 }
3739
3740 pub(crate) fn has_staged_changes(&self) -> bool {
3741 self.tracked_staged_count > 0
3742 || self.new_staged_count > 0
3743 || self.conflicted_staged_count > 0
3744 }
3745
3746 pub(crate) fn has_unstaged_changes(&self) -> bool {
3747 self.tracked_count > self.tracked_staged_count
3748 || self.new_count > self.new_staged_count
3749 || self.conflicted_count > self.conflicted_staged_count
3750 }
3751
3752 fn has_tracked_changes(&self) -> bool {
3753 self.tracked_count > 0
3754 }
3755
3756 pub fn has_unstaged_conflicts(&self) -> bool {
3757 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
3758 }
3759
3760 fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
3761 let Some(workspace) = self.workspace.upgrade() else {
3762 return;
3763 };
3764 show_error_toast(workspace, action, e, cx)
3765 }
3766
3767 fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
3768 where
3769 E: std::fmt::Debug + std::fmt::Display,
3770 {
3771 if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
3772 let _ = workspace.update(cx, |workspace, cx| {
3773 struct CommitMessageError;
3774 let notification_id = NotificationId::unique::<CommitMessageError>();
3775 workspace.show_notification(notification_id, cx, |cx| {
3776 cx.new(|cx| {
3777 ErrorMessagePrompt::new(
3778 format!("Failed to generate commit message: {err}"),
3779 cx,
3780 )
3781 })
3782 });
3783 });
3784 }
3785 }
3786
3787 fn show_remote_output(&self, action: RemoteAction, info: RemoteCommandOutput, cx: &mut App) {
3788 let Some(workspace) = self.workspace.upgrade() else {
3789 return;
3790 };
3791
3792 workspace.update(cx, |workspace, cx| {
3793 let SuccessMessage { message, style } = remote_output::format_output(&action, info);
3794 let workspace_weak = cx.weak_entity();
3795 let operation = action.name();
3796
3797 let status_toast = StatusToast::new(message, cx, move |this, _cx| {
3798 use remote_output::SuccessStyle::*;
3799 match style {
3800 Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
3801 ToastWithLog { output } => this
3802 .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3803 .action("View Log", move |window, cx| {
3804 let output = output.clone();
3805 let output =
3806 format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
3807 workspace_weak
3808 .update(cx, move |workspace, cx| {
3809 open_output(operation, workspace, &output, window, cx)
3810 })
3811 .ok();
3812 }),
3813 PushPrLink { text, link } => this
3814 .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3815 .action(text, move |_, cx| cx.open_url(&link)),
3816 }
3817 .dismiss_button(true)
3818 });
3819 workspace.toggle_status_toast(status_toast, cx)
3820 });
3821 }
3822
3823 pub fn can_commit(&self) -> bool {
3824 (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
3825 }
3826
3827 pub fn can_stage_all(&self) -> bool {
3828 self.has_unstaged_changes()
3829 }
3830
3831 pub fn can_unstage_all(&self) -> bool {
3832 self.has_staged_changes()
3833 }
3834
3835 fn status_width_estimate(
3836 tree_view: bool,
3837 entry: &GitStatusEntry,
3838 path_style: PathStyle,
3839 depth: usize,
3840 ) -> usize {
3841 if tree_view {
3842 Self::item_width_estimate(0, entry.display_name(path_style).len(), depth)
3843 } else {
3844 Self::item_width_estimate(
3845 entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
3846 entry.display_name(path_style).len(),
3847 0,
3848 )
3849 }
3850 }
3851
3852 fn width_estimate_for_list_entry(
3853 &self,
3854 tree_view: bool,
3855 entry: &GitListEntry,
3856 path_style: PathStyle,
3857 ) -> Option<usize> {
3858 match entry {
3859 GitListEntry::Status(status) => Some(Self::status_width_estimate(
3860 tree_view, status, path_style, 0,
3861 )),
3862 GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate(
3863 tree_view,
3864 &status.entry,
3865 path_style,
3866 status.depth,
3867 )),
3868 GitListEntry::Directory(dir) => {
3869 Some(Self::item_width_estimate(0, dir.name.len(), dir.depth))
3870 }
3871 GitListEntry::Header(_) => None,
3872 }
3873 }
3874
3875 fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize {
3876 path + file_name + depth * 2
3877 }
3878
3879 fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
3880 let focus_handle = self.focus_handle.clone();
3881 let has_tracked_changes = self.has_tracked_changes();
3882 let has_staged_changes = self.has_staged_changes();
3883 let has_unstaged_changes = self.has_unstaged_changes();
3884 let has_new_changes = self.new_count > 0;
3885 let has_stash_items = self.stash_entries.entries.len() > 0;
3886
3887 PopoverMenu::new(id.into())
3888 .trigger(
3889 IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
3890 .icon_size(IconSize::Small)
3891 .icon_color(Color::Muted),
3892 )
3893 .menu(move |window, cx| {
3894 Some(git_panel_context_menu(
3895 focus_handle.clone(),
3896 GitMenuState {
3897 has_tracked_changes,
3898 has_staged_changes,
3899 has_unstaged_changes,
3900 has_new_changes,
3901 sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
3902 has_stash_items,
3903 tree_view: GitPanelSettings::get_global(cx).tree_view,
3904 },
3905 window,
3906 cx,
3907 ))
3908 })
3909 .anchor(Corner::TopRight)
3910 }
3911
3912 pub(crate) fn render_generate_commit_message_button(
3913 &self,
3914 cx: &Context<Self>,
3915 ) -> Option<AnyElement> {
3916 if !agent_settings::AgentSettings::get_global(cx).enabled(cx)
3917 || LanguageModelRegistry::read_global(cx)
3918 .commit_message_model()
3919 .is_none()
3920 {
3921 return None;
3922 }
3923
3924 if self.generate_commit_message_task.is_some() {
3925 return Some(
3926 h_flex()
3927 .gap_1()
3928 .child(
3929 Icon::new(IconName::ArrowCircle)
3930 .size(IconSize::XSmall)
3931 .color(Color::Info)
3932 .with_rotate_animation(2),
3933 )
3934 .child(
3935 Label::new("Generating Commit...")
3936 .size(LabelSize::Small)
3937 .color(Color::Muted),
3938 )
3939 .into_any_element(),
3940 );
3941 }
3942
3943 let can_commit = self.can_commit();
3944 let editor_focus_handle = self.commit_editor.focus_handle(cx);
3945 Some(
3946 IconButton::new("generate-commit-message", IconName::AiEdit)
3947 .shape(ui::IconButtonShape::Square)
3948 .icon_color(Color::Muted)
3949 .tooltip(move |_window, cx| {
3950 if can_commit {
3951 Tooltip::for_action_in(
3952 "Generate Commit Message",
3953 &git::GenerateCommitMessage,
3954 &editor_focus_handle,
3955 cx,
3956 )
3957 } else {
3958 Tooltip::simple("No changes to commit", cx)
3959 }
3960 })
3961 .disabled(!can_commit)
3962 .on_click(cx.listener(move |this, _event, _window, cx| {
3963 this.generate_commit_message(cx);
3964 }))
3965 .into_any_element(),
3966 )
3967 }
3968
3969 pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
3970 let potential_co_authors = self.potential_co_authors(cx);
3971
3972 let (tooltip_label, icon) = if self.add_coauthors {
3973 ("Remove co-authored-by", IconName::Person)
3974 } else {
3975 ("Add co-authored-by", IconName::UserCheck)
3976 };
3977
3978 if potential_co_authors.is_empty() {
3979 None
3980 } else {
3981 Some(
3982 IconButton::new("co-authors", icon)
3983 .shape(ui::IconButtonShape::Square)
3984 .icon_color(Color::Disabled)
3985 .selected_icon_color(Color::Selected)
3986 .toggle_state(self.add_coauthors)
3987 .tooltip(move |_, cx| {
3988 let title = format!(
3989 "{}:{}{}",
3990 tooltip_label,
3991 if potential_co_authors.len() == 1 {
3992 ""
3993 } else {
3994 "\n"
3995 },
3996 potential_co_authors
3997 .iter()
3998 .map(|(name, email)| format!(" {} <{}>", name, email))
3999 .join("\n")
4000 );
4001 Tooltip::simple(title, cx)
4002 })
4003 .on_click(cx.listener(|this, _, _, cx| {
4004 this.add_coauthors = !this.add_coauthors;
4005 cx.notify();
4006 }))
4007 .into_any_element(),
4008 )
4009 }
4010 }
4011
4012 fn render_git_commit_menu(
4013 &self,
4014 id: impl Into<ElementId>,
4015 keybinding_target: Option<FocusHandle>,
4016 cx: &mut Context<Self>,
4017 ) -> impl IntoElement {
4018 PopoverMenu::new(id.into())
4019 .trigger(
4020 ui::ButtonLike::new_rounded_right("commit-split-button-right")
4021 .layer(ui::ElevationIndex::ModalSurface)
4022 .size(ButtonSize::None)
4023 .child(
4024 h_flex()
4025 .px_1()
4026 .h_full()
4027 .justify_center()
4028 .border_l_1()
4029 .border_color(cx.theme().colors().border)
4030 .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
4031 ),
4032 )
4033 .menu({
4034 let git_panel = cx.entity();
4035 let has_previous_commit = self.head_commit(cx).is_some();
4036 let amend = self.amend_pending();
4037 let signoff = self.signoff_enabled;
4038
4039 move |window, cx| {
4040 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
4041 context_menu
4042 .when_some(keybinding_target.clone(), |el, keybinding_target| {
4043 el.context(keybinding_target)
4044 })
4045 .when(has_previous_commit, |this| {
4046 this.toggleable_entry(
4047 "Amend",
4048 amend,
4049 IconPosition::Start,
4050 Some(Box::new(Amend)),
4051 {
4052 let git_panel = git_panel.downgrade();
4053 move |_, cx| {
4054 git_panel
4055 .update(cx, |git_panel, cx| {
4056 git_panel.toggle_amend_pending(cx);
4057 })
4058 .ok();
4059 }
4060 },
4061 )
4062 })
4063 .toggleable_entry(
4064 "Signoff",
4065 signoff,
4066 IconPosition::Start,
4067 Some(Box::new(Signoff)),
4068 move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
4069 )
4070 }))
4071 }
4072 })
4073 .anchor(Corner::TopRight)
4074 }
4075
4076 pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
4077 if self.has_unstaged_conflicts() {
4078 (false, "You must resolve conflicts before committing")
4079 } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending {
4080 (false, "No changes to commit")
4081 } else if self.pending_commit.is_some() {
4082 (false, "Commit in progress")
4083 } else if !self.has_commit_message(cx) {
4084 (false, "No commit message")
4085 } else if !self.has_write_access(cx) {
4086 (false, "You do not have write access to this project")
4087 } else {
4088 (true, self.commit_button_title())
4089 }
4090 }
4091
4092 pub fn commit_button_title(&self) -> &'static str {
4093 if self.amend_pending {
4094 if self.has_staged_changes() {
4095 "Amend"
4096 } else if self.has_tracked_changes() {
4097 "Amend Tracked"
4098 } else {
4099 "Amend"
4100 }
4101 } else if self.has_staged_changes() {
4102 "Commit"
4103 } else {
4104 "Commit Tracked"
4105 }
4106 }
4107
4108 fn expand_commit_editor(
4109 &mut self,
4110 _: &git::ExpandCommitEditor,
4111 window: &mut Window,
4112 cx: &mut Context<Self>,
4113 ) {
4114 let workspace = self.workspace.clone();
4115 window.defer(cx, move |window, cx| {
4116 workspace
4117 .update(cx, |workspace, cx| {
4118 CommitModal::toggle(workspace, None, window, cx)
4119 })
4120 .ok();
4121 })
4122 }
4123
4124 fn render_panel_header(
4125 &self,
4126 window: &mut Window,
4127 cx: &mut Context<Self>,
4128 ) -> Option<impl IntoElement> {
4129 self.active_repository.as_ref()?;
4130
4131 let (text, action, stage, tooltip) =
4132 if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
4133 ("Unstage All", UnstageAll.boxed_clone(), false, "git reset")
4134 } else {
4135 ("Stage All", StageAll.boxed_clone(), true, "git add --all")
4136 };
4137
4138 let change_string = match self.changes_count {
4139 0 => "No Changes".to_string(),
4140 1 => "1 Change".to_string(),
4141 count => format!("{} Changes", count),
4142 };
4143
4144 Some(
4145 self.panel_header_container(window, cx)
4146 .px_2()
4147 .justify_between()
4148 .child(
4149 panel_button(change_string)
4150 .color(Color::Muted)
4151 .tooltip(Tooltip::for_action_title_in(
4152 "Open Diff",
4153 &Diff,
4154 &self.focus_handle,
4155 ))
4156 .on_click(|_, _, cx| {
4157 cx.defer(|cx| {
4158 cx.dispatch_action(&Diff);
4159 })
4160 }),
4161 )
4162 .child(
4163 h_flex()
4164 .gap_1()
4165 .child(self.render_overflow_menu("overflow_menu"))
4166 .child(
4167 panel_filled_button(text)
4168 .tooltip(Tooltip::for_action_title_in(
4169 tooltip,
4170 action.as_ref(),
4171 &self.focus_handle,
4172 ))
4173 .disabled(self.entry_count == 0)
4174 .on_click({
4175 let git_panel = cx.weak_entity();
4176 move |_, _, cx| {
4177 git_panel
4178 .update(cx, |git_panel, cx| {
4179 git_panel.change_all_files_stage(stage, cx);
4180 })
4181 .ok();
4182 }
4183 }),
4184 ),
4185 ),
4186 )
4187 }
4188
4189 pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4190 let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
4191 if !self.can_push_and_pull(cx) {
4192 return None;
4193 }
4194 Some(
4195 h_flex()
4196 .gap_1()
4197 .flex_shrink_0()
4198 .when_some(branch, |this, branch| {
4199 let focus_handle = Some(self.focus_handle(cx));
4200
4201 this.children(render_remote_button(
4202 "remote-button",
4203 &branch,
4204 focus_handle,
4205 true,
4206 ))
4207 })
4208 .into_any_element(),
4209 )
4210 }
4211
4212 pub fn render_footer(
4213 &self,
4214 window: &mut Window,
4215 cx: &mut Context<Self>,
4216 ) -> Option<impl IntoElement> {
4217 let active_repository = self.active_repository.clone()?;
4218 let panel_editor_style = panel_editor_style(true, window, cx);
4219 let enable_coauthors = self.render_co_authors(cx);
4220
4221 let editor_focus_handle = self.commit_editor.focus_handle(cx);
4222 let expand_tooltip_focus_handle = editor_focus_handle;
4223
4224 let branch = active_repository.read(cx).branch.clone();
4225 let head_commit = active_repository.read(cx).head_commit.clone();
4226
4227 let footer_size = px(32.);
4228 let gap = px(9.0);
4229 let max_height = panel_editor_style
4230 .text
4231 .line_height_in_pixels(window.rem_size())
4232 * MAX_PANEL_EDITOR_LINES
4233 + gap;
4234
4235 let git_panel = cx.entity();
4236 let display_name = SharedString::from(Arc::from(
4237 active_repository
4238 .read(cx)
4239 .display_name()
4240 .trim_end_matches("/"),
4241 ));
4242 let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
4243 editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
4244 });
4245
4246 let footer = v_flex()
4247 .child(PanelRepoFooter::new(
4248 display_name,
4249 branch,
4250 head_commit,
4251 Some(git_panel),
4252 ))
4253 .child(
4254 panel_editor_container(window, cx)
4255 .id("commit-editor-container")
4256 .relative()
4257 .w_full()
4258 .h(max_height + footer_size)
4259 .border_t_1()
4260 .border_color(cx.theme().colors().border)
4261 .cursor_text()
4262 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
4263 window.focus(&this.commit_editor.focus_handle(cx), cx);
4264 }))
4265 .child(
4266 h_flex()
4267 .id("commit-footer")
4268 .border_t_1()
4269 .when(editor_is_long, |el| {
4270 el.border_color(cx.theme().colors().border_variant)
4271 })
4272 .absolute()
4273 .bottom_0()
4274 .left_0()
4275 .w_full()
4276 .px_2()
4277 .h(footer_size)
4278 .flex_none()
4279 .justify_between()
4280 .child(
4281 self.render_generate_commit_message_button(cx)
4282 .unwrap_or_else(|| div().into_any_element()),
4283 )
4284 .child(
4285 h_flex()
4286 .gap_0p5()
4287 .children(enable_coauthors)
4288 .child(self.render_commit_button(cx)),
4289 ),
4290 )
4291 .child(
4292 div()
4293 .pr_2p5()
4294 .on_action(|&editor::actions::MoveUp, _, cx| {
4295 cx.stop_propagation();
4296 })
4297 .on_action(|&editor::actions::MoveDown, _, cx| {
4298 cx.stop_propagation();
4299 })
4300 .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
4301 )
4302 .child(
4303 h_flex()
4304 .absolute()
4305 .top_2()
4306 .right_2()
4307 .opacity(0.5)
4308 .hover(|this| this.opacity(1.0))
4309 .child(
4310 panel_icon_button("expand-commit-editor", IconName::Maximize)
4311 .icon_size(IconSize::Small)
4312 .size(ui::ButtonSize::Default)
4313 .tooltip(move |_window, cx| {
4314 Tooltip::for_action_in(
4315 "Open Commit Modal",
4316 &git::ExpandCommitEditor,
4317 &expand_tooltip_focus_handle,
4318 cx,
4319 )
4320 })
4321 .on_click(cx.listener({
4322 move |_, _, window, cx| {
4323 window.dispatch_action(
4324 git::ExpandCommitEditor.boxed_clone(),
4325 cx,
4326 )
4327 }
4328 })),
4329 ),
4330 ),
4331 );
4332
4333 Some(footer)
4334 }
4335
4336 fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4337 let (can_commit, tooltip) = self.configure_commit_button(cx);
4338 let title = self.commit_button_title();
4339 let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
4340 let amend = self.amend_pending();
4341 let signoff = self.signoff_enabled;
4342
4343 let label_color = if self.pending_commit.is_some() {
4344 Color::Disabled
4345 } else {
4346 Color::Default
4347 };
4348
4349 div()
4350 .id("commit-wrapper")
4351 .on_hover(cx.listener(move |this, hovered, _, cx| {
4352 this.show_placeholders =
4353 *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
4354 cx.notify()
4355 }))
4356 .child(SplitButton::new(
4357 ButtonLike::new_rounded_left(ElementId::Name(
4358 format!("split-button-left-{}", title).into(),
4359 ))
4360 .layer(ElevationIndex::ModalSurface)
4361 .size(ButtonSize::Compact)
4362 .child(
4363 Label::new(title)
4364 .size(LabelSize::Small)
4365 .color(label_color)
4366 .mr_0p5(),
4367 )
4368 .on_click({
4369 let git_panel = cx.weak_entity();
4370 move |_, window, cx| {
4371 telemetry::event!("Git Committed", source = "Git Panel");
4372 git_panel
4373 .update(cx, |git_panel, cx| {
4374 git_panel.commit_changes(
4375 CommitOptions { amend, signoff },
4376 window,
4377 cx,
4378 );
4379 })
4380 .ok();
4381 }
4382 })
4383 .disabled(!can_commit || self.modal_open)
4384 .tooltip({
4385 let handle = commit_tooltip_focus_handle.clone();
4386 move |_window, cx| {
4387 if can_commit {
4388 Tooltip::with_meta_in(
4389 tooltip,
4390 Some(if amend { &git::Amend } else { &git::Commit }),
4391 format!(
4392 "git commit{}{}",
4393 if amend { " --amend" } else { "" },
4394 if signoff { " --signoff" } else { "" }
4395 ),
4396 &handle.clone(),
4397 cx,
4398 )
4399 } else {
4400 Tooltip::simple(tooltip, cx)
4401 }
4402 }
4403 }),
4404 self.render_git_commit_menu(
4405 ElementId::Name(format!("split-button-right-{}", title).into()),
4406 Some(commit_tooltip_focus_handle),
4407 cx,
4408 )
4409 .into_any_element(),
4410 ))
4411 }
4412
4413 fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
4414 h_flex()
4415 .py_1p5()
4416 .px_2()
4417 .gap_1p5()
4418 .justify_between()
4419 .border_t_1()
4420 .border_color(cx.theme().colors().border.opacity(0.8))
4421 .child(
4422 div()
4423 .flex_grow()
4424 .overflow_hidden()
4425 .max_w(relative(0.85))
4426 .child(
4427 Label::new("This will update your most recent commit.")
4428 .size(LabelSize::Small)
4429 .truncate(),
4430 ),
4431 )
4432 .child(
4433 panel_button("Cancel")
4434 .size(ButtonSize::Default)
4435 .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
4436 )
4437 }
4438
4439 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
4440 let active_repository = self.active_repository.as_ref()?;
4441 let branch = active_repository.read(cx).branch.as_ref()?;
4442 let commit = branch.most_recent_commit.as_ref()?.clone();
4443 let workspace = self.workspace.clone();
4444 let this = cx.entity();
4445
4446 Some(
4447 h_flex()
4448 .py_1p5()
4449 .px_2()
4450 .gap_1p5()
4451 .justify_between()
4452 .border_t_1()
4453 .border_color(cx.theme().colors().border.opacity(0.8))
4454 .child(
4455 div()
4456 .cursor_pointer()
4457 .overflow_hidden()
4458 .line_clamp(1)
4459 .child(
4460 Label::new(commit.subject.clone())
4461 .size(LabelSize::Small)
4462 .truncate(),
4463 )
4464 .id("commit-msg-hover")
4465 .on_click({
4466 let commit = commit.clone();
4467 let repo = active_repository.downgrade();
4468 move |_, window, cx| {
4469 CommitView::open(
4470 commit.sha.to_string(),
4471 repo.clone(),
4472 workspace.clone(),
4473 None,
4474 None,
4475 window,
4476 cx,
4477 );
4478 }
4479 })
4480 .hoverable_tooltip({
4481 let repo = active_repository.clone();
4482 move |window, cx| {
4483 GitPanelMessageTooltip::new(
4484 this.clone(),
4485 commit.sha.clone(),
4486 repo.clone(),
4487 window,
4488 cx,
4489 )
4490 .into()
4491 }
4492 }),
4493 )
4494 .when(commit.has_parent, |this| {
4495 let has_unstaged = self.has_unstaged_changes();
4496 this.child(
4497 panel_icon_button("undo", IconName::Undo)
4498 .icon_size(IconSize::XSmall)
4499 .icon_color(Color::Muted)
4500 .tooltip(move |_window, cx| {
4501 Tooltip::with_meta(
4502 "Uncommit",
4503 Some(&git::Uncommit),
4504 if has_unstaged {
4505 "git reset HEAD^ --soft"
4506 } else {
4507 "git reset HEAD^"
4508 },
4509 cx,
4510 )
4511 })
4512 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
4513 )
4514 }),
4515 )
4516 }
4517
4518 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4519 h_flex().h_full().flex_grow().justify_center().child(
4520 v_flex()
4521 .gap_2()
4522 .child(h_flex().w_full().justify_around().child(
4523 if self.active_repository.is_some() {
4524 "No changes to commit"
4525 } else {
4526 "No Git repositories"
4527 },
4528 ))
4529 .children({
4530 let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
4531 (worktree_count > 0 && self.active_repository.is_none()).then(|| {
4532 h_flex().w_full().justify_around().child(
4533 panel_filled_button("Initialize Repository")
4534 .tooltip(Tooltip::for_action_title_in(
4535 "git init",
4536 &git::Init,
4537 &self.focus_handle,
4538 ))
4539 .on_click(move |_, _, cx| {
4540 cx.defer(move |cx| {
4541 cx.dispatch_action(&git::Init);
4542 })
4543 }),
4544 )
4545 })
4546 })
4547 .text_ui_sm(cx)
4548 .mx_auto()
4549 .text_color(Color::Placeholder.color(cx)),
4550 )
4551 }
4552
4553 fn render_buffer_header_controls(
4554 &self,
4555 entity: &Entity<Self>,
4556 file: &Arc<dyn File>,
4557 _: &Window,
4558 cx: &App,
4559 ) -> Option<AnyElement> {
4560 let repo = self.active_repository.as_ref()?.read(cx);
4561 let project_path = (file.worktree_id(cx), file.path().clone()).into();
4562 let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
4563 let ix = self.entry_by_path(&repo_path)?;
4564 let entry = self.entries.get(ix)?;
4565
4566 let is_staging_or_staged = repo
4567 .pending_ops_for_path(&repo_path)
4568 .map(|ops| ops.staging() || ops.staged())
4569 .or_else(|| {
4570 repo.status_for_path(&repo_path)
4571 .and_then(|status| status.status.staging().as_bool())
4572 })
4573 .or_else(|| {
4574 entry
4575 .status_entry()
4576 .and_then(|entry| entry.staging.as_bool())
4577 });
4578
4579 let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into())
4580 .disabled(!self.has_write_access(cx))
4581 .fill()
4582 .elevation(ElevationIndex::Surface)
4583 .on_click({
4584 let entry = entry.clone();
4585 let git_panel = entity.downgrade();
4586 move |_, window, cx| {
4587 git_panel
4588 .update(cx, |this, cx| {
4589 this.toggle_staged_for_entry(&entry, window, cx);
4590 cx.stop_propagation();
4591 })
4592 .ok();
4593 }
4594 });
4595 Some(
4596 h_flex()
4597 .id("start-slot")
4598 .text_lg()
4599 .child(checkbox)
4600 .on_mouse_down(MouseButton::Left, |_, _, cx| {
4601 // prevent the list item active state triggering when toggling checkbox
4602 cx.stop_propagation();
4603 })
4604 .into_any_element(),
4605 )
4606 }
4607
4608 fn render_entries(
4609 &self,
4610 has_write_access: bool,
4611 window: &mut Window,
4612 cx: &mut Context<Self>,
4613 ) -> impl IntoElement {
4614 let (is_tree_view, entry_count) = match &self.view_mode {
4615 GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()),
4616 GitPanelViewMode::Flat => (false, self.entries.len()),
4617 };
4618
4619 v_flex()
4620 .flex_1()
4621 .size_full()
4622 .overflow_hidden()
4623 .relative()
4624 .child(
4625 h_flex()
4626 .flex_1()
4627 .size_full()
4628 .relative()
4629 .overflow_hidden()
4630 .child(
4631 uniform_list(
4632 "entries",
4633 entry_count,
4634 cx.processor(move |this, range: Range<usize>, window, cx| {
4635 let mut items = Vec::with_capacity(range.end - range.start);
4636
4637 for ix in range.into_iter().map(|ix| match &this.view_mode {
4638 GitPanelViewMode::Tree(state) => state.logical_indices[ix],
4639 GitPanelViewMode::Flat => ix,
4640 }) {
4641 match &this.entries.get(ix) {
4642 Some(GitListEntry::Status(entry)) => {
4643 items.push(this.render_status_entry(
4644 ix,
4645 entry,
4646 0,
4647 has_write_access,
4648 window,
4649 cx,
4650 ));
4651 }
4652 Some(GitListEntry::TreeStatus(entry)) => {
4653 items.push(this.render_status_entry(
4654 ix,
4655 &entry.entry,
4656 entry.depth,
4657 has_write_access,
4658 window,
4659 cx,
4660 ));
4661 }
4662 Some(GitListEntry::Directory(entry)) => {
4663 items.push(this.render_directory_entry(
4664 ix,
4665 entry,
4666 has_write_access,
4667 window,
4668 cx,
4669 ));
4670 }
4671 Some(GitListEntry::Header(header)) => {
4672 items.push(this.render_list_header(
4673 ix,
4674 header,
4675 has_write_access,
4676 window,
4677 cx,
4678 ));
4679 }
4680 None => {}
4681 }
4682 }
4683
4684 items
4685 }),
4686 )
4687 .when(is_tree_view, |list| {
4688 let indent_size = px(TREE_INDENT);
4689 list.with_decoration(
4690 ui::indent_guides(indent_size, IndentGuideColors::panel(cx))
4691 .with_compute_indents_fn(
4692 cx.entity(),
4693 |this, range, _window, _cx| {
4694 range
4695 .map(|ix| match this.entries.get(ix) {
4696 Some(GitListEntry::Directory(dir)) => dir.depth,
4697 Some(GitListEntry::TreeStatus(status)) => {
4698 status.depth
4699 }
4700 _ => 0,
4701 })
4702 .collect()
4703 },
4704 )
4705 .with_render_fn(cx.entity(), |_, params, _, _| {
4706 // Magic number to align the tree item is 3 here
4707 // because we're using 12px as the left-side padding
4708 // and 3 makes the alignment work with the bounding box of the icon
4709 let left_offset = px(TREE_INDENT + 3_f32);
4710 let indent_size = params.indent_size;
4711 let item_height = params.item_height;
4712
4713 params
4714 .indent_guides
4715 .into_iter()
4716 .map(|layout| {
4717 let bounds = Bounds::new(
4718 point(
4719 layout.offset.x * indent_size + left_offset,
4720 layout.offset.y * item_height,
4721 ),
4722 size(px(1.), layout.length * item_height),
4723 );
4724 RenderedIndentGuide {
4725 bounds,
4726 layout,
4727 is_active: false,
4728 hitbox: None,
4729 }
4730 })
4731 .collect()
4732 }),
4733 )
4734 })
4735 .size_full()
4736 .flex_grow()
4737 .with_width_from_item(self.max_width_item_index)
4738 .track_scroll(&self.scroll_handle),
4739 )
4740 .on_mouse_down(
4741 MouseButton::Right,
4742 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4743 this.deploy_panel_context_menu(event.position, window, cx)
4744 }),
4745 )
4746 .custom_scrollbars(
4747 Scrollbars::for_settings::<GitPanelSettings>()
4748 .tracked_scroll_handle(&self.scroll_handle)
4749 .with_track_along(
4750 ScrollAxes::Horizontal,
4751 cx.theme().colors().panel_background,
4752 ),
4753 window,
4754 cx,
4755 ),
4756 )
4757 }
4758
4759 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
4760 Label::new(label.into()).color(color)
4761 }
4762
4763 fn list_item_height(&self) -> Rems {
4764 rems(1.75)
4765 }
4766
4767 fn render_list_header(
4768 &self,
4769 ix: usize,
4770 header: &GitHeaderEntry,
4771 _: bool,
4772 _: &Window,
4773 _: &Context<Self>,
4774 ) -> AnyElement {
4775 let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
4776
4777 h_flex()
4778 .id(id)
4779 .h(self.list_item_height())
4780 .w_full()
4781 .items_end()
4782 .px_3()
4783 .pb_1()
4784 .child(
4785 Label::new(header.title())
4786 .color(Color::Muted)
4787 .size(LabelSize::Small)
4788 .line_height_style(LineHeightStyle::UiLabel)
4789 .single_line(),
4790 )
4791 .into_any_element()
4792 }
4793
4794 pub fn load_commit_details(
4795 &self,
4796 sha: String,
4797 cx: &mut Context<Self>,
4798 ) -> Task<anyhow::Result<CommitDetails>> {
4799 let Some(repo) = self.active_repository.clone() else {
4800 return Task::ready(Err(anyhow::anyhow!("no active repo")));
4801 };
4802 repo.update(cx, |repo, cx| {
4803 let show = repo.show(sha);
4804 cx.spawn(async move |_, _| show.await?)
4805 })
4806 }
4807
4808 fn deploy_entry_context_menu(
4809 &mut self,
4810 position: Point<Pixels>,
4811 ix: usize,
4812 window: &mut Window,
4813 cx: &mut Context<Self>,
4814 ) {
4815 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
4816 return;
4817 };
4818 let stage_title = if entry.status.staging().is_fully_staged() {
4819 "Unstage File"
4820 } else {
4821 "Stage File"
4822 };
4823 let restore_title = if entry.status.is_created() {
4824 "Trash File"
4825 } else {
4826 "Discard Changes"
4827 };
4828 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
4829 let is_created = entry.status.is_created();
4830 context_menu
4831 .context(self.focus_handle.clone())
4832 .action(stage_title, ToggleStaged.boxed_clone())
4833 .action(restore_title, git::RestoreFile::default().boxed_clone())
4834 .action_disabled_when(
4835 !is_created,
4836 "Add to .gitignore",
4837 git::AddToGitignore.boxed_clone(),
4838 )
4839 .separator()
4840 .action("Open Diff", menu::Confirm.boxed_clone())
4841 .action("Open File", menu::SecondaryConfirm.boxed_clone())
4842 .separator()
4843 .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
4844 });
4845 self.selected_entry = Some(ix);
4846 self.set_context_menu(context_menu, position, window, cx);
4847 }
4848
4849 fn deploy_panel_context_menu(
4850 &mut self,
4851 position: Point<Pixels>,
4852 window: &mut Window,
4853 cx: &mut Context<Self>,
4854 ) {
4855 let context_menu = git_panel_context_menu(
4856 self.focus_handle.clone(),
4857 GitMenuState {
4858 has_tracked_changes: self.has_tracked_changes(),
4859 has_staged_changes: self.has_staged_changes(),
4860 has_unstaged_changes: self.has_unstaged_changes(),
4861 has_new_changes: self.new_count > 0,
4862 sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
4863 has_stash_items: self.stash_entries.entries.len() > 0,
4864 tree_view: GitPanelSettings::get_global(cx).tree_view,
4865 },
4866 window,
4867 cx,
4868 );
4869 self.set_context_menu(context_menu, position, window, cx);
4870 }
4871
4872 fn set_context_menu(
4873 &mut self,
4874 context_menu: Entity<ContextMenu>,
4875 position: Point<Pixels>,
4876 window: &Window,
4877 cx: &mut Context<Self>,
4878 ) {
4879 let subscription = cx.subscribe_in(
4880 &context_menu,
4881 window,
4882 |this, _, _: &DismissEvent, window, cx| {
4883 if this.context_menu.as_ref().is_some_and(|context_menu| {
4884 context_menu.0.focus_handle(cx).contains_focused(window, cx)
4885 }) {
4886 cx.focus_self(window);
4887 }
4888 this.context_menu.take();
4889 cx.notify();
4890 },
4891 );
4892 self.context_menu = Some((context_menu, position, subscription));
4893 cx.notify();
4894 }
4895
4896 fn render_status_entry(
4897 &self,
4898 ix: usize,
4899 entry: &GitStatusEntry,
4900 depth: usize,
4901 has_write_access: bool,
4902 window: &Window,
4903 cx: &Context<Self>,
4904 ) -> AnyElement {
4905 let tree_view = GitPanelSettings::get_global(cx).tree_view;
4906 let path_style = self.project.read(cx).path_style(cx);
4907 let git_path_style = ProjectSettings::get_global(cx).git.path_style;
4908 let display_name = entry.display_name(path_style);
4909
4910 let selected = self.selected_entry == Some(ix);
4911 let marked = self.marked_entries.contains(&ix);
4912 let status_style = GitPanelSettings::get_global(cx).status_style;
4913 let status = entry.status;
4914
4915 let has_conflict = status.is_conflicted();
4916 let is_modified = status.is_modified();
4917 let is_deleted = status.is_deleted();
4918 let is_created = status.is_created();
4919
4920 let label_color = if status_style == StatusStyle::LabelColor {
4921 if has_conflict {
4922 Color::VersionControlConflict
4923 } else if is_created {
4924 Color::VersionControlAdded
4925 } else if is_modified {
4926 Color::VersionControlModified
4927 } else if is_deleted {
4928 // We don't want a bunch of red labels in the list
4929 Color::Disabled
4930 } else {
4931 Color::VersionControlAdded
4932 }
4933 } else {
4934 Color::Default
4935 };
4936
4937 let path_color = if status.is_deleted() {
4938 Color::Disabled
4939 } else {
4940 Color::Muted
4941 };
4942
4943 let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
4944 let checkbox_wrapper_id: ElementId =
4945 ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
4946 let checkbox_id: ElementId =
4947 ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
4948
4949 let active_repo = self
4950 .project
4951 .read(cx)
4952 .active_repository(cx)
4953 .expect("active repository must be set");
4954 let repo = active_repo.read(cx);
4955 let stage_status = GitPanel::stage_status_for_entry(entry, &repo);
4956 let mut is_staged: ToggleState = match stage_status {
4957 StageStatus::Staged => ToggleState::Selected,
4958 StageStatus::Unstaged => ToggleState::Unselected,
4959 StageStatus::PartiallyStaged => ToggleState::Indeterminate,
4960 };
4961 if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
4962 is_staged = ToggleState::Selected;
4963 }
4964
4965 let handle = cx.weak_entity();
4966
4967 let selected_bg_alpha = 0.08;
4968 let marked_bg_alpha = 0.12;
4969 let state_opacity_step = 0.04;
4970
4971 let info_color = cx.theme().status().info;
4972
4973 let base_bg = match (selected, marked) {
4974 (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
4975 (true, false) => info_color.alpha(selected_bg_alpha),
4976 (false, true) => info_color.alpha(marked_bg_alpha),
4977 _ => cx.theme().colors().ghost_element_background,
4978 };
4979
4980 let (hover_bg, active_bg) = if selected {
4981 (
4982 info_color.alpha(selected_bg_alpha + state_opacity_step),
4983 info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
4984 )
4985 } else {
4986 (
4987 cx.theme().colors().ghost_element_hover,
4988 cx.theme().colors().ghost_element_active,
4989 )
4990 };
4991
4992 let name_row = h_flex()
4993 .min_w_0()
4994 .flex_1()
4995 .gap_1()
4996 .child(git_status_icon(status))
4997 .map(|this| {
4998 if tree_view {
4999 this.pl(px(depth as f32 * TREE_INDENT)).child(
5000 self.entry_label(display_name, label_color)
5001 .when(status.is_deleted(), Label::strikethrough)
5002 .truncate(),
5003 )
5004 } else {
5005 this.child(self.path_formatted(
5006 entry.parent_dir(path_style),
5007 path_color,
5008 display_name,
5009 label_color,
5010 path_style,
5011 git_path_style,
5012 status.is_deleted(),
5013 ))
5014 }
5015 });
5016
5017 h_flex()
5018 .id(id)
5019 .h(self.list_item_height())
5020 .w_full()
5021 .pl_3()
5022 .pr_1()
5023 .gap_1p5()
5024 .border_1()
5025 .border_r_2()
5026 .when(selected && self.focus_handle.is_focused(window), |el| {
5027 el.border_color(cx.theme().colors().panel_focused_border)
5028 })
5029 .bg(base_bg)
5030 .hover(|s| s.bg(hover_bg))
5031 .active(|s| s.bg(active_bg))
5032 .child(name_row)
5033 .child(
5034 div()
5035 .id(checkbox_wrapper_id)
5036 .flex_none()
5037 .occlude()
5038 .cursor_pointer()
5039 .child(
5040 Checkbox::new(checkbox_id, is_staged)
5041 .disabled(!has_write_access)
5042 .fill()
5043 .elevation(ElevationIndex::Surface)
5044 .on_click_ext({
5045 let entry = entry.clone();
5046 let this = cx.weak_entity();
5047 move |_, click, window, cx| {
5048 this.update(cx, |this, cx| {
5049 if !has_write_access {
5050 return;
5051 }
5052 if click.modifiers().shift {
5053 this.stage_bulk(ix, cx);
5054 } else {
5055 let list_entry =
5056 if GitPanelSettings::get_global(cx).tree_view {
5057 GitListEntry::TreeStatus(GitTreeStatusEntry {
5058 entry: entry.clone(),
5059 depth,
5060 })
5061 } else {
5062 GitListEntry::Status(entry.clone())
5063 };
5064 this.toggle_staged_for_entry(&list_entry, window, cx);
5065 }
5066 cx.stop_propagation();
5067 })
5068 .ok();
5069 }
5070 })
5071 .tooltip(move |_window, cx| {
5072 let action = match stage_status {
5073 StageStatus::Staged => "Unstage",
5074 StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5075 };
5076 let tooltip_name = action.to_string();
5077
5078 Tooltip::for_action(tooltip_name, &ToggleStaged, cx)
5079 }),
5080 ),
5081 )
5082 .on_click({
5083 cx.listener(move |this, event: &ClickEvent, window, cx| {
5084 this.selected_entry = Some(ix);
5085 cx.notify();
5086 if event.modifiers().secondary() {
5087 this.open_file(&Default::default(), window, cx)
5088 } else {
5089 this.open_diff(&Default::default(), window, cx);
5090 this.focus_handle.focus(window, cx);
5091 }
5092 })
5093 })
5094 .on_mouse_down(
5095 MouseButton::Right,
5096 move |event: &MouseDownEvent, window, cx| {
5097 // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
5098 if event.button != MouseButton::Right {
5099 return;
5100 }
5101
5102 let Some(this) = handle.upgrade() else {
5103 return;
5104 };
5105 this.update(cx, |this, cx| {
5106 this.deploy_entry_context_menu(event.position, ix, window, cx);
5107 });
5108 cx.stop_propagation();
5109 },
5110 )
5111 .into_any_element()
5112 }
5113
5114 fn render_directory_entry(
5115 &self,
5116 ix: usize,
5117 entry: &GitTreeDirEntry,
5118 has_write_access: bool,
5119 window: &Window,
5120 cx: &Context<Self>,
5121 ) -> AnyElement {
5122 // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that
5123 let selected = self.selected_entry == Some(ix);
5124 let label_color = Color::Muted;
5125
5126 let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into());
5127 let checkbox_id: ElementId =
5128 ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into());
5129 let checkbox_wrapper_id: ElementId =
5130 ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into());
5131
5132 let selected_bg_alpha = 0.08;
5133 let state_opacity_step = 0.04;
5134
5135 let info_color = cx.theme().status().info;
5136 let colors = cx.theme().colors();
5137
5138 let (base_bg, hover_bg, active_bg) = if selected {
5139 (
5140 info_color.alpha(selected_bg_alpha),
5141 info_color.alpha(selected_bg_alpha + state_opacity_step),
5142 info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
5143 )
5144 } else {
5145 (
5146 colors.ghost_element_background,
5147 colors.ghost_element_hover,
5148 colors.ghost_element_active,
5149 )
5150 };
5151
5152 let folder_icon = if entry.expanded {
5153 IconName::FolderOpen
5154 } else {
5155 IconName::Folder
5156 };
5157
5158 let stage_status = if let Some(repo) = &self.active_repository {
5159 self.stage_status_for_directory(entry, repo.read(cx))
5160 } else {
5161 util::debug_panic!(
5162 "Won't have entries to render without an active repository in Git Panel"
5163 );
5164 StageStatus::PartiallyStaged
5165 };
5166
5167 let toggle_state: ToggleState = match stage_status {
5168 StageStatus::Staged => ToggleState::Selected,
5169 StageStatus::Unstaged => ToggleState::Unselected,
5170 StageStatus::PartiallyStaged => ToggleState::Indeterminate,
5171 };
5172
5173 let name_row = h_flex()
5174 .min_w_0()
5175 .gap_1()
5176 .pl(px(entry.depth as f32 * TREE_INDENT))
5177 .child(
5178 Icon::new(folder_icon)
5179 .size(IconSize::Small)
5180 .color(Color::Muted),
5181 )
5182 .child(self.entry_label(entry.name.clone(), label_color).truncate());
5183
5184 h_flex()
5185 .id(id)
5186 .h(self.list_item_height())
5187 .min_w_0()
5188 .w_full()
5189 .pl_3()
5190 .pr_1()
5191 .gap_1p5()
5192 .justify_between()
5193 .border_1()
5194 .border_r_2()
5195 .when(selected && self.focus_handle.is_focused(window), |el| {
5196 el.border_color(cx.theme().colors().panel_focused_border)
5197 })
5198 .bg(base_bg)
5199 .hover(|s| s.bg(hover_bg))
5200 .active(|s| s.bg(active_bg))
5201 .child(name_row)
5202 .child(
5203 div()
5204 .id(checkbox_wrapper_id)
5205 .flex_none()
5206 .occlude()
5207 .cursor_pointer()
5208 .child(
5209 Checkbox::new(checkbox_id, toggle_state)
5210 .disabled(!has_write_access)
5211 .fill()
5212 .elevation(ElevationIndex::Surface)
5213 .on_click({
5214 let entry = entry.clone();
5215 let this = cx.weak_entity();
5216 move |_, window, cx| {
5217 this.update(cx, |this, cx| {
5218 if !has_write_access {
5219 return;
5220 }
5221 this.toggle_staged_for_entry(
5222 &GitListEntry::Directory(entry.clone()),
5223 window,
5224 cx,
5225 );
5226 cx.stop_propagation();
5227 })
5228 .ok();
5229 }
5230 })
5231 .tooltip(move |_window, cx| {
5232 let action = match stage_status {
5233 StageStatus::Staged => "Unstage",
5234 StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5235 };
5236 Tooltip::simple(format!("{action} folder"), cx)
5237 }),
5238 ),
5239 )
5240 .on_click({
5241 let key = entry.key.clone();
5242 cx.listener(move |this, _event: &ClickEvent, window, cx| {
5243 this.selected_entry = Some(ix);
5244 this.toggle_directory(&key, window, cx);
5245 })
5246 })
5247 .into_any_element()
5248 }
5249
5250 fn path_formatted(
5251 &self,
5252 directory: Option<String>,
5253 path_color: Color,
5254 file_name: String,
5255 label_color: Color,
5256 path_style: PathStyle,
5257 git_path_style: GitPathStyle,
5258 strikethrough: bool,
5259 ) -> Div {
5260 let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
5261 let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
5262
5263 let file_name = format!("{} ", file_name);
5264
5265 h_flex()
5266 .min_w_0()
5267 .overflow_hidden()
5268 .when(file_path_first, |this| this.flex_row_reverse())
5269 .child(
5270 div().flex_none().child(
5271 self.entry_label(file_name, label_color)
5272 .when(strikethrough, Label::strikethrough),
5273 ),
5274 )
5275 .when_some(directory, |this, dir| {
5276 let path_name = if file_name_first {
5277 dir
5278 } else {
5279 format!("{dir}{}", path_style.primary_separator())
5280 };
5281
5282 this.child(
5283 self.entry_label(path_name, path_color)
5284 .truncate()
5285 .when(strikethrough, Label::strikethrough),
5286 )
5287 })
5288 }
5289
5290 fn has_write_access(&self, cx: &App) -> bool {
5291 !self.project.read(cx).is_read_only(cx)
5292 }
5293
5294 pub fn amend_pending(&self) -> bool {
5295 self.amend_pending
5296 }
5297
5298 /// Sets the pending amend state, ensuring that the original commit message
5299 /// is either saved, when `value` is `true` and there's no pending amend, or
5300 /// restored, when `value` is `false` and there's a pending amend.
5301 pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
5302 if value && !self.amend_pending {
5303 let current_message = self.commit_message_buffer(cx).read(cx).text();
5304 self.original_commit_message = if current_message.trim().is_empty() {
5305 None
5306 } else {
5307 Some(current_message)
5308 };
5309 } else if !value && self.amend_pending {
5310 let message = self.original_commit_message.take().unwrap_or_default();
5311 self.commit_message_buffer(cx).update(cx, |buffer, cx| {
5312 let start = buffer.anchor_before(0);
5313 let end = buffer.anchor_after(buffer.len());
5314 buffer.edit([(start..end, message)], None, cx);
5315 });
5316 }
5317
5318 self.amend_pending = value;
5319 self.serialize(cx);
5320 cx.notify();
5321 }
5322
5323 pub fn signoff_enabled(&self) -> bool {
5324 self.signoff_enabled
5325 }
5326
5327 pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
5328 self.signoff_enabled = value;
5329 self.serialize(cx);
5330 cx.notify();
5331 }
5332
5333 pub fn toggle_signoff_enabled(
5334 &mut self,
5335 _: &Signoff,
5336 _window: &mut Window,
5337 cx: &mut Context<Self>,
5338 ) {
5339 self.set_signoff_enabled(!self.signoff_enabled, cx);
5340 }
5341
5342 pub async fn load(
5343 workspace: WeakEntity<Workspace>,
5344 mut cx: AsyncWindowContext,
5345 ) -> anyhow::Result<Entity<Self>> {
5346 let serialized_panel = match workspace
5347 .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
5348 .ok()
5349 .flatten()
5350 {
5351 Some(serialization_key) => cx
5352 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
5353 .await
5354 .context("loading git panel")
5355 .log_err()
5356 .flatten()
5357 .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
5358 .transpose()
5359 .log_err()
5360 .flatten(),
5361 None => None,
5362 };
5363
5364 workspace.update_in(&mut cx, |workspace, window, cx| {
5365 let panel = GitPanel::new(workspace, window, cx);
5366
5367 if let Some(serialized_panel) = serialized_panel {
5368 panel.update(cx, |panel, cx| {
5369 panel.width = serialized_panel.width;
5370 panel.amend_pending = serialized_panel.amend_pending;
5371 panel.signoff_enabled = serialized_panel.signoff_enabled;
5372 cx.notify();
5373 })
5374 }
5375
5376 panel
5377 })
5378 }
5379
5380 fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
5381 let Some(op) = self.bulk_staging.as_ref() else {
5382 return;
5383 };
5384 let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else {
5385 return;
5386 };
5387 if let Some(entry) = self.entries.get(index)
5388 && let Some(entry) = entry.status_entry()
5389 {
5390 self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
5391 }
5392 if index < anchor_index {
5393 std::mem::swap(&mut index, &mut anchor_index);
5394 }
5395 let entries = self
5396 .entries
5397 .get(anchor_index..=index)
5398 .unwrap_or_default()
5399 .iter()
5400 .filter_map(|entry| entry.status_entry().cloned())
5401 .collect::<Vec<_>>();
5402 self.change_file_stage(true, entries, cx);
5403 }
5404
5405 fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
5406 let Some(repo) = self.active_repository.as_ref() else {
5407 return;
5408 };
5409 self.bulk_staging = Some(BulkStaging {
5410 repo_id: repo.read(cx).id,
5411 anchor: path,
5412 });
5413 }
5414
5415 pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
5416 self.set_amend_pending(!self.amend_pending, cx);
5417 if self.amend_pending {
5418 self.load_last_commit_message(cx);
5419 }
5420 }
5421}
5422
5423impl Render for GitPanel {
5424 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5425 let project = self.project.read(cx);
5426 let has_entries = !self.entries.is_empty();
5427 let room = self
5428 .workspace
5429 .upgrade()
5430 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
5431
5432 let has_write_access = self.has_write_access(cx);
5433
5434 let has_co_authors = room.is_some_and(|room| {
5435 self.load_local_committer(cx);
5436 let room = room.read(cx);
5437 room.remote_participants()
5438 .values()
5439 .any(|remote_participant| remote_participant.can_write())
5440 });
5441
5442 v_flex()
5443 .id("git_panel")
5444 .key_context(self.dispatch_context(window, cx))
5445 .track_focus(&self.focus_handle)
5446 .when(has_write_access && !project.is_read_only(cx), |this| {
5447 this.on_action(cx.listener(Self::toggle_staged_for_selected))
5448 .on_action(cx.listener(Self::stage_range))
5449 .on_action(cx.listener(GitPanel::on_commit))
5450 .on_action(cx.listener(GitPanel::on_amend))
5451 .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
5452 .on_action(cx.listener(Self::stage_all))
5453 .on_action(cx.listener(Self::unstage_all))
5454 .on_action(cx.listener(Self::stage_selected))
5455 .on_action(cx.listener(Self::unstage_selected))
5456 .on_action(cx.listener(Self::restore_tracked_files))
5457 .on_action(cx.listener(Self::revert_selected))
5458 .on_action(cx.listener(Self::add_to_gitignore))
5459 .on_action(cx.listener(Self::clean_all))
5460 .on_action(cx.listener(Self::generate_commit_message_action))
5461 .on_action(cx.listener(Self::stash_all))
5462 .on_action(cx.listener(Self::stash_pop))
5463 })
5464 .on_action(cx.listener(Self::collapse_selected_entry))
5465 .on_action(cx.listener(Self::expand_selected_entry))
5466 .on_action(cx.listener(Self::select_first))
5467 .on_action(cx.listener(Self::select_next))
5468 .on_action(cx.listener(Self::select_previous))
5469 .on_action(cx.listener(Self::select_last))
5470 .on_action(cx.listener(Self::first_entry))
5471 .on_action(cx.listener(Self::next_entry))
5472 .on_action(cx.listener(Self::previous_entry))
5473 .on_action(cx.listener(Self::last_entry))
5474 .on_action(cx.listener(Self::close_panel))
5475 .on_action(cx.listener(Self::open_diff))
5476 .on_action(cx.listener(Self::open_file))
5477 .on_action(cx.listener(Self::file_history))
5478 .on_action(cx.listener(Self::focus_changes_list))
5479 .on_action(cx.listener(Self::focus_editor))
5480 .on_action(cx.listener(Self::expand_commit_editor))
5481 .when(has_write_access && has_co_authors, |git_panel| {
5482 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
5483 })
5484 .on_action(cx.listener(Self::toggle_sort_by_path))
5485 .on_action(cx.listener(Self::toggle_tree_view))
5486 .size_full()
5487 .overflow_hidden()
5488 .bg(cx.theme().colors().panel_background)
5489 .child(
5490 v_flex()
5491 .size_full()
5492 .children(self.render_panel_header(window, cx))
5493 .map(|this| {
5494 if has_entries {
5495 this.child(self.render_entries(has_write_access, window, cx))
5496 } else {
5497 this.child(self.render_empty_state(cx).into_any_element())
5498 }
5499 })
5500 .children(self.render_footer(window, cx))
5501 .when(self.amend_pending, |this| {
5502 this.child(self.render_pending_amend(cx))
5503 })
5504 .when(!self.amend_pending, |this| {
5505 this.children(self.render_previous_commit(cx))
5506 })
5507 .into_any_element(),
5508 )
5509 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
5510 deferred(
5511 anchored()
5512 .position(*position)
5513 .anchor(Corner::TopLeft)
5514 .child(menu.clone()),
5515 )
5516 .with_priority(1)
5517 }))
5518 }
5519}
5520
5521impl Focusable for GitPanel {
5522 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
5523 if self.entries.is_empty() {
5524 self.commit_editor.focus_handle(cx)
5525 } else {
5526 self.focus_handle.clone()
5527 }
5528 }
5529}
5530
5531impl EventEmitter<Event> for GitPanel {}
5532
5533impl EventEmitter<PanelEvent> for GitPanel {}
5534
5535pub(crate) struct GitPanelAddon {
5536 pub(crate) workspace: WeakEntity<Workspace>,
5537}
5538
5539impl editor::Addon for GitPanelAddon {
5540 fn to_any(&self) -> &dyn std::any::Any {
5541 self
5542 }
5543
5544 fn render_buffer_header_controls(
5545 &self,
5546 excerpt_info: &ExcerptInfo,
5547 window: &Window,
5548 cx: &App,
5549 ) -> Option<AnyElement> {
5550 let file = excerpt_info.buffer.file()?;
5551 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
5552
5553 git_panel
5554 .read(cx)
5555 .render_buffer_header_controls(&git_panel, file, window, cx)
5556 }
5557}
5558
5559impl Panel for GitPanel {
5560 fn persistent_name() -> &'static str {
5561 "GitPanel"
5562 }
5563
5564 fn panel_key() -> &'static str {
5565 GIT_PANEL_KEY
5566 }
5567
5568 fn position(&self, _: &Window, cx: &App) -> DockPosition {
5569 GitPanelSettings::get_global(cx).dock
5570 }
5571
5572 fn position_is_valid(&self, position: DockPosition) -> bool {
5573 matches!(position, DockPosition::Left | DockPosition::Right)
5574 }
5575
5576 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
5577 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
5578 settings.git_panel.get_or_insert_default().dock = Some(position.into())
5579 });
5580 }
5581
5582 fn size(&self, _: &Window, cx: &App) -> Pixels {
5583 self.width
5584 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
5585 }
5586
5587 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
5588 self.width = size;
5589 self.serialize(cx);
5590 cx.notify();
5591 }
5592
5593 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
5594 Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
5595 }
5596
5597 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
5598 Some("Git Panel")
5599 }
5600
5601 fn toggle_action(&self) -> Box<dyn Action> {
5602 Box::new(ToggleFocus)
5603 }
5604
5605 fn activation_priority(&self) -> u32 {
5606 2
5607 }
5608}
5609
5610impl PanelHeader for GitPanel {}
5611
5612struct GitPanelMessageTooltip {
5613 commit_tooltip: Option<Entity<CommitTooltip>>,
5614}
5615
5616impl GitPanelMessageTooltip {
5617 fn new(
5618 git_panel: Entity<GitPanel>,
5619 sha: SharedString,
5620 repository: Entity<Repository>,
5621 window: &mut Window,
5622 cx: &mut App,
5623 ) -> Entity<Self> {
5624 let remote_url = repository.read(cx).default_remote_url();
5625 cx.new(|cx| {
5626 cx.spawn_in(window, async move |this, cx| {
5627 let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
5628 (
5629 git_panel.load_commit_details(sha.to_string(), cx),
5630 git_panel.workspace.clone(),
5631 )
5632 })?;
5633 let details = details.await?;
5634 let provider_registry = cx
5635 .update(|_, app| GitHostingProviderRegistry::default_global(app))
5636 .ok();
5637
5638 let commit_details = crate::commit_tooltip::CommitDetails {
5639 sha: details.sha.clone(),
5640 author_name: details.author_name.clone(),
5641 author_email: details.author_email.clone(),
5642 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
5643 message: Some(ParsedCommitMessage::parse(
5644 details.sha.to_string(),
5645 details.message.to_string(),
5646 remote_url.as_deref(),
5647 provider_registry,
5648 )),
5649 };
5650
5651 this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
5652 this.commit_tooltip = Some(cx.new(move |cx| {
5653 CommitTooltip::new(commit_details, repository, workspace, cx)
5654 }));
5655 cx.notify();
5656 })
5657 })
5658 .detach();
5659
5660 Self {
5661 commit_tooltip: None,
5662 }
5663 })
5664 }
5665}
5666
5667impl Render for GitPanelMessageTooltip {
5668 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
5669 if let Some(commit_tooltip) = &self.commit_tooltip {
5670 commit_tooltip.clone().into_any_element()
5671 } else {
5672 gpui::Empty.into_any_element()
5673 }
5674 }
5675}
5676
5677#[derive(IntoElement, RegisterComponent)]
5678pub struct PanelRepoFooter {
5679 active_repository: SharedString,
5680 branch: Option<Branch>,
5681 head_commit: Option<CommitDetails>,
5682
5683 // Getting a GitPanel in previews will be difficult.
5684 //
5685 // For now just take an option here, and we won't bind handlers to buttons in previews.
5686 git_panel: Option<Entity<GitPanel>>,
5687}
5688
5689impl PanelRepoFooter {
5690 pub fn new(
5691 active_repository: SharedString,
5692 branch: Option<Branch>,
5693 head_commit: Option<CommitDetails>,
5694 git_panel: Option<Entity<GitPanel>>,
5695 ) -> Self {
5696 Self {
5697 active_repository,
5698 branch,
5699 head_commit,
5700 git_panel,
5701 }
5702 }
5703
5704 pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
5705 Self {
5706 active_repository,
5707 branch,
5708 head_commit: None,
5709 git_panel: None,
5710 }
5711 }
5712}
5713
5714impl RenderOnce for PanelRepoFooter {
5715 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
5716 let project = self
5717 .git_panel
5718 .as_ref()
5719 .map(|panel| panel.read(cx).project.clone());
5720
5721 let (workspace, repo) = self
5722 .git_panel
5723 .as_ref()
5724 .map(|panel| {
5725 let panel = panel.read(cx);
5726 (panel.workspace.clone(), panel.active_repository.clone())
5727 })
5728 .unzip();
5729
5730 let single_repo = project
5731 .as_ref()
5732 .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
5733 .unwrap_or(true);
5734
5735 const MAX_BRANCH_LEN: usize = 16;
5736 const MAX_REPO_LEN: usize = 16;
5737 const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
5738 const MAX_SHORT_SHA_LEN: usize = 8;
5739 let branch_name = self
5740 .branch
5741 .as_ref()
5742 .map(|branch| branch.name().to_owned())
5743 .or_else(|| {
5744 self.head_commit.as_ref().map(|commit| {
5745 commit
5746 .sha
5747 .chars()
5748 .take(MAX_SHORT_SHA_LEN)
5749 .collect::<String>()
5750 })
5751 })
5752 .unwrap_or_else(|| " (no branch)".to_owned());
5753 let show_separator = self.branch.is_some() || self.head_commit.is_some();
5754
5755 let active_repo_name = self.active_repository.clone();
5756
5757 let branch_actual_len = branch_name.len();
5758 let repo_actual_len = active_repo_name.len();
5759
5760 // ideally, show the whole branch and repo names but
5761 // when we can't, use a budget to allocate space between the two
5762 let (repo_display_len, branch_display_len) =
5763 if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
5764 (repo_actual_len, branch_actual_len)
5765 } else if branch_actual_len <= MAX_BRANCH_LEN {
5766 let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
5767 (repo_space, branch_actual_len)
5768 } else if repo_actual_len <= MAX_REPO_LEN {
5769 let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
5770 (repo_actual_len, branch_space)
5771 } else {
5772 (MAX_REPO_LEN, MAX_BRANCH_LEN)
5773 };
5774
5775 let truncated_repo_name = if repo_actual_len <= repo_display_len {
5776 active_repo_name.to_string()
5777 } else {
5778 util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
5779 };
5780
5781 let truncated_branch_name = if branch_actual_len <= branch_display_len {
5782 branch_name
5783 } else {
5784 util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
5785 };
5786
5787 let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
5788 .size(ButtonSize::None)
5789 .label_size(LabelSize::Small)
5790 .color(Color::Muted);
5791
5792 let repo_selector = PopoverMenu::new("repository-switcher")
5793 .menu({
5794 let project = project;
5795 move |window, cx| {
5796 let project = project.clone()?;
5797 Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
5798 }
5799 })
5800 .trigger_with_tooltip(
5801 repo_selector_trigger.disabled(single_repo).truncate(true),
5802 Tooltip::text("Switch Active Repository"),
5803 )
5804 .anchor(Corner::BottomLeft)
5805 .into_any_element();
5806
5807 let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
5808 .size(ButtonSize::None)
5809 .label_size(LabelSize::Small)
5810 .truncate(true)
5811 .on_click(|_, window, cx| {
5812 window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
5813 });
5814
5815 let branch_selector = PopoverMenu::new("popover-button")
5816 .menu(move |window, cx| {
5817 let workspace = workspace.clone()?;
5818 let repo = repo.clone().flatten();
5819 Some(branch_picker::popover(workspace, repo, window, cx))
5820 })
5821 .trigger_with_tooltip(
5822 branch_selector_button,
5823 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
5824 )
5825 .anchor(Corner::BottomLeft)
5826 .offset(gpui::Point {
5827 x: px(0.0),
5828 y: px(-2.0),
5829 });
5830
5831 h_flex()
5832 .h(px(36.))
5833 .w_full()
5834 .px_2()
5835 .justify_between()
5836 .gap_1()
5837 .child(
5838 h_flex()
5839 .flex_1()
5840 .overflow_hidden()
5841 .gap_px()
5842 .child(
5843 Icon::new(IconName::GitBranchAlt)
5844 .size(IconSize::Small)
5845 .color(if single_repo {
5846 Color::Disabled
5847 } else {
5848 Color::Muted
5849 }),
5850 )
5851 .child(repo_selector)
5852 .when(show_separator, |this| {
5853 this.child(
5854 div()
5855 .text_sm()
5856 .text_color(cx.theme().colors().icon_muted.opacity(0.5))
5857 .child("/"),
5858 )
5859 })
5860 .child(branch_selector),
5861 )
5862 .children(if let Some(git_panel) = self.git_panel {
5863 git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
5864 } else {
5865 None
5866 })
5867 }
5868}
5869
5870impl Component for PanelRepoFooter {
5871 fn scope() -> ComponentScope {
5872 ComponentScope::VersionControl
5873 }
5874
5875 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
5876 let unknown_upstream = None;
5877 let no_remote_upstream = Some(UpstreamTracking::Gone);
5878 let ahead_of_upstream = Some(
5879 UpstreamTrackingStatus {
5880 ahead: 2,
5881 behind: 0,
5882 }
5883 .into(),
5884 );
5885 let behind_upstream = Some(
5886 UpstreamTrackingStatus {
5887 ahead: 0,
5888 behind: 2,
5889 }
5890 .into(),
5891 );
5892 let ahead_and_behind_upstream = Some(
5893 UpstreamTrackingStatus {
5894 ahead: 3,
5895 behind: 1,
5896 }
5897 .into(),
5898 );
5899
5900 let not_ahead_or_behind_upstream = Some(
5901 UpstreamTrackingStatus {
5902 ahead: 0,
5903 behind: 0,
5904 }
5905 .into(),
5906 );
5907
5908 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
5909 Branch {
5910 is_head: true,
5911 ref_name: "some-branch".into(),
5912 upstream: upstream.map(|tracking| Upstream {
5913 ref_name: "origin/some-branch".into(),
5914 tracking,
5915 }),
5916 most_recent_commit: Some(CommitSummary {
5917 sha: "abc123".into(),
5918 subject: "Modify stuff".into(),
5919 commit_timestamp: 1710932954,
5920 author_name: "John Doe".into(),
5921 has_parent: true,
5922 }),
5923 }
5924 }
5925
5926 fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
5927 Branch {
5928 is_head: true,
5929 ref_name: branch_name.to_string().into(),
5930 upstream: upstream.map(|tracking| Upstream {
5931 ref_name: format!("zed/{}", branch_name).into(),
5932 tracking,
5933 }),
5934 most_recent_commit: Some(CommitSummary {
5935 sha: "abc123".into(),
5936 subject: "Modify stuff".into(),
5937 commit_timestamp: 1710932954,
5938 author_name: "John Doe".into(),
5939 has_parent: true,
5940 }),
5941 }
5942 }
5943
5944 fn active_repository(id: usize) -> SharedString {
5945 format!("repo-{}", id).into()
5946 }
5947
5948 let example_width = px(340.);
5949 Some(
5950 v_flex()
5951 .gap_6()
5952 .w_full()
5953 .flex_none()
5954 .children(vec![
5955 example_group_with_title(
5956 "Action Button States",
5957 vec![
5958 single_example(
5959 "No Branch",
5960 div()
5961 .w(example_width)
5962 .overflow_hidden()
5963 .child(PanelRepoFooter::new_preview(active_repository(1), None))
5964 .into_any_element(),
5965 ),
5966 single_example(
5967 "Remote status unknown",
5968 div()
5969 .w(example_width)
5970 .overflow_hidden()
5971 .child(PanelRepoFooter::new_preview(
5972 active_repository(2),
5973 Some(branch(unknown_upstream)),
5974 ))
5975 .into_any_element(),
5976 ),
5977 single_example(
5978 "No Remote Upstream",
5979 div()
5980 .w(example_width)
5981 .overflow_hidden()
5982 .child(PanelRepoFooter::new_preview(
5983 active_repository(3),
5984 Some(branch(no_remote_upstream)),
5985 ))
5986 .into_any_element(),
5987 ),
5988 single_example(
5989 "Not Ahead or Behind",
5990 div()
5991 .w(example_width)
5992 .overflow_hidden()
5993 .child(PanelRepoFooter::new_preview(
5994 active_repository(4),
5995 Some(branch(not_ahead_or_behind_upstream)),
5996 ))
5997 .into_any_element(),
5998 ),
5999 single_example(
6000 "Behind remote",
6001 div()
6002 .w(example_width)
6003 .overflow_hidden()
6004 .child(PanelRepoFooter::new_preview(
6005 active_repository(5),
6006 Some(branch(behind_upstream)),
6007 ))
6008 .into_any_element(),
6009 ),
6010 single_example(
6011 "Ahead of remote",
6012 div()
6013 .w(example_width)
6014 .overflow_hidden()
6015 .child(PanelRepoFooter::new_preview(
6016 active_repository(6),
6017 Some(branch(ahead_of_upstream)),
6018 ))
6019 .into_any_element(),
6020 ),
6021 single_example(
6022 "Ahead and behind remote",
6023 div()
6024 .w(example_width)
6025 .overflow_hidden()
6026 .child(PanelRepoFooter::new_preview(
6027 active_repository(7),
6028 Some(branch(ahead_and_behind_upstream)),
6029 ))
6030 .into_any_element(),
6031 ),
6032 ],
6033 )
6034 .grow()
6035 .vertical(),
6036 ])
6037 .children(vec![
6038 example_group_with_title(
6039 "Labels",
6040 vec![
6041 single_example(
6042 "Short Branch & Repo",
6043 div()
6044 .w(example_width)
6045 .overflow_hidden()
6046 .child(PanelRepoFooter::new_preview(
6047 SharedString::from("zed"),
6048 Some(custom("main", behind_upstream)),
6049 ))
6050 .into_any_element(),
6051 ),
6052 single_example(
6053 "Long Branch",
6054 div()
6055 .w(example_width)
6056 .overflow_hidden()
6057 .child(PanelRepoFooter::new_preview(
6058 SharedString::from("zed"),
6059 Some(custom(
6060 "redesign-and-update-git-ui-list-entry-style",
6061 behind_upstream,
6062 )),
6063 ))
6064 .into_any_element(),
6065 ),
6066 single_example(
6067 "Long Repo",
6068 div()
6069 .w(example_width)
6070 .overflow_hidden()
6071 .child(PanelRepoFooter::new_preview(
6072 SharedString::from("zed-industries-community-examples"),
6073 Some(custom("gpui", ahead_of_upstream)),
6074 ))
6075 .into_any_element(),
6076 ),
6077 single_example(
6078 "Long Repo & Branch",
6079 div()
6080 .w(example_width)
6081 .overflow_hidden()
6082 .child(PanelRepoFooter::new_preview(
6083 SharedString::from("zed-industries-community-examples"),
6084 Some(custom(
6085 "redesign-and-update-git-ui-list-entry-style",
6086 behind_upstream,
6087 )),
6088 ))
6089 .into_any_element(),
6090 ),
6091 single_example(
6092 "Uppercase Repo",
6093 div()
6094 .w(example_width)
6095 .overflow_hidden()
6096 .child(PanelRepoFooter::new_preview(
6097 SharedString::from("LICENSES"),
6098 Some(custom("main", ahead_of_upstream)),
6099 ))
6100 .into_any_element(),
6101 ),
6102 single_example(
6103 "Uppercase Branch",
6104 div()
6105 .w(example_width)
6106 .overflow_hidden()
6107 .child(PanelRepoFooter::new_preview(
6108 SharedString::from("zed"),
6109 Some(custom("update-README", behind_upstream)),
6110 ))
6111 .into_any_element(),
6112 ),
6113 ],
6114 )
6115 .grow()
6116 .vertical(),
6117 ])
6118 .into_any_element(),
6119 )
6120 }
6121}
6122
6123fn open_output(
6124 operation: impl Into<SharedString>,
6125 workspace: &mut Workspace,
6126 output: &str,
6127 window: &mut Window,
6128 cx: &mut Context<Workspace>,
6129) {
6130 let operation = operation.into();
6131 let buffer = cx.new(|cx| Buffer::local(output, cx));
6132 buffer.update(cx, |buffer, cx| {
6133 buffer.set_capability(language::Capability::ReadOnly, cx);
6134 });
6135 let editor = cx.new(|cx| {
6136 let mut editor = Editor::for_buffer(buffer, None, window, cx);
6137 editor.buffer().update(cx, |buffer, cx| {
6138 buffer.set_title(format!("Output from git {operation}"), cx);
6139 });
6140 editor.set_read_only(true);
6141 editor
6142 });
6143
6144 workspace.add_item_to_center(Box::new(editor), window, cx);
6145}
6146
6147pub(crate) fn show_error_toast(
6148 workspace: Entity<Workspace>,
6149 action: impl Into<SharedString>,
6150 e: anyhow::Error,
6151 cx: &mut App,
6152) {
6153 let action = action.into();
6154 let message = e.to_string().trim().to_string();
6155 if message
6156 .matches(git::repository::REMOTE_CANCELLED_BY_USER)
6157 .next()
6158 .is_some()
6159 { // Hide the cancelled by user message
6160 } else {
6161 workspace.update(cx, |workspace, cx| {
6162 let workspace_weak = cx.weak_entity();
6163 let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
6164 this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
6165 .action("View Log", move |window, cx| {
6166 let message = message.clone();
6167 let action = action.clone();
6168 workspace_weak
6169 .update(cx, move |workspace, cx| {
6170 open_output(action, workspace, &message, window, cx)
6171 })
6172 .ok();
6173 })
6174 });
6175 workspace.toggle_status_toast(toast, cx)
6176 });
6177 }
6178}
6179
6180#[cfg(test)]
6181mod tests {
6182 use git::{
6183 repository::repo_path,
6184 status::{StatusCode, UnmergedStatus, UnmergedStatusCode},
6185 };
6186 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
6187 use indoc::indoc;
6188 use project::FakeFs;
6189 use serde_json::json;
6190 use settings::SettingsStore;
6191 use theme::LoadThemes;
6192 use util::path;
6193 use util::rel_path::rel_path;
6194
6195 use super::*;
6196
6197 fn init_test(cx: &mut gpui::TestAppContext) {
6198 zlog::init_test();
6199
6200 cx.update(|cx| {
6201 let settings_store = SettingsStore::test(cx);
6202 cx.set_global(settings_store);
6203 theme::init(LoadThemes::JustBase, cx);
6204 editor::init(cx);
6205 crate::init(cx);
6206 });
6207 }
6208
6209 #[gpui::test]
6210 async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
6211 init_test(cx);
6212 let fs = FakeFs::new(cx.background_executor.clone());
6213 fs.insert_tree(
6214 "/root",
6215 json!({
6216 "zed": {
6217 ".git": {},
6218 "crates": {
6219 "gpui": {
6220 "gpui.rs": "fn main() {}"
6221 },
6222 "util": {
6223 "util.rs": "fn do_it() {}"
6224 }
6225 }
6226 },
6227 }),
6228 )
6229 .await;
6230
6231 fs.set_status_for_repo(
6232 Path::new(path!("/root/zed/.git")),
6233 &[
6234 ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()),
6235 ("crates/util/util.rs", StatusCode::Modified.worktree()),
6236 ],
6237 );
6238
6239 let project =
6240 Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
6241 let workspace =
6242 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6243 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6244
6245 cx.read(|cx| {
6246 project
6247 .read(cx)
6248 .worktrees(cx)
6249 .next()
6250 .unwrap()
6251 .read(cx)
6252 .as_local()
6253 .unwrap()
6254 .scan_complete()
6255 })
6256 .await;
6257
6258 cx.executor().run_until_parked();
6259
6260 let panel = workspace.update(cx, GitPanel::new).unwrap();
6261
6262 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6263 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6264 });
6265 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6266 handle.await;
6267
6268 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6269 pretty_assertions::assert_eq!(
6270 entries,
6271 [
6272 GitListEntry::Header(GitHeaderEntry {
6273 header: Section::Tracked
6274 }),
6275 GitListEntry::Status(GitStatusEntry {
6276 repo_path: repo_path("crates/gpui/gpui.rs"),
6277 status: StatusCode::Modified.worktree(),
6278 staging: StageStatus::Unstaged,
6279 }),
6280 GitListEntry::Status(GitStatusEntry {
6281 repo_path: repo_path("crates/util/util.rs"),
6282 status: StatusCode::Modified.worktree(),
6283 staging: StageStatus::Unstaged,
6284 },),
6285 ],
6286 );
6287
6288 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6289 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6290 });
6291 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6292 handle.await;
6293 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6294 pretty_assertions::assert_eq!(
6295 entries,
6296 [
6297 GitListEntry::Header(GitHeaderEntry {
6298 header: Section::Tracked
6299 }),
6300 GitListEntry::Status(GitStatusEntry {
6301 repo_path: repo_path("crates/gpui/gpui.rs"),
6302 status: StatusCode::Modified.worktree(),
6303 staging: StageStatus::Unstaged,
6304 }),
6305 GitListEntry::Status(GitStatusEntry {
6306 repo_path: repo_path("crates/util/util.rs"),
6307 status: StatusCode::Modified.worktree(),
6308 staging: StageStatus::Unstaged,
6309 },),
6310 ],
6311 );
6312 }
6313
6314 #[gpui::test]
6315 async fn test_bulk_staging(cx: &mut TestAppContext) {
6316 use GitListEntry::*;
6317
6318 init_test(cx);
6319 let fs = FakeFs::new(cx.background_executor.clone());
6320 fs.insert_tree(
6321 "/root",
6322 json!({
6323 "project": {
6324 ".git": {},
6325 "src": {
6326 "main.rs": "fn main() {}",
6327 "lib.rs": "pub fn hello() {}",
6328 "utils.rs": "pub fn util() {}"
6329 },
6330 "tests": {
6331 "test.rs": "fn test() {}"
6332 },
6333 "new_file.txt": "new content",
6334 "another_new.rs": "// new file",
6335 "conflict.txt": "conflicted content"
6336 }
6337 }),
6338 )
6339 .await;
6340
6341 fs.set_status_for_repo(
6342 Path::new(path!("/root/project/.git")),
6343 &[
6344 ("src/main.rs", StatusCode::Modified.worktree()),
6345 ("src/lib.rs", StatusCode::Modified.worktree()),
6346 ("tests/test.rs", StatusCode::Modified.worktree()),
6347 ("new_file.txt", FileStatus::Untracked),
6348 ("another_new.rs", FileStatus::Untracked),
6349 ("src/utils.rs", FileStatus::Untracked),
6350 (
6351 "conflict.txt",
6352 UnmergedStatus {
6353 first_head: UnmergedStatusCode::Updated,
6354 second_head: UnmergedStatusCode::Updated,
6355 }
6356 .into(),
6357 ),
6358 ],
6359 );
6360
6361 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6362 let workspace =
6363 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6364 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6365
6366 cx.read(|cx| {
6367 project
6368 .read(cx)
6369 .worktrees(cx)
6370 .next()
6371 .unwrap()
6372 .read(cx)
6373 .as_local()
6374 .unwrap()
6375 .scan_complete()
6376 })
6377 .await;
6378
6379 cx.executor().run_until_parked();
6380
6381 let panel = workspace.update(cx, GitPanel::new).unwrap();
6382
6383 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6384 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6385 });
6386 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6387 handle.await;
6388
6389 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6390 #[rustfmt::skip]
6391 pretty_assertions::assert_matches!(
6392 entries.as_slice(),
6393 &[
6394 Header(GitHeaderEntry { header: Section::Conflict }),
6395 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6396 Header(GitHeaderEntry { header: Section::Tracked }),
6397 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6398 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6399 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6400 Header(GitHeaderEntry { header: Section::New }),
6401 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6402 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6403 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6404 ],
6405 );
6406
6407 let second_status_entry = entries[3].clone();
6408 panel.update_in(cx, |panel, window, cx| {
6409 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6410 });
6411
6412 panel.update_in(cx, |panel, window, cx| {
6413 panel.selected_entry = Some(7);
6414 panel.stage_range(&git::StageRange, window, cx);
6415 });
6416
6417 cx.read(|cx| {
6418 project
6419 .read(cx)
6420 .worktrees(cx)
6421 .next()
6422 .unwrap()
6423 .read(cx)
6424 .as_local()
6425 .unwrap()
6426 .scan_complete()
6427 })
6428 .await;
6429
6430 cx.executor().run_until_parked();
6431
6432 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6433 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6434 });
6435 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6436 handle.await;
6437
6438 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6439 #[rustfmt::skip]
6440 pretty_assertions::assert_matches!(
6441 entries.as_slice(),
6442 &[
6443 Header(GitHeaderEntry { header: Section::Conflict }),
6444 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6445 Header(GitHeaderEntry { header: Section::Tracked }),
6446 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6447 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6448 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6449 Header(GitHeaderEntry { header: Section::New }),
6450 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6451 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6452 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6453 ],
6454 );
6455
6456 let third_status_entry = entries[4].clone();
6457 panel.update_in(cx, |panel, window, cx| {
6458 panel.toggle_staged_for_entry(&third_status_entry, window, cx);
6459 });
6460
6461 panel.update_in(cx, |panel, window, cx| {
6462 panel.selected_entry = Some(9);
6463 panel.stage_range(&git::StageRange, window, cx);
6464 });
6465
6466 cx.read(|cx| {
6467 project
6468 .read(cx)
6469 .worktrees(cx)
6470 .next()
6471 .unwrap()
6472 .read(cx)
6473 .as_local()
6474 .unwrap()
6475 .scan_complete()
6476 })
6477 .await;
6478
6479 cx.executor().run_until_parked();
6480
6481 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6482 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6483 });
6484 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6485 handle.await;
6486
6487 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6488 #[rustfmt::skip]
6489 pretty_assertions::assert_matches!(
6490 entries.as_slice(),
6491 &[
6492 Header(GitHeaderEntry { header: Section::Conflict }),
6493 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6494 Header(GitHeaderEntry { header: Section::Tracked }),
6495 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6496 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6497 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6498 Header(GitHeaderEntry { header: Section::New }),
6499 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6500 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6501 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
6502 ],
6503 );
6504 }
6505
6506 #[gpui::test]
6507 async fn test_bulk_staging_with_sort_by_paths(cx: &mut TestAppContext) {
6508 use GitListEntry::*;
6509
6510 init_test(cx);
6511 let fs = FakeFs::new(cx.background_executor.clone());
6512 fs.insert_tree(
6513 "/root",
6514 json!({
6515 "project": {
6516 ".git": {},
6517 "src": {
6518 "main.rs": "fn main() {}",
6519 "lib.rs": "pub fn hello() {}",
6520 "utils.rs": "pub fn util() {}"
6521 },
6522 "tests": {
6523 "test.rs": "fn test() {}"
6524 },
6525 "new_file.txt": "new content",
6526 "another_new.rs": "// new file",
6527 "conflict.txt": "conflicted content"
6528 }
6529 }),
6530 )
6531 .await;
6532
6533 fs.set_status_for_repo(
6534 Path::new(path!("/root/project/.git")),
6535 &[
6536 ("src/main.rs", StatusCode::Modified.worktree()),
6537 ("src/lib.rs", StatusCode::Modified.worktree()),
6538 ("tests/test.rs", StatusCode::Modified.worktree()),
6539 ("new_file.txt", FileStatus::Untracked),
6540 ("another_new.rs", FileStatus::Untracked),
6541 ("src/utils.rs", FileStatus::Untracked),
6542 (
6543 "conflict.txt",
6544 UnmergedStatus {
6545 first_head: UnmergedStatusCode::Updated,
6546 second_head: UnmergedStatusCode::Updated,
6547 }
6548 .into(),
6549 ),
6550 ],
6551 );
6552
6553 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6554 let workspace =
6555 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6556 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6557
6558 cx.read(|cx| {
6559 project
6560 .read(cx)
6561 .worktrees(cx)
6562 .next()
6563 .unwrap()
6564 .read(cx)
6565 .as_local()
6566 .unwrap()
6567 .scan_complete()
6568 })
6569 .await;
6570
6571 cx.executor().run_until_parked();
6572
6573 let panel = workspace.update(cx, GitPanel::new).unwrap();
6574
6575 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6576 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6577 });
6578 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6579 handle.await;
6580
6581 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6582 #[rustfmt::skip]
6583 pretty_assertions::assert_matches!(
6584 entries.as_slice(),
6585 &[
6586 Header(GitHeaderEntry { header: Section::Conflict }),
6587 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6588 Header(GitHeaderEntry { header: Section::Tracked }),
6589 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6590 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6591 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6592 Header(GitHeaderEntry { header: Section::New }),
6593 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6594 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6595 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
6596 ],
6597 );
6598
6599 assert_entry_paths(
6600 &entries,
6601 &[
6602 None,
6603 Some("conflict.txt"),
6604 None,
6605 Some("src/lib.rs"),
6606 Some("src/main.rs"),
6607 Some("tests/test.rs"),
6608 None,
6609 Some("another_new.rs"),
6610 Some("new_file.txt"),
6611 Some("src/utils.rs"),
6612 ],
6613 );
6614
6615 let second_status_entry = entries[3].clone();
6616 panel.update_in(cx, |panel, window, cx| {
6617 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6618 });
6619
6620 cx.update(|_window, cx| {
6621 SettingsStore::update_global(cx, |store, cx| {
6622 store.update_user_settings(cx, |settings| {
6623 settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
6624 })
6625 });
6626 });
6627
6628 panel.update_in(cx, |panel, window, cx| {
6629 panel.selected_entry = Some(7);
6630 panel.stage_range(&git::StageRange, window, cx);
6631 });
6632
6633 cx.read(|cx| {
6634 project
6635 .read(cx)
6636 .worktrees(cx)
6637 .next()
6638 .unwrap()
6639 .read(cx)
6640 .as_local()
6641 .unwrap()
6642 .scan_complete()
6643 })
6644 .await;
6645
6646 cx.executor().run_until_parked();
6647
6648 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6649 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6650 });
6651 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6652 handle.await;
6653
6654 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6655 #[rustfmt::skip]
6656 pretty_assertions::assert_matches!(
6657 entries.as_slice(),
6658 &[
6659 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6660 Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
6661 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6662 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
6663 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
6664 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6665 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
6666 ],
6667 );
6668
6669 assert_entry_paths(
6670 &entries,
6671 &[
6672 Some("another_new.rs"),
6673 Some("conflict.txt"),
6674 Some("new_file.txt"),
6675 Some("src/lib.rs"),
6676 Some("src/main.rs"),
6677 Some("src/utils.rs"),
6678 Some("tests/test.rs"),
6679 ],
6680 );
6681
6682 let third_status_entry = entries[4].clone();
6683 panel.update_in(cx, |panel, window, cx| {
6684 panel.toggle_staged_for_entry(&third_status_entry, window, cx);
6685 });
6686
6687 panel.update_in(cx, |panel, window, cx| {
6688 panel.selected_entry = Some(9);
6689 panel.stage_range(&git::StageRange, window, cx);
6690 });
6691
6692 cx.read(|cx| {
6693 project
6694 .read(cx)
6695 .worktrees(cx)
6696 .next()
6697 .unwrap()
6698 .read(cx)
6699 .as_local()
6700 .unwrap()
6701 .scan_complete()
6702 })
6703 .await;
6704
6705 cx.executor().run_until_parked();
6706
6707 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6708 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6709 });
6710 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6711 handle.await;
6712
6713 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6714 #[rustfmt::skip]
6715 pretty_assertions::assert_matches!(
6716 entries.as_slice(),
6717 &[
6718 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6719 Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
6720 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6721 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
6722 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
6723 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
6724 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
6725 ],
6726 );
6727
6728 assert_entry_paths(
6729 &entries,
6730 &[
6731 Some("another_new.rs"),
6732 Some("conflict.txt"),
6733 Some("new_file.txt"),
6734 Some("src/lib.rs"),
6735 Some("src/main.rs"),
6736 Some("src/utils.rs"),
6737 Some("tests/test.rs"),
6738 ],
6739 );
6740 }
6741
6742 #[gpui::test]
6743 async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
6744 init_test(cx);
6745 let fs = FakeFs::new(cx.background_executor.clone());
6746 fs.insert_tree(
6747 "/root",
6748 json!({
6749 "project": {
6750 ".git": {},
6751 "src": {
6752 "main.rs": "fn main() {}"
6753 }
6754 }
6755 }),
6756 )
6757 .await;
6758
6759 fs.set_status_for_repo(
6760 Path::new(path!("/root/project/.git")),
6761 &[("src/main.rs", StatusCode::Modified.worktree())],
6762 );
6763
6764 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6765 let workspace =
6766 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6767 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6768
6769 let panel = workspace.update(cx, GitPanel::new).unwrap();
6770
6771 // Test: User has commit message, enables amend (saves message), then disables (restores message)
6772 panel.update(cx, |panel, cx| {
6773 panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
6774 let start = buffer.anchor_before(0);
6775 let end = buffer.anchor_after(buffer.len());
6776 buffer.edit([(start..end, "Initial commit message")], None, cx);
6777 });
6778
6779 panel.set_amend_pending(true, cx);
6780 assert!(panel.original_commit_message.is_some());
6781
6782 panel.set_amend_pending(false, cx);
6783 let current_message = panel.commit_message_buffer(cx).read(cx).text();
6784 assert_eq!(current_message, "Initial commit message");
6785 assert!(panel.original_commit_message.is_none());
6786 });
6787
6788 // Test: User has empty commit message, enables amend, then disables (clears message)
6789 panel.update(cx, |panel, cx| {
6790 panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
6791 let start = buffer.anchor_before(0);
6792 let end = buffer.anchor_after(buffer.len());
6793 buffer.edit([(start..end, "")], None, cx);
6794 });
6795
6796 panel.set_amend_pending(true, cx);
6797 assert!(panel.original_commit_message.is_none());
6798
6799 panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
6800 let start = buffer.anchor_before(0);
6801 let end = buffer.anchor_after(buffer.len());
6802 buffer.edit([(start..end, "Previous commit message")], None, cx);
6803 });
6804
6805 panel.set_amend_pending(false, cx);
6806 let current_message = panel.commit_message_buffer(cx).read(cx).text();
6807 assert_eq!(current_message, "");
6808 });
6809 }
6810
6811 #[gpui::test]
6812 async fn test_amend(cx: &mut TestAppContext) {
6813 init_test(cx);
6814 let fs = FakeFs::new(cx.background_executor.clone());
6815 fs.insert_tree(
6816 "/root",
6817 json!({
6818 "project": {
6819 ".git": {},
6820 "src": {
6821 "main.rs": "fn main() {}"
6822 }
6823 }
6824 }),
6825 )
6826 .await;
6827
6828 fs.set_status_for_repo(
6829 Path::new(path!("/root/project/.git")),
6830 &[("src/main.rs", StatusCode::Modified.worktree())],
6831 );
6832
6833 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
6834 let workspace =
6835 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6836 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6837
6838 // Wait for the project scanning to finish so that `head_commit(cx)` is
6839 // actually set, otherwise no head commit would be available from which
6840 // to fetch the latest commit message from.
6841 cx.executor().run_until_parked();
6842
6843 let panel = workspace.update(cx, GitPanel::new).unwrap();
6844 panel.read_with(cx, |panel, cx| {
6845 assert!(panel.active_repository.is_some());
6846 assert!(panel.head_commit(cx).is_some());
6847 });
6848
6849 panel.update_in(cx, |panel, window, cx| {
6850 // Update the commit editor's message to ensure that its contents
6851 // are later restored, after amending is finished.
6852 panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
6853 buffer.set_text("refactor: update main.rs", cx);
6854 });
6855
6856 // Start amending the previous commit.
6857 panel.focus_editor(&Default::default(), window, cx);
6858 panel.on_amend(&Amend, window, cx);
6859 });
6860
6861 // Since `GitPanel.amend` attempts to fetch the latest commit message in
6862 // a background task, we need to wait for it to complete before being
6863 // able to assert that the commit message editor's state has been
6864 // updated.
6865 cx.run_until_parked();
6866
6867 panel.update_in(cx, |panel, window, cx| {
6868 assert_eq!(
6869 panel.commit_message_buffer(cx).read(cx).text(),
6870 "initial commit"
6871 );
6872 assert_eq!(
6873 panel.original_commit_message,
6874 Some("refactor: update main.rs".to_string())
6875 );
6876
6877 // Finish amending the previous commit.
6878 panel.focus_editor(&Default::default(), window, cx);
6879 panel.on_amend(&Amend, window, cx);
6880 });
6881
6882 // Since the actual commit logic is run in a background task, we need to
6883 // await its completion to actually ensure that the commit message
6884 // editor's contents are set to the original message and haven't been
6885 // cleared.
6886 cx.run_until_parked();
6887
6888 panel.update_in(cx, |panel, _window, cx| {
6889 // After amending, the commit editor's message should be restored to
6890 // the original message.
6891 assert_eq!(
6892 panel.commit_message_buffer(cx).read(cx).text(),
6893 "refactor: update main.rs"
6894 );
6895 assert!(panel.original_commit_message.is_none());
6896 });
6897 }
6898
6899 #[gpui::test]
6900 async fn test_open_diff(cx: &mut TestAppContext) {
6901 init_test(cx);
6902
6903 let fs = FakeFs::new(cx.background_executor.clone());
6904 fs.insert_tree(
6905 path!("/project"),
6906 json!({
6907 ".git": {},
6908 "tracked": "tracked\n",
6909 "untracked": "\n",
6910 }),
6911 )
6912 .await;
6913
6914 fs.set_head_and_index_for_repo(
6915 path!("/project/.git").as_ref(),
6916 &[("tracked", "old tracked\n".into())],
6917 );
6918
6919 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
6920 let workspace =
6921 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6922 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6923 let panel = workspace.update(cx, GitPanel::new).unwrap();
6924
6925 // Enable the `sort_by_path` setting and wait for entries to be updated,
6926 // as there should no longer be separators between Tracked and Untracked
6927 // files.
6928 cx.update(|_window, cx| {
6929 SettingsStore::update_global(cx, |store, cx| {
6930 store.update_user_settings(cx, |settings| {
6931 settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
6932 })
6933 });
6934 });
6935
6936 cx.update_window_entity(&panel, |panel, _, _| {
6937 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6938 })
6939 .await;
6940
6941 // Confirm that `Open Diff` still works for the untracked file, updating
6942 // the Project Diff's active path.
6943 panel.update_in(cx, |panel, window, cx| {
6944 panel.selected_entry = Some(1);
6945 panel.open_diff(&menu::Confirm, window, cx);
6946 });
6947 cx.run_until_parked();
6948
6949 let _ = workspace.update(cx, |workspace, _window, cx| {
6950 let active_path = workspace
6951 .item_of_type::<ProjectDiff>(cx)
6952 .expect("ProjectDiff should exist")
6953 .read(cx)
6954 .active_path(cx)
6955 .expect("active_path should exist");
6956
6957 assert_eq!(active_path.path, rel_path("untracked").into_arc());
6958 });
6959 }
6960
6961 #[gpui::test]
6962 async fn test_tree_view_reveals_collapsed_parent_on_select_entry_by_path(
6963 cx: &mut TestAppContext,
6964 ) {
6965 init_test(cx);
6966
6967 let fs = FakeFs::new(cx.background_executor.clone());
6968 fs.insert_tree(
6969 path!("/project"),
6970 json!({
6971 ".git": {},
6972 "src": {
6973 "a": {
6974 "foo.rs": "fn foo() {}",
6975 },
6976 "b": {
6977 "bar.rs": "fn bar() {}",
6978 },
6979 },
6980 }),
6981 )
6982 .await;
6983
6984 fs.set_status_for_repo(
6985 path!("/project/.git").as_ref(),
6986 &[
6987 ("src/a/foo.rs", StatusCode::Modified.worktree()),
6988 ("src/b/bar.rs", StatusCode::Modified.worktree()),
6989 ],
6990 );
6991
6992 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
6993 let workspace =
6994 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6995 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6996
6997 cx.read(|cx| {
6998 project
6999 .read(cx)
7000 .worktrees(cx)
7001 .next()
7002 .unwrap()
7003 .read(cx)
7004 .as_local()
7005 .unwrap()
7006 .scan_complete()
7007 })
7008 .await;
7009
7010 cx.executor().run_until_parked();
7011
7012 cx.update(|_window, cx| {
7013 SettingsStore::update_global(cx, |store, cx| {
7014 store.update_user_settings(cx, |settings| {
7015 settings.git_panel.get_or_insert_default().tree_view = Some(true);
7016 })
7017 });
7018 });
7019
7020 let panel = workspace.update(cx, GitPanel::new).unwrap();
7021
7022 let handle = cx.update_window_entity(&panel, |panel, _, _| {
7023 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7024 });
7025 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7026 handle.await;
7027
7028 let src_key = panel.read_with(cx, |panel, _| {
7029 panel
7030 .entries
7031 .iter()
7032 .find_map(|entry| match entry {
7033 GitListEntry::Directory(dir) if dir.key.path == repo_path("src") => {
7034 Some(dir.key.clone())
7035 }
7036 _ => None,
7037 })
7038 .expect("src directory should exist in tree view")
7039 });
7040
7041 panel.update_in(cx, |panel, window, cx| {
7042 panel.toggle_directory(&src_key, window, cx);
7043 });
7044
7045 panel.read_with(cx, |panel, _| {
7046 let state = panel
7047 .view_mode
7048 .tree_state()
7049 .expect("tree view state should exist");
7050 assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(false));
7051 });
7052
7053 let worktree_id =
7054 cx.read(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
7055 let project_path = ProjectPath {
7056 worktree_id,
7057 path: RelPath::unix("src/a/foo.rs").unwrap().into_arc(),
7058 };
7059
7060 panel.update_in(cx, |panel, window, cx| {
7061 panel.select_entry_by_path(project_path, window, cx);
7062 });
7063
7064 panel.read_with(cx, |panel, _| {
7065 let state = panel
7066 .view_mode
7067 .tree_state()
7068 .expect("tree view state should exist");
7069 assert_eq!(state.expanded_dirs.get(&src_key).copied(), Some(true));
7070
7071 let selected_ix = panel.selected_entry.expect("selection should be set");
7072 assert!(state.logical_indices.contains(&selected_ix));
7073
7074 let selected_entry = panel
7075 .entries
7076 .get(selected_ix)
7077 .and_then(|entry| entry.status_entry())
7078 .expect("selected entry should be a status entry");
7079 assert_eq!(selected_entry.repo_path, repo_path("src/a/foo.rs"));
7080 });
7081 }
7082
7083 fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
7084 assert_eq!(entries.len(), expected_paths.len());
7085 for (entry, expected_path) in entries.iter().zip(expected_paths) {
7086 assert_eq!(
7087 entry.status_entry().map(|status| status
7088 .repo_path
7089 .as_ref()
7090 .as_std_path()
7091 .to_string_lossy()
7092 .to_string()),
7093 expected_path.map(|s| s.to_string())
7094 );
7095 }
7096 }
7097
7098 #[test]
7099 fn test_compress_diff_no_truncation() {
7100 let diff = indoc! {"
7101 --- a/file.txt
7102 +++ b/file.txt
7103 @@ -1,2 +1,2 @@
7104 -old
7105 +new
7106 "};
7107 let result = GitPanel::compress_commit_diff(diff, 1000);
7108 assert_eq!(result, diff);
7109 }
7110
7111 #[test]
7112 fn test_compress_diff_truncate_long_lines() {
7113 let long_line = "🦀".repeat(300);
7114 let diff = indoc::formatdoc! {"
7115 --- a/file.txt
7116 +++ b/file.txt
7117 @@ -1,2 +1,3 @@
7118 context
7119 +{}
7120 more context
7121 ", long_line};
7122 let result = GitPanel::compress_commit_diff(&diff, 100);
7123 assert!(result.contains("...[truncated]"));
7124 assert!(result.len() < diff.len());
7125 }
7126
7127 #[test]
7128 fn test_compress_diff_truncate_hunks() {
7129 let diff = indoc! {"
7130 --- a/file.txt
7131 +++ b/file.txt
7132 @@ -1,2 +1,2 @@
7133 context
7134 -old1
7135 +new1
7136 @@ -5,2 +5,2 @@
7137 context 2
7138 -old2
7139 +new2
7140 @@ -10,2 +10,2 @@
7141 context 3
7142 -old3
7143 +new3
7144 "};
7145 let result = GitPanel::compress_commit_diff(diff, 100);
7146 let expected = indoc! {"
7147 --- a/file.txt
7148 +++ b/file.txt
7149 @@ -1,2 +1,2 @@
7150 context
7151 -old1
7152 +new1
7153 [...skipped 2 hunks...]
7154 "};
7155 assert_eq!(result, expected);
7156 }
7157
7158 #[gpui::test]
7159 async fn test_suggest_commit_message(cx: &mut TestAppContext) {
7160 init_test(cx);
7161
7162 let fs = FakeFs::new(cx.background_executor.clone());
7163 fs.insert_tree(
7164 path!("/project"),
7165 json!({
7166 ".git": {},
7167 "tracked": "tracked\n",
7168 "untracked": "\n",
7169 }),
7170 )
7171 .await;
7172
7173 fs.set_head_and_index_for_repo(
7174 path!("/project/.git").as_ref(),
7175 &[("tracked", "old tracked\n".into())],
7176 );
7177
7178 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
7179 let workspace =
7180 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
7181 let cx = &mut VisualTestContext::from_window(*workspace, cx);
7182 let panel = workspace.update(cx, GitPanel::new).unwrap();
7183
7184 let handle = cx.update_window_entity(&panel, |panel, _, _| {
7185 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7186 });
7187 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7188 handle.await;
7189
7190 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
7191
7192 // GitPanel
7193 // - Tracked:
7194 // - [] tracked
7195 // - Untracked
7196 // - [] untracked
7197 //
7198 // The commit message should now read:
7199 // "Update tracked"
7200 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7201 assert_eq!(message, Some("Update tracked".to_string()));
7202
7203 let first_status_entry = entries[1].clone();
7204 panel.update_in(cx, |panel, window, cx| {
7205 panel.toggle_staged_for_entry(&first_status_entry, window, cx);
7206 });
7207
7208 cx.read(|cx| {
7209 project
7210 .read(cx)
7211 .worktrees(cx)
7212 .next()
7213 .unwrap()
7214 .read(cx)
7215 .as_local()
7216 .unwrap()
7217 .scan_complete()
7218 })
7219 .await;
7220
7221 cx.executor().run_until_parked();
7222
7223 let handle = cx.update_window_entity(&panel, |panel, _, _| {
7224 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7225 });
7226 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7227 handle.await;
7228
7229 // GitPanel
7230 // - Tracked:
7231 // - [x] tracked
7232 // - Untracked
7233 // - [] untracked
7234 //
7235 // The commit message should still read:
7236 // "Update tracked"
7237 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7238 assert_eq!(message, Some("Update tracked".to_string()));
7239
7240 let second_status_entry = entries[3].clone();
7241 panel.update_in(cx, |panel, window, cx| {
7242 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
7243 });
7244
7245 cx.read(|cx| {
7246 project
7247 .read(cx)
7248 .worktrees(cx)
7249 .next()
7250 .unwrap()
7251 .read(cx)
7252 .as_local()
7253 .unwrap()
7254 .scan_complete()
7255 })
7256 .await;
7257
7258 cx.executor().run_until_parked();
7259
7260 let handle = cx.update_window_entity(&panel, |panel, _, _| {
7261 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7262 });
7263 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7264 handle.await;
7265
7266 // GitPanel
7267 // - Tracked:
7268 // - [x] tracked
7269 // - Untracked
7270 // - [x] untracked
7271 //
7272 // The commit message should now read:
7273 // "Enter commit message"
7274 // (which means we should see None returned).
7275 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7276 assert!(message.is_none());
7277
7278 panel.update_in(cx, |panel, window, cx| {
7279 panel.toggle_staged_for_entry(&first_status_entry, window, cx);
7280 });
7281
7282 cx.read(|cx| {
7283 project
7284 .read(cx)
7285 .worktrees(cx)
7286 .next()
7287 .unwrap()
7288 .read(cx)
7289 .as_local()
7290 .unwrap()
7291 .scan_complete()
7292 })
7293 .await;
7294
7295 cx.executor().run_until_parked();
7296
7297 let handle = cx.update_window_entity(&panel, |panel, _, _| {
7298 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7299 });
7300 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7301 handle.await;
7302
7303 // GitPanel
7304 // - Tracked:
7305 // - [] tracked
7306 // - Untracked
7307 // - [x] untracked
7308 //
7309 // The commit message should now read:
7310 // "Update untracked"
7311 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7312 assert_eq!(message, Some("Create untracked".to_string()));
7313
7314 panel.update_in(cx, |panel, window, cx| {
7315 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
7316 });
7317
7318 cx.read(|cx| {
7319 project
7320 .read(cx)
7321 .worktrees(cx)
7322 .next()
7323 .unwrap()
7324 .read(cx)
7325 .as_local()
7326 .unwrap()
7327 .scan_complete()
7328 })
7329 .await;
7330
7331 cx.executor().run_until_parked();
7332
7333 let handle = cx.update_window_entity(&panel, |panel, _, _| {
7334 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
7335 });
7336 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
7337 handle.await;
7338
7339 // GitPanel
7340 // - Tracked:
7341 // - [] tracked
7342 // - Untracked
7343 // - [] untracked
7344 //
7345 // The commit message should now read:
7346 // "Update tracked"
7347 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
7348 assert_eq!(message, Some("Update tracked".to_string()));
7349 }
7350}