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