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