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