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