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