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