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