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::RewrapOptions;
19use editor::{
20 Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
21 actions::ExpandAllDiffHunks,
22};
23use futures::StreamExt as _;
24use git::commit::ParsedCommitMessage;
25use git::repository::{
26 Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
27 PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
28 UpstreamTrackingStatus, get_git_committer,
29};
30use git::stash::GitStash;
31use git::status::StageStatus;
32use git::{Amend, Signoff, ToggleStaged, repository::RepoPath, status::FileStatus};
33use git::{
34 ExpandCommitEditor, GitHostingProviderRegistry, RestoreTrackedFiles, StageAll, StashAll,
35 StashApply, StashPop, TrashUntrackedFiles, UnstageAll,
36};
37use gpui::{
38 Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Entity,
39 EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
40 PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
41 anchored, deferred, point, size, uniform_list,
42};
43use itertools::Itertools;
44use language::{Buffer, File};
45use language_model::{
46 ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
47};
48use menu;
49use multi_buffer::ExcerptInfo;
50use notifications::status_toast::{StatusToast, ToastIcon};
51use panel::{
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::{BuiltInPrompt, 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::RepositoryUpdated(
720 _,
721 RepositoryEvent::StatusesChanged
722 | RepositoryEvent::BranchChanged
723 | RepositoryEvent::MergeHeadsChanged,
724 true,
725 )
726 | GitStoreEvent::RepositoryAdded
727 | GitStoreEvent::RepositoryRemoved(_)
728 | GitStoreEvent::ActiveRepositoryChanged(_) => {
729 this.schedule_update(window, cx);
730 }
731 GitStoreEvent::IndexWriteError(error) => {
732 this.workspace
733 .update(cx, |workspace, cx| {
734 workspace.show_error(error, cx);
735 })
736 .ok();
737 }
738 GitStoreEvent::RepositoryUpdated(_, _, _) => {}
739 GitStoreEvent::JobsUpdated | GitStoreEvent::ConflictsUpdated => {}
740 },
741 )
742 .detach();
743
744 let mut this = Self {
745 active_repository,
746 commit_editor,
747 conflicted_count: 0,
748 conflicted_staged_count: 0,
749 add_coauthors: true,
750 generate_commit_message_task: None,
751 entries: Vec::new(),
752 view_mode: GitPanelViewMode::from_settings(cx),
753 entries_indices: HashMap::default(),
754 focus_handle: cx.focus_handle(),
755 fs,
756 new_count: 0,
757 new_staged_count: 0,
758 changes_count: 0,
759 pending_commit: None,
760 amend_pending: false,
761 original_commit_message: None,
762 signoff_enabled: false,
763 pending_serialization: Task::ready(()),
764 single_staged_entry: None,
765 single_tracked_entry: None,
766 project,
767 scroll_handle,
768 max_width_item_index: None,
769 selected_entry: None,
770 marked_entries: Vec::new(),
771 tracked_count: 0,
772 tracked_staged_count: 0,
773 update_visible_entries_task: Task::ready(()),
774 width: None,
775 show_placeholders: false,
776 local_committer: None,
777 local_committer_task: None,
778 context_menu: None,
779 workspace: workspace.weak_handle(),
780 modal_open: false,
781 entry_count: 0,
782 bulk_staging: None,
783 stash_entries: Default::default(),
784 _settings_subscription,
785 };
786
787 this.schedule_update(window, cx);
788 this
789 })
790 }
791
792 pub fn entry_by_path(&self, path: &RepoPath) -> Option<usize> {
793 self.entries_indices.get(path).copied()
794 }
795
796 pub fn select_entry_by_path(
797 &mut self,
798 path: ProjectPath,
799 window: &mut Window,
800 cx: &mut Context<Self>,
801 ) {
802 let Some(git_repo) = self.active_repository.as_ref() else {
803 return;
804 };
805
806 let (repo_path, section) = {
807 let repo = git_repo.read(cx);
808 let Some(repo_path) = repo.project_path_to_repo_path(&path, cx) else {
809 return;
810 };
811
812 let section = repo
813 .status_for_path(&repo_path)
814 .map(|status| status.status)
815 .map(|status| {
816 if repo.had_conflict_on_last_merge_head_change(&repo_path) {
817 Section::Conflict
818 } else if status.is_created() {
819 Section::New
820 } else {
821 Section::Tracked
822 }
823 });
824
825 (repo_path, section)
826 };
827
828 let mut needs_rebuild = false;
829 if let (Some(section), Some(tree_state)) = (section, self.view_mode.tree_state_mut()) {
830 let mut current_dir = repo_path.parent();
831 while let Some(dir) = current_dir {
832 let key = TreeKey {
833 section,
834 path: RepoPath::from_rel_path(dir),
835 };
836
837 if tree_state.expanded_dirs.get(&key) == Some(&false) {
838 tree_state.expanded_dirs.insert(key, true);
839 needs_rebuild = true;
840 }
841
842 current_dir = dir.parent();
843 }
844 }
845
846 if needs_rebuild {
847 self.update_visible_entries(window, cx);
848 }
849
850 let Some(ix) = self.entry_by_path(&repo_path) else {
851 return;
852 };
853
854 self.selected_entry = Some(ix);
855 self.scroll_to_selected_entry(cx);
856 }
857
858 fn serialization_key(workspace: &Workspace) -> Option<String> {
859 workspace
860 .database_id()
861 .map(|id| i64::from(id).to_string())
862 .or(workspace.session_id())
863 .map(|id| format!("{}-{:?}", GIT_PANEL_KEY, id))
864 }
865
866 fn serialize(&mut self, cx: &mut Context<Self>) {
867 let width = self.width;
868 let amend_pending = self.amend_pending;
869 let signoff_enabled = self.signoff_enabled;
870
871 self.pending_serialization = cx.spawn(async move |git_panel, cx| {
872 cx.background_executor()
873 .timer(SERIALIZATION_THROTTLE_TIME)
874 .await;
875 let Some(serialization_key) = git_panel
876 .update(cx, |git_panel, cx| {
877 git_panel
878 .workspace
879 .read_with(cx, |workspace, _| Self::serialization_key(workspace))
880 .ok()
881 .flatten()
882 })
883 .ok()
884 .flatten()
885 else {
886 return;
887 };
888 cx.background_spawn(
889 async move {
890 KEY_VALUE_STORE
891 .write_kvp(
892 serialization_key,
893 serde_json::to_string(&SerializedGitPanel {
894 width,
895 amend_pending,
896 signoff_enabled,
897 })?,
898 )
899 .await?;
900 anyhow::Ok(())
901 }
902 .log_err(),
903 )
904 .await;
905 });
906 }
907
908 pub(crate) fn set_modal_open(&mut self, open: bool, cx: &mut Context<Self>) {
909 self.modal_open = open;
910 cx.notify();
911 }
912
913 fn dispatch_context(&self, window: &mut Window, cx: &Context<Self>) -> KeyContext {
914 let mut dispatch_context = KeyContext::new_with_defaults();
915 dispatch_context.add("GitPanel");
916
917 if window
918 .focused(cx)
919 .is_some_and(|focused| self.focus_handle == focused)
920 {
921 dispatch_context.add("menu");
922 dispatch_context.add("ChangesList");
923 }
924
925 if self.commit_editor.read(cx).is_focused(window) {
926 dispatch_context.add("CommitEditor");
927 }
928
929 dispatch_context
930 }
931
932 fn close_panel(&mut self, _: &Close, _window: &mut Window, cx: &mut Context<Self>) {
933 cx.emit(PanelEvent::Close);
934 }
935
936 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
937 if !self.focus_handle.contains_focused(window, cx) {
938 cx.emit(Event::Focus);
939 }
940 }
941
942 fn scroll_to_selected_entry(&mut self, cx: &mut Context<Self>) {
943 let Some(selected_entry) = self.selected_entry else {
944 cx.notify();
945 return;
946 };
947
948 let visible_index = match &self.view_mode {
949 GitPanelViewMode::Flat => Some(selected_entry),
950 GitPanelViewMode::Tree(state) => state
951 .logical_indices
952 .iter()
953 .position(|&ix| ix == selected_entry),
954 };
955
956 if let Some(visible_index) = visible_index {
957 self.scroll_handle
958 .scroll_to_item(visible_index, ScrollStrategy::Center);
959 }
960
961 cx.notify();
962 }
963
964 fn expand_selected_entry(
965 &mut self,
966 _: &ExpandSelectedEntry,
967 window: &mut Window,
968 cx: &mut Context<Self>,
969 ) {
970 let Some(entry) = self.get_selected_entry().cloned() else {
971 return;
972 };
973
974 if let GitListEntry::Directory(dir_entry) = entry {
975 if dir_entry.expanded {
976 self.select_next(&menu::SelectNext, window, cx);
977 } else {
978 self.toggle_directory(&dir_entry.key, window, cx);
979 }
980 } else {
981 self.select_next(&menu::SelectNext, window, cx);
982 }
983 }
984
985 fn collapse_selected_entry(
986 &mut self,
987 _: &CollapseSelectedEntry,
988 window: &mut Window,
989 cx: &mut Context<Self>,
990 ) {
991 let Some(entry) = self.get_selected_entry().cloned() else {
992 return;
993 };
994
995 if let GitListEntry::Directory(dir_entry) = entry {
996 if dir_entry.expanded {
997 self.toggle_directory(&dir_entry.key, window, cx);
998 } else {
999 self.select_previous(&menu::SelectPrevious, window, cx);
1000 }
1001 } else {
1002 self.select_previous(&menu::SelectPrevious, window, cx);
1003 }
1004 }
1005
1006 fn select_first(
1007 &mut self,
1008 _: &menu::SelectFirst,
1009 _window: &mut Window,
1010 cx: &mut Context<Self>,
1011 ) {
1012 let first_entry = match &self.view_mode {
1013 GitPanelViewMode::Flat => self
1014 .entries
1015 .iter()
1016 .position(|entry| entry.status_entry().is_some()),
1017 GitPanelViewMode::Tree(state) => {
1018 let index = self.entries.iter().position(|entry| {
1019 entry.status_entry().is_some() || entry.directory_entry().is_some()
1020 });
1021
1022 index.map(|index| state.logical_indices[index])
1023 }
1024 };
1025
1026 if let Some(first_entry) = first_entry {
1027 self.selected_entry = Some(first_entry);
1028 self.scroll_to_selected_entry(cx);
1029 }
1030 }
1031
1032 fn select_previous(
1033 &mut self,
1034 _: &menu::SelectPrevious,
1035 _window: &mut Window,
1036 cx: &mut Context<Self>,
1037 ) {
1038 let item_count = self.entries.len();
1039 if item_count == 0 {
1040 return;
1041 }
1042
1043 let Some(selected_entry) = self.selected_entry else {
1044 return;
1045 };
1046
1047 let new_index = match &self.view_mode {
1048 GitPanelViewMode::Flat => selected_entry.saturating_sub(1),
1049 GitPanelViewMode::Tree(state) => {
1050 let Some(current_logical_index) = state
1051 .logical_indices
1052 .iter()
1053 .position(|&i| i == selected_entry)
1054 else {
1055 return;
1056 };
1057
1058 state.logical_indices[current_logical_index.saturating_sub(1)]
1059 }
1060 };
1061
1062 if selected_entry == 0 && new_index == 0 {
1063 return;
1064 }
1065
1066 if matches!(
1067 self.entries.get(new_index.saturating_sub(1)),
1068 Some(GitListEntry::Header(..))
1069 ) && new_index == 0
1070 {
1071 return;
1072 }
1073
1074 if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
1075 self.selected_entry = Some(new_index.saturating_sub(1));
1076 } else {
1077 self.selected_entry = Some(new_index);
1078 }
1079
1080 self.scroll_to_selected_entry(cx);
1081 }
1082
1083 fn select_next(&mut self, _: &menu::SelectNext, _window: &mut Window, cx: &mut Context<Self>) {
1084 let item_count = self.entries.len();
1085 if item_count == 0 {
1086 return;
1087 }
1088
1089 let Some(selected_entry) = self.selected_entry else {
1090 return;
1091 };
1092
1093 if selected_entry == item_count - 1 {
1094 return;
1095 }
1096
1097 let new_index = match &self.view_mode {
1098 GitPanelViewMode::Flat => selected_entry.saturating_add(1),
1099 GitPanelViewMode::Tree(state) => {
1100 let Some(current_logical_index) = state
1101 .logical_indices
1102 .iter()
1103 .position(|&i| i == selected_entry)
1104 else {
1105 return;
1106 };
1107
1108 state.logical_indices[current_logical_index.saturating_add(1)]
1109 }
1110 };
1111
1112 if matches!(self.entries.get(new_index), Some(GitListEntry::Header(..))) {
1113 self.selected_entry = Some(new_index.saturating_add(1));
1114 } else {
1115 self.selected_entry = Some(new_index);
1116 }
1117
1118 self.scroll_to_selected_entry(cx);
1119 }
1120
1121 fn select_last(&mut self, _: &menu::SelectLast, _window: &mut Window, cx: &mut Context<Self>) {
1122 if self.entries.last().is_some() {
1123 self.selected_entry = Some(self.entries.len() - 1);
1124 self.scroll_to_selected_entry(cx);
1125 }
1126 }
1127
1128 /// Show diff view at selected entry, only if the diff view is open
1129 fn move_diff_to_entry(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1130 maybe!({
1131 let workspace = self.workspace.upgrade()?;
1132
1133 if let Some(project_diff) = workspace.read(cx).item_of_type::<ProjectDiff>(cx) {
1134 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1135
1136 project_diff.update(cx, |project_diff, cx| {
1137 project_diff.move_to_entry(entry.clone(), window, cx);
1138 });
1139 }
1140
1141 Some(())
1142 });
1143 }
1144
1145 fn first_entry(&mut self, _: &FirstEntry, window: &mut Window, cx: &mut Context<Self>) {
1146 self.select_first(&menu::SelectFirst, window, cx);
1147 self.move_diff_to_entry(window, cx);
1148 }
1149
1150 fn last_entry(&mut self, _: &LastEntry, window: &mut Window, cx: &mut Context<Self>) {
1151 self.select_last(&menu::SelectLast, window, cx);
1152 self.move_diff_to_entry(window, cx);
1153 }
1154
1155 fn next_entry(&mut self, _: &NextEntry, window: &mut Window, cx: &mut Context<Self>) {
1156 self.select_next(&menu::SelectNext, window, cx);
1157 self.move_diff_to_entry(window, cx);
1158 }
1159
1160 fn previous_entry(&mut self, _: &PreviousEntry, window: &mut Window, cx: &mut Context<Self>) {
1161 self.select_previous(&menu::SelectPrevious, window, cx);
1162 self.move_diff_to_entry(window, cx);
1163 }
1164
1165 fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
1166 self.commit_editor.update(cx, |editor, cx| {
1167 window.focus(&editor.focus_handle(cx), cx);
1168 });
1169 cx.notify();
1170 }
1171
1172 fn select_first_entry_if_none(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1173 let have_entries = self
1174 .active_repository
1175 .as_ref()
1176 .is_some_and(|active_repository| active_repository.read(cx).status_summary().count > 0);
1177 if have_entries && self.selected_entry.is_none() {
1178 self.select_first(&menu::SelectFirst, window, cx);
1179 }
1180 }
1181
1182 fn focus_changes_list(
1183 &mut self,
1184 _: &FocusChanges,
1185 window: &mut Window,
1186 cx: &mut Context<Self>,
1187 ) {
1188 self.focus_handle.focus(window, cx);
1189 self.select_first_entry_if_none(window, cx);
1190 }
1191
1192 fn get_selected_entry(&self) -> Option<&GitListEntry> {
1193 self.selected_entry.and_then(|i| self.entries.get(i))
1194 }
1195
1196 fn open_diff(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1197 maybe!({
1198 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1199 let workspace = self.workspace.upgrade()?;
1200 let git_repo = self.active_repository.as_ref()?;
1201
1202 if let Some(project_diff) = workspace.read(cx).active_item_as::<ProjectDiff>(cx)
1203 && let Some(project_path) = project_diff.read(cx).active_path(cx)
1204 && Some(&entry.repo_path)
1205 == git_repo
1206 .read(cx)
1207 .project_path_to_repo_path(&project_path, cx)
1208 .as_ref()
1209 {
1210 project_diff.focus_handle(cx).focus(window, cx);
1211 project_diff.update(cx, |project_diff, cx| project_diff.autoscroll(cx));
1212 return None;
1213 };
1214
1215 self.workspace
1216 .update(cx, |workspace, cx| {
1217 ProjectDiff::deploy_at(workspace, Some(entry.clone()), window, cx);
1218 })
1219 .ok();
1220 self.focus_handle.focus(window, cx);
1221
1222 Some(())
1223 });
1224 }
1225
1226 fn file_history(&mut self, _: &git::FileHistory, window: &mut Window, cx: &mut Context<Self>) {
1227 maybe!({
1228 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1229 let active_repo = self.active_repository.as_ref()?;
1230 let repo_path = entry.repo_path.clone();
1231 let git_store = self.project.read(cx).git_store();
1232
1233 FileHistoryView::open(
1234 repo_path,
1235 git_store.downgrade(),
1236 active_repo.downgrade(),
1237 self.workspace.clone(),
1238 window,
1239 cx,
1240 );
1241
1242 Some(())
1243 });
1244 }
1245
1246 fn open_file(
1247 &mut self,
1248 _: &menu::SecondaryConfirm,
1249 window: &mut Window,
1250 cx: &mut Context<Self>,
1251 ) {
1252 maybe!({
1253 let entry = self.entries.get(self.selected_entry?)?.status_entry()?;
1254 let active_repo = self.active_repository.as_ref()?;
1255 let path = active_repo
1256 .read(cx)
1257 .repo_path_to_project_path(&entry.repo_path, cx)?;
1258 if entry.status.is_deleted() {
1259 return None;
1260 }
1261
1262 let open_task = self
1263 .workspace
1264 .update(cx, |workspace, cx| {
1265 workspace.open_path_preview(path, None, false, false, true, window, cx)
1266 })
1267 .ok()?;
1268
1269 cx.spawn_in(window, async move |_, mut cx| {
1270 let item = open_task
1271 .await
1272 .notify_async_err(&mut cx)
1273 .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
1274 if let Some(active_editor) = item.downcast::<Editor>() {
1275 if let Some(diff_task) =
1276 active_editor.update(cx, |editor, _cx| editor.wait_for_diff_to_load())
1277 {
1278 diff_task.await;
1279 }
1280
1281 cx.update(|window, cx| {
1282 active_editor.update(cx, |editor, cx| {
1283 editor.expand_all_diff_hunks(&ExpandAllDiffHunks, window, cx);
1284
1285 let snapshot = editor.snapshot(window, cx);
1286 editor.go_to_hunk_before_or_after_position(
1287 &snapshot,
1288 language::Point::new(0, 0),
1289 Direction::Next,
1290 window,
1291 cx,
1292 );
1293 })
1294 })
1295 .log_err();
1296 }
1297
1298 anyhow::Ok(())
1299 })
1300 .detach();
1301
1302 Some(())
1303 });
1304 }
1305
1306 fn revert_selected(
1307 &mut self,
1308 action: &git::RestoreFile,
1309 window: &mut Window,
1310 cx: &mut Context<Self>,
1311 ) {
1312 let path_style = self.project.read(cx).path_style(cx);
1313 maybe!({
1314 let list_entry = self.entries.get(self.selected_entry?)?.clone();
1315 let entry = list_entry.status_entry()?.to_owned();
1316 let skip_prompt = action.skip_prompt || entry.status.is_created();
1317
1318 let prompt = if skip_prompt {
1319 Task::ready(Ok(0))
1320 } else {
1321 let prompt = window.prompt(
1322 PromptLevel::Warning,
1323 &format!(
1324 "Are you sure you want to discard changes to {}?",
1325 entry
1326 .repo_path
1327 .file_name()
1328 .unwrap_or(entry.repo_path.display(path_style).as_ref()),
1329 ),
1330 None,
1331 &["Discard Changes", "Cancel"],
1332 cx,
1333 );
1334 cx.background_spawn(prompt)
1335 };
1336
1337 let this = cx.weak_entity();
1338 window
1339 .spawn(cx, async move |cx| {
1340 if prompt.await? != 0 {
1341 return anyhow::Ok(());
1342 }
1343
1344 this.update_in(cx, |this, window, cx| {
1345 this.revert_entry(&entry, window, cx);
1346 })?;
1347
1348 Ok(())
1349 })
1350 .detach();
1351 Some(())
1352 });
1353 }
1354
1355 fn add_to_gitignore(
1356 &mut self,
1357 _: &git::AddToGitignore,
1358 _window: &mut Window,
1359 cx: &mut Context<Self>,
1360 ) {
1361 maybe!({
1362 let list_entry = self.entries.get(self.selected_entry?)?.clone();
1363 let entry = list_entry.status_entry()?.to_owned();
1364
1365 if !entry.status.is_created() {
1366 return Some(());
1367 }
1368
1369 let project = self.project.downgrade();
1370 let repo_path = entry.repo_path;
1371 let active_repository = self.active_repository.as_ref()?.downgrade();
1372
1373 cx.spawn(async move |_, cx| {
1374 let file_path_str = repo_path.as_ref().display(PathStyle::Posix);
1375
1376 let repo_root = active_repository.read_with(cx, |repository, _| {
1377 repository.snapshot().work_directory_abs_path
1378 })?;
1379
1380 let gitignore_abs_path = repo_root.join(".gitignore");
1381
1382 let buffer: Entity<Buffer> = project
1383 .update(cx, |project, cx| {
1384 project.open_local_buffer(gitignore_abs_path, cx)
1385 })?
1386 .await?;
1387
1388 let mut should_save = false;
1389 buffer.update(cx, |buffer, cx| {
1390 let existing_content = buffer.text();
1391
1392 if existing_content
1393 .lines()
1394 .any(|line: &str| line.trim() == file_path_str)
1395 {
1396 return;
1397 }
1398
1399 let insert_position = existing_content.len();
1400 let new_entry = if existing_content.is_empty() {
1401 format!("{}\n", file_path_str)
1402 } else if existing_content.ends_with('\n') {
1403 format!("{}\n", file_path_str)
1404 } else {
1405 format!("\n{}\n", file_path_str)
1406 };
1407
1408 buffer.edit([(insert_position..insert_position, new_entry)], None, cx);
1409 should_save = true;
1410 });
1411
1412 if should_save {
1413 project
1414 .update(cx, |project, cx| project.save_buffer(buffer, cx))?
1415 .await?;
1416 }
1417
1418 anyhow::Ok(())
1419 })
1420 .detach_and_log_err(cx);
1421
1422 Some(())
1423 });
1424 }
1425
1426 fn revert_entry(
1427 &mut self,
1428 entry: &GitStatusEntry,
1429 window: &mut Window,
1430 cx: &mut Context<Self>,
1431 ) {
1432 maybe!({
1433 let active_repo = self.active_repository.clone()?;
1434 let path = active_repo
1435 .read(cx)
1436 .repo_path_to_project_path(&entry.repo_path, cx)?;
1437 let workspace = self.workspace.clone();
1438
1439 if entry.status.staging().has_staged() {
1440 self.change_file_stage(false, vec![entry.clone()], cx);
1441 }
1442 let filename = path.path.file_name()?.to_string();
1443
1444 if !entry.status.is_created() {
1445 self.perform_checkout(vec![entry.clone()], window, cx);
1446 } else {
1447 let prompt = prompt(&format!("Trash {}?", filename), None, window, cx);
1448 cx.spawn_in(window, async move |_, cx| {
1449 match prompt.await? {
1450 TrashCancel::Trash => {}
1451 TrashCancel::Cancel => return Ok(()),
1452 }
1453 let task = workspace.update(cx, |workspace, cx| {
1454 workspace
1455 .project()
1456 .update(cx, |project, cx| project.delete_file(path, true, cx))
1457 })?;
1458 if let Some(task) = task {
1459 task.await?;
1460 }
1461 Ok(())
1462 })
1463 .detach_and_prompt_err(
1464 "Failed to trash file",
1465 window,
1466 cx,
1467 |e, _, _| Some(format!("{e}")),
1468 );
1469 }
1470 Some(())
1471 });
1472 }
1473
1474 fn perform_checkout(
1475 &mut self,
1476 entries: Vec<GitStatusEntry>,
1477 window: &mut Window,
1478 cx: &mut Context<Self>,
1479 ) {
1480 let workspace = self.workspace.clone();
1481 let Some(active_repository) = self.active_repository.clone() else {
1482 return;
1483 };
1484
1485 let task = cx.spawn_in(window, async move |this, cx| {
1486 let tasks: Vec<_> = workspace.update(cx, |workspace, cx| {
1487 workspace.project().update(cx, |project, cx| {
1488 entries
1489 .iter()
1490 .filter_map(|entry| {
1491 let path = active_repository
1492 .read(cx)
1493 .repo_path_to_project_path(&entry.repo_path, cx)?;
1494 Some(project.open_buffer(path, cx))
1495 })
1496 .collect()
1497 })
1498 })?;
1499
1500 let buffers = futures::future::join_all(tasks).await;
1501
1502 this.update_in(cx, |this, window, cx| {
1503 let task = active_repository.update(cx, |repo, cx| {
1504 repo.checkout_files(
1505 "HEAD",
1506 entries
1507 .into_iter()
1508 .map(|entries| entries.repo_path)
1509 .collect(),
1510 cx,
1511 )
1512 });
1513 this.update_visible_entries(window, cx);
1514 cx.notify();
1515 task
1516 })?
1517 .await?;
1518
1519 let tasks: Vec<_> = cx.update(|_, cx| {
1520 buffers
1521 .iter()
1522 .filter_map(|buffer| {
1523 buffer.as_ref().ok()?.update(cx, |buffer, cx| {
1524 buffer.is_dirty().then(|| buffer.reload(cx))
1525 })
1526 })
1527 .collect()
1528 })?;
1529
1530 futures::future::join_all(tasks).await;
1531
1532 Ok(())
1533 });
1534
1535 cx.spawn_in(window, async move |this, cx| {
1536 let result = task.await;
1537
1538 this.update_in(cx, |this, window, cx| {
1539 if let Err(err) = result {
1540 this.update_visible_entries(window, cx);
1541 this.show_error_toast("checkout", err, cx);
1542 }
1543 })
1544 .ok();
1545 })
1546 .detach();
1547 }
1548
1549 fn restore_tracked_files(
1550 &mut self,
1551 _: &RestoreTrackedFiles,
1552 window: &mut Window,
1553 cx: &mut Context<Self>,
1554 ) {
1555 let entries = self
1556 .entries
1557 .iter()
1558 .filter_map(|entry| entry.status_entry().cloned())
1559 .filter(|status_entry| !status_entry.status.is_created())
1560 .collect::<Vec<_>>();
1561
1562 match entries.len() {
1563 0 => return,
1564 1 => return self.revert_entry(&entries[0], window, cx),
1565 _ => {}
1566 }
1567 let mut details = entries
1568 .iter()
1569 .filter_map(|entry| entry.repo_path.as_ref().file_name())
1570 .map(|filename| filename.to_string())
1571 .take(5)
1572 .join("\n");
1573 if entries.len() > 5 {
1574 details.push_str(&format!("\nand {} more…", entries.len() - 5))
1575 }
1576
1577 #[derive(strum::EnumIter, strum::VariantNames)]
1578 #[strum(serialize_all = "title_case")]
1579 enum RestoreCancel {
1580 RestoreTrackedFiles,
1581 Cancel,
1582 }
1583 let prompt = prompt(
1584 "Discard changes to these files?",
1585 Some(&details),
1586 window,
1587 cx,
1588 );
1589 cx.spawn_in(window, async move |this, cx| {
1590 if let Ok(RestoreCancel::RestoreTrackedFiles) = prompt.await {
1591 this.update_in(cx, |this, window, cx| {
1592 this.perform_checkout(entries, window, cx);
1593 })
1594 .ok();
1595 }
1596 })
1597 .detach();
1598 }
1599
1600 fn clean_all(&mut self, _: &TrashUntrackedFiles, window: &mut Window, cx: &mut Context<Self>) {
1601 let workspace = self.workspace.clone();
1602 let Some(active_repo) = self.active_repository.clone() else {
1603 return;
1604 };
1605 let to_delete = self
1606 .entries
1607 .iter()
1608 .filter_map(|entry| entry.status_entry())
1609 .filter(|status_entry| status_entry.status.is_created())
1610 .cloned()
1611 .collect::<Vec<_>>();
1612
1613 match to_delete.len() {
1614 0 => return,
1615 1 => return self.revert_entry(&to_delete[0], window, cx),
1616 _ => {}
1617 };
1618
1619 let mut details = to_delete
1620 .iter()
1621 .map(|entry| {
1622 entry
1623 .repo_path
1624 .as_ref()
1625 .file_name()
1626 .map(|f| f.to_string())
1627 .unwrap_or_default()
1628 })
1629 .take(5)
1630 .join("\n");
1631
1632 if to_delete.len() > 5 {
1633 details.push_str(&format!("\nand {} more…", to_delete.len() - 5))
1634 }
1635
1636 let prompt = prompt("Trash these files?", Some(&details), window, cx);
1637 cx.spawn_in(window, async move |this, cx| {
1638 match prompt.await? {
1639 TrashCancel::Trash => {}
1640 TrashCancel::Cancel => return Ok(()),
1641 }
1642 let tasks = workspace.update(cx, |workspace, cx| {
1643 to_delete
1644 .iter()
1645 .filter_map(|entry| {
1646 workspace.project().update(cx, |project, cx| {
1647 let project_path = active_repo
1648 .read(cx)
1649 .repo_path_to_project_path(&entry.repo_path, cx)?;
1650 project.delete_file(project_path, true, cx)
1651 })
1652 })
1653 .collect::<Vec<_>>()
1654 })?;
1655 let to_unstage = to_delete
1656 .into_iter()
1657 .filter(|entry| !entry.status.staging().is_fully_unstaged())
1658 .collect();
1659 this.update(cx, |this, cx| this.change_file_stage(false, to_unstage, cx))?;
1660 for task in tasks {
1661 task.await?;
1662 }
1663 Ok(())
1664 })
1665 .detach_and_prompt_err("Failed to trash files", window, cx, |e, _, _| {
1666 Some(format!("{e}"))
1667 });
1668 }
1669
1670 fn change_all_files_stage(&mut self, stage: bool, cx: &mut Context<Self>) {
1671 let Some(active_repository) = self.active_repository.clone() else {
1672 return;
1673 };
1674 cx.spawn({
1675 async move |this, cx| {
1676 let result = this
1677 .update(cx, |this, cx| {
1678 let task = active_repository.update(cx, |repo, cx| {
1679 if stage {
1680 repo.stage_all(cx)
1681 } else {
1682 repo.unstage_all(cx)
1683 }
1684 });
1685 this.update_counts(active_repository.read(cx));
1686 cx.notify();
1687 task
1688 })?
1689 .await;
1690
1691 this.update(cx, |this, cx| {
1692 if let Err(err) = result {
1693 this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
1694 }
1695 cx.notify()
1696 })
1697 }
1698 })
1699 .detach();
1700 }
1701
1702 fn stage_status_for_entry(entry: &GitStatusEntry, repo: &Repository) -> StageStatus {
1703 // Checking for current staged/unstaged file status is a chained operation:
1704 // 1. first, we check for any pending operation recorded in repository
1705 // 2. if there are no pending ops either running or finished, we then ask the repository
1706 // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry`
1707 // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on
1708 // the checkbox's state (or flickering) which is undesirable.
1709 // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded
1710 // in `entry` arg.
1711 repo.pending_ops_for_path(&entry.repo_path)
1712 .map(|ops| {
1713 if ops.staging() || ops.staged() {
1714 StageStatus::Staged
1715 } else {
1716 StageStatus::Unstaged
1717 }
1718 })
1719 .or_else(|| {
1720 repo.status_for_path(&entry.repo_path)
1721 .map(|status| status.status.staging())
1722 })
1723 .unwrap_or(entry.staging)
1724 }
1725
1726 fn stage_status_for_directory(
1727 &self,
1728 entry: &GitTreeDirEntry,
1729 repo: &Repository,
1730 ) -> StageStatus {
1731 let GitPanelViewMode::Tree(tree_state) = &self.view_mode else {
1732 util::debug_panic!("We should never render a directory entry while in flat view mode");
1733 return StageStatus::Unstaged;
1734 };
1735
1736 let Some(descendants) = tree_state.directory_descendants.get(&entry.key) else {
1737 return StageStatus::Unstaged;
1738 };
1739
1740 let mut fully_staged_count = 0usize;
1741 let mut any_staged_or_partially_staged = false;
1742
1743 for descendant in descendants {
1744 match GitPanel::stage_status_for_entry(descendant, repo) {
1745 StageStatus::Staged => {
1746 fully_staged_count += 1;
1747 any_staged_or_partially_staged = true;
1748 }
1749 StageStatus::PartiallyStaged => {
1750 any_staged_or_partially_staged = true;
1751 }
1752 StageStatus::Unstaged => {}
1753 }
1754 }
1755
1756 if descendants.is_empty() {
1757 StageStatus::Unstaged
1758 } else if fully_staged_count == descendants.len() {
1759 StageStatus::Staged
1760 } else if any_staged_or_partially_staged {
1761 StageStatus::PartiallyStaged
1762 } else {
1763 StageStatus::Unstaged
1764 }
1765 }
1766
1767 pub fn stage_all(&mut self, _: &StageAll, _window: &mut Window, cx: &mut Context<Self>) {
1768 self.change_all_files_stage(true, cx);
1769 }
1770
1771 pub fn unstage_all(&mut self, _: &UnstageAll, _window: &mut Window, cx: &mut Context<Self>) {
1772 self.change_all_files_stage(false, cx);
1773 }
1774
1775 fn toggle_staged_for_entry(
1776 &mut self,
1777 entry: &GitListEntry,
1778 _window: &mut Window,
1779 cx: &mut Context<Self>,
1780 ) {
1781 let Some(active_repository) = self.active_repository.clone() else {
1782 return;
1783 };
1784 let mut set_anchor: Option<RepoPath> = None;
1785 let mut clear_anchor = None;
1786
1787 let (stage, repo_paths) = {
1788 let repo = active_repository.read(cx);
1789 match entry {
1790 GitListEntry::Status(status_entry) => {
1791 let repo_paths = vec![status_entry.clone()];
1792 let stage = match GitPanel::stage_status_for_entry(status_entry, &repo) {
1793 StageStatus::Staged => {
1794 if let Some(op) = self.bulk_staging.clone()
1795 && op.anchor == status_entry.repo_path
1796 {
1797 clear_anchor = Some(op.anchor);
1798 }
1799 false
1800 }
1801 StageStatus::Unstaged | StageStatus::PartiallyStaged => {
1802 set_anchor = Some(status_entry.repo_path.clone());
1803 true
1804 }
1805 };
1806 (stage, repo_paths)
1807 }
1808 GitListEntry::TreeStatus(status_entry) => {
1809 let repo_paths = vec![status_entry.entry.clone()];
1810 let stage = match GitPanel::stage_status_for_entry(&status_entry.entry, &repo) {
1811 StageStatus::Staged => {
1812 if let Some(op) = self.bulk_staging.clone()
1813 && op.anchor == status_entry.entry.repo_path
1814 {
1815 clear_anchor = Some(op.anchor);
1816 }
1817 false
1818 }
1819 StageStatus::Unstaged | StageStatus::PartiallyStaged => {
1820 set_anchor = Some(status_entry.entry.repo_path.clone());
1821 true
1822 }
1823 };
1824 (stage, repo_paths)
1825 }
1826 GitListEntry::Header(section) => {
1827 let goal_staged_state = !self.header_state(section.header).selected();
1828 let entries = self
1829 .entries
1830 .iter()
1831 .filter_map(|entry| entry.status_entry())
1832 .filter(|status_entry| {
1833 section.contains(status_entry, &repo)
1834 && GitPanel::stage_status_for_entry(status_entry, &repo).as_bool()
1835 != Some(goal_staged_state)
1836 })
1837 .cloned()
1838 .collect::<Vec<_>>();
1839
1840 (goal_staged_state, entries)
1841 }
1842 GitListEntry::Directory(entry) => {
1843 let goal_staged_state = match self.stage_status_for_directory(entry, repo) {
1844 StageStatus::Staged => StageStatus::Unstaged,
1845 StageStatus::Unstaged | StageStatus::PartiallyStaged => StageStatus::Staged,
1846 };
1847 let goal_stage = goal_staged_state == StageStatus::Staged;
1848
1849 let entries = self
1850 .view_mode
1851 .tree_state()
1852 .and_then(|state| state.directory_descendants.get(&entry.key))
1853 .cloned()
1854 .unwrap_or_default()
1855 .into_iter()
1856 .filter(|status_entry| {
1857 GitPanel::stage_status_for_entry(status_entry, &repo)
1858 != goal_staged_state
1859 })
1860 .collect::<Vec<_>>();
1861 (goal_stage, entries)
1862 }
1863 }
1864 };
1865 if let Some(anchor) = clear_anchor {
1866 if let Some(op) = self.bulk_staging.clone()
1867 && op.anchor == anchor
1868 {
1869 self.bulk_staging = None;
1870 }
1871 }
1872 if let Some(anchor) = set_anchor {
1873 self.set_bulk_staging_anchor(anchor, cx);
1874 }
1875
1876 self.change_file_stage(stage, repo_paths, cx);
1877 }
1878
1879 fn change_file_stage(
1880 &mut self,
1881 stage: bool,
1882 entries: Vec<GitStatusEntry>,
1883 cx: &mut Context<Self>,
1884 ) {
1885 let Some(active_repository) = self.active_repository.clone() else {
1886 return;
1887 };
1888 cx.spawn({
1889 async move |this, cx| {
1890 let result = this
1891 .update(cx, |this, cx| {
1892 let task = active_repository.update(cx, |repo, cx| {
1893 let repo_paths = entries
1894 .iter()
1895 .map(|entry| entry.repo_path.clone())
1896 .collect();
1897 if stage {
1898 repo.stage_entries(repo_paths, cx)
1899 } else {
1900 repo.unstage_entries(repo_paths, cx)
1901 }
1902 });
1903 this.update_counts(active_repository.read(cx));
1904 cx.notify();
1905 task
1906 })?
1907 .await;
1908
1909 this.update(cx, |this, cx| {
1910 if let Err(err) = result {
1911 this.show_error_toast(if stage { "add" } else { "reset" }, err, cx);
1912 }
1913 cx.notify();
1914 })
1915 }
1916 })
1917 .detach();
1918 }
1919
1920 pub fn total_staged_count(&self) -> usize {
1921 self.tracked_staged_count + self.new_staged_count + self.conflicted_staged_count
1922 }
1923
1924 pub fn stash_pop(&mut self, _: &StashPop, _window: &mut Window, cx: &mut Context<Self>) {
1925 let Some(active_repository) = self.active_repository.clone() else {
1926 return;
1927 };
1928
1929 cx.spawn({
1930 async move |this, cx| {
1931 let stash_task = active_repository
1932 .update(cx, |repo, cx| repo.stash_pop(None, cx))
1933 .await;
1934 this.update(cx, |this, cx| {
1935 stash_task
1936 .map_err(|e| {
1937 this.show_error_toast("stash pop", e, cx);
1938 })
1939 .ok();
1940 cx.notify();
1941 })
1942 }
1943 })
1944 .detach();
1945 }
1946
1947 pub fn stash_apply(&mut self, _: &StashApply, _window: &mut Window, cx: &mut Context<Self>) {
1948 let Some(active_repository) = self.active_repository.clone() else {
1949 return;
1950 };
1951
1952 cx.spawn({
1953 async move |this, cx| {
1954 let stash_task = active_repository
1955 .update(cx, |repo, cx| repo.stash_apply(None, cx))
1956 .await;
1957 this.update(cx, |this, cx| {
1958 stash_task
1959 .map_err(|e| {
1960 this.show_error_toast("stash apply", e, cx);
1961 })
1962 .ok();
1963 cx.notify();
1964 })
1965 }
1966 })
1967 .detach();
1968 }
1969
1970 pub fn stash_all(&mut self, _: &StashAll, _window: &mut Window, cx: &mut Context<Self>) {
1971 let Some(active_repository) = self.active_repository.clone() else {
1972 return;
1973 };
1974
1975 cx.spawn({
1976 async move |this, cx| {
1977 let stash_task = active_repository
1978 .update(cx, |repo, cx| repo.stash_all(cx))
1979 .await;
1980 this.update(cx, |this, cx| {
1981 stash_task
1982 .map_err(|e| {
1983 this.show_error_toast("stash", e, cx);
1984 })
1985 .ok();
1986 cx.notify();
1987 })
1988 }
1989 })
1990 .detach();
1991 }
1992
1993 pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
1994 self.commit_editor
1995 .read(cx)
1996 .buffer()
1997 .read(cx)
1998 .as_singleton()
1999 .unwrap()
2000 }
2001
2002 fn toggle_staged_for_selected(
2003 &mut self,
2004 _: &git::ToggleStaged,
2005 window: &mut Window,
2006 cx: &mut Context<Self>,
2007 ) {
2008 if let Some(selected_entry) = self.get_selected_entry().cloned() {
2009 self.toggle_staged_for_entry(&selected_entry, window, cx);
2010 }
2011 }
2012
2013 fn stage_range(&mut self, _: &git::StageRange, _window: &mut Window, cx: &mut Context<Self>) {
2014 let Some(index) = self.selected_entry else {
2015 return;
2016 };
2017 self.stage_bulk(index, cx);
2018 }
2019
2020 fn stage_selected(&mut self, _: &git::StageFile, _window: &mut Window, cx: &mut Context<Self>) {
2021 let Some(selected_entry) = self.get_selected_entry() else {
2022 return;
2023 };
2024 let Some(status_entry) = selected_entry.status_entry() else {
2025 return;
2026 };
2027 if status_entry.staging != StageStatus::Staged {
2028 self.change_file_stage(true, vec![status_entry.clone()], cx);
2029 }
2030 }
2031
2032 fn unstage_selected(
2033 &mut self,
2034 _: &git::UnstageFile,
2035 _window: &mut Window,
2036 cx: &mut Context<Self>,
2037 ) {
2038 let Some(selected_entry) = self.get_selected_entry() else {
2039 return;
2040 };
2041 let Some(status_entry) = selected_entry.status_entry() else {
2042 return;
2043 };
2044 if status_entry.staging != StageStatus::Unstaged {
2045 self.change_file_stage(false, vec![status_entry.clone()], cx);
2046 }
2047 }
2048
2049 fn on_commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
2050 if self.commit(&self.commit_editor.focus_handle(cx), window, cx) {
2051 telemetry::event!("Git Committed", source = "Git Panel");
2052 }
2053 }
2054
2055 /// Commits staged changes with the current commit message.
2056 ///
2057 /// Returns `true` if the commit was executed, `false` otherwise.
2058 pub(crate) fn commit(
2059 &mut self,
2060 commit_editor_focus_handle: &FocusHandle,
2061 window: &mut Window,
2062 cx: &mut Context<Self>,
2063 ) -> bool {
2064 if self.amend_pending {
2065 return false;
2066 }
2067
2068 if commit_editor_focus_handle.contains_focused(window, cx) {
2069 self.commit_changes(
2070 CommitOptions {
2071 amend: false,
2072 signoff: self.signoff_enabled,
2073 },
2074 window,
2075 cx,
2076 );
2077 true
2078 } else {
2079 cx.propagate();
2080 false
2081 }
2082 }
2083
2084 fn on_amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
2085 if self.amend(&self.commit_editor.focus_handle(cx), window, cx) {
2086 telemetry::event!("Git Amended", source = "Git Panel");
2087 }
2088 }
2089
2090 /// Amends the most recent commit with staged changes and/or an updated commit message.
2091 ///
2092 /// Uses a two-stage workflow where the first invocation loads the commit
2093 /// message for editing, second invocation performs the amend. Returns
2094 /// `true` if the amend was executed, `false` otherwise.
2095 pub(crate) fn amend(
2096 &mut self,
2097 commit_editor_focus_handle: &FocusHandle,
2098 window: &mut Window,
2099 cx: &mut Context<Self>,
2100 ) -> bool {
2101 if commit_editor_focus_handle.contains_focused(window, cx) {
2102 if self.head_commit(cx).is_some() {
2103 if !self.amend_pending {
2104 self.set_amend_pending(true, cx);
2105 self.load_last_commit_message(cx);
2106
2107 return false;
2108 } else {
2109 self.commit_changes(
2110 CommitOptions {
2111 amend: true,
2112 signoff: self.signoff_enabled,
2113 },
2114 window,
2115 cx,
2116 );
2117
2118 return true;
2119 }
2120 }
2121 return false;
2122 } else {
2123 cx.propagate();
2124 return false;
2125 }
2126 }
2127 pub fn head_commit(&self, cx: &App) -> Option<CommitDetails> {
2128 self.active_repository
2129 .as_ref()
2130 .and_then(|repo| repo.read(cx).head_commit.as_ref())
2131 .cloned()
2132 }
2133
2134 pub fn load_last_commit_message(&mut self, cx: &mut Context<Self>) {
2135 let Some(head_commit) = self.head_commit(cx) else {
2136 return;
2137 };
2138
2139 let recent_sha = head_commit.sha.to_string();
2140 let detail_task = self.load_commit_details(recent_sha, cx);
2141 cx.spawn(async move |this, cx| {
2142 if let Ok(message) = detail_task.await.map(|detail| detail.message) {
2143 this.update(cx, |this, cx| {
2144 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2145 let start = buffer.anchor_before(0);
2146 let end = buffer.anchor_after(buffer.len());
2147 buffer.edit([(start..end, message)], None, cx);
2148 });
2149 })
2150 .log_err();
2151 }
2152 })
2153 .detach();
2154 }
2155
2156 fn custom_or_suggested_commit_message(
2157 &self,
2158 window: &mut Window,
2159 cx: &mut Context<Self>,
2160 ) -> Option<String> {
2161 let git_commit_language = self
2162 .commit_editor
2163 .read(cx)
2164 .language_at(MultiBufferOffset(0), cx);
2165 let message = self.commit_editor.read(cx).text(cx);
2166 if message.is_empty() {
2167 return self
2168 .suggest_commit_message(cx)
2169 .filter(|message| !message.trim().is_empty());
2170 } else if message.trim().is_empty() {
2171 return None;
2172 }
2173 let buffer = cx.new(|cx| {
2174 let mut buffer = Buffer::local(message, cx);
2175 buffer.set_language(git_commit_language, cx);
2176 buffer
2177 });
2178 let editor = cx.new(|cx| Editor::for_buffer(buffer, None, window, cx));
2179 let wrapped_message = editor.update(cx, |editor, cx| {
2180 editor.select_all(&Default::default(), window, cx);
2181 editor.rewrap_impl(
2182 RewrapOptions {
2183 override_language_settings: false,
2184 preserve_existing_whitespace: true,
2185 },
2186 cx,
2187 );
2188 editor.text(cx)
2189 });
2190 if wrapped_message.trim().is_empty() {
2191 return None;
2192 }
2193 Some(wrapped_message)
2194 }
2195
2196 fn has_commit_message(&self, cx: &mut Context<Self>) -> bool {
2197 let text = self.commit_editor.read(cx).text(cx);
2198 if !text.trim().is_empty() {
2199 true
2200 } else if text.is_empty() {
2201 self.suggest_commit_message(cx)
2202 .is_some_and(|text| !text.trim().is_empty())
2203 } else {
2204 false
2205 }
2206 }
2207
2208 pub(crate) fn commit_changes(
2209 &mut self,
2210 options: CommitOptions,
2211 window: &mut Window,
2212 cx: &mut Context<Self>,
2213 ) {
2214 let Some(active_repository) = self.active_repository.clone() else {
2215 return;
2216 };
2217 let error_spawn = |message, window: &mut Window, cx: &mut App| {
2218 let prompt = window.prompt(PromptLevel::Warning, message, None, &["Ok"], cx);
2219 cx.spawn(async move |_| {
2220 prompt.await.ok();
2221 })
2222 .detach();
2223 };
2224
2225 if self.has_unstaged_conflicts() {
2226 error_spawn(
2227 "There are still conflicts. You must stage these before committing",
2228 window,
2229 cx,
2230 );
2231 return;
2232 }
2233
2234 let askpass = self.askpass_delegate("git commit", window, cx);
2235 let commit_message = self.custom_or_suggested_commit_message(window, cx);
2236
2237 let Some(mut message) = commit_message else {
2238 self.commit_editor
2239 .read(cx)
2240 .focus_handle(cx)
2241 .focus(window, cx);
2242 return;
2243 };
2244
2245 if self.add_coauthors {
2246 self.fill_co_authors(&mut message, cx);
2247 }
2248
2249 let task = if self.has_staged_changes() {
2250 // Repository serializes all git operations, so we can just send a commit immediately
2251 let commit_task = active_repository.update(cx, |repo, cx| {
2252 repo.commit(message.into(), None, options, askpass, cx)
2253 });
2254 cx.background_spawn(async move { commit_task.await? })
2255 } else {
2256 let changed_files = self
2257 .entries
2258 .iter()
2259 .filter_map(|entry| entry.status_entry())
2260 .filter(|status_entry| !status_entry.status.is_created())
2261 .map(|status_entry| status_entry.repo_path.clone())
2262 .collect::<Vec<_>>();
2263
2264 if changed_files.is_empty() && !options.amend {
2265 error_spawn("No changes to commit", window, cx);
2266 return;
2267 }
2268
2269 let stage_task =
2270 active_repository.update(cx, |repo, cx| repo.stage_entries(changed_files, cx));
2271 cx.spawn(async move |_, cx| {
2272 stage_task.await?;
2273 let commit_task = active_repository.update(cx, |repo, cx| {
2274 repo.commit(message.into(), None, options, askpass, cx)
2275 });
2276 commit_task.await?
2277 })
2278 };
2279 let task = cx.spawn_in(window, async move |this, cx| {
2280 let result = task.await;
2281 this.update_in(cx, |this, window, cx| {
2282 this.pending_commit.take();
2283
2284 match result {
2285 Ok(()) => {
2286 if options.amend {
2287 this.set_amend_pending(false, cx);
2288 } else {
2289 this.commit_editor
2290 .update(cx, |editor, cx| editor.clear(window, cx));
2291 this.original_commit_message = None;
2292 }
2293 }
2294 Err(e) => this.show_error_toast("commit", e, cx),
2295 }
2296 })
2297 .ok();
2298 });
2299
2300 self.pending_commit = Some(task);
2301 }
2302
2303 pub(crate) fn uncommit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2304 let Some(repo) = self.active_repository.clone() else {
2305 return;
2306 };
2307 telemetry::event!("Git Uncommitted");
2308
2309 let confirmation = self.check_for_pushed_commits(window, cx);
2310 let prior_head = self.load_commit_details("HEAD".to_string(), cx);
2311
2312 let task = cx.spawn_in(window, async move |this, cx| {
2313 let result = maybe!(async {
2314 if let Ok(true) = confirmation.await {
2315 let prior_head = prior_head.await?;
2316
2317 repo.update(cx, |repo, cx| {
2318 repo.reset("HEAD^".to_string(), ResetMode::Soft, cx)
2319 })
2320 .await??;
2321
2322 Ok(Some(prior_head))
2323 } else {
2324 Ok(None)
2325 }
2326 })
2327 .await;
2328
2329 this.update_in(cx, |this, window, cx| {
2330 this.pending_commit.take();
2331 match result {
2332 Ok(None) => {}
2333 Ok(Some(prior_commit)) => {
2334 this.commit_editor.update(cx, |editor, cx| {
2335 editor.set_text(prior_commit.message, window, cx)
2336 });
2337 }
2338 Err(e) => this.show_error_toast("reset", e, cx),
2339 }
2340 })
2341 .ok();
2342 });
2343
2344 self.pending_commit = Some(task);
2345 }
2346
2347 fn check_for_pushed_commits(
2348 &mut self,
2349 window: &mut Window,
2350 cx: &mut Context<Self>,
2351 ) -> impl Future<Output = anyhow::Result<bool>> + use<> {
2352 let repo = self.active_repository.clone();
2353 let mut cx = window.to_async(cx);
2354
2355 async move {
2356 let repo = repo.context("No active repository")?;
2357
2358 let pushed_to: Vec<SharedString> = repo
2359 .update(&mut cx, |repo, _| repo.check_for_pushed_commits())
2360 .await??;
2361
2362 if pushed_to.is_empty() {
2363 Ok(true)
2364 } else {
2365 #[derive(strum::EnumIter, strum::VariantNames)]
2366 #[strum(serialize_all = "title_case")]
2367 enum CancelUncommit {
2368 Uncommit,
2369 Cancel,
2370 }
2371 let detail = format!(
2372 "This commit was already pushed to {}.",
2373 pushed_to.into_iter().join(", ")
2374 );
2375 let result = cx
2376 .update(|window, cx| prompt("Are you sure?", Some(&detail), window, cx))?
2377 .await?;
2378
2379 match result {
2380 CancelUncommit::Cancel => Ok(false),
2381 CancelUncommit::Uncommit => Ok(true),
2382 }
2383 }
2384 }
2385 }
2386
2387 /// Suggests a commit message based on the changed files and their statuses
2388 pub fn suggest_commit_message(&self, cx: &App) -> Option<String> {
2389 if let Some(merge_message) = self
2390 .active_repository
2391 .as_ref()
2392 .and_then(|repo| repo.read(cx).merge.message.as_ref())
2393 {
2394 return Some(merge_message.to_string());
2395 }
2396
2397 let git_status_entry = if let Some(staged_entry) = &self.single_staged_entry {
2398 Some(staged_entry)
2399 } else if self.total_staged_count() == 0
2400 && let Some(single_tracked_entry) = &self.single_tracked_entry
2401 {
2402 Some(single_tracked_entry)
2403 } else {
2404 None
2405 }?;
2406
2407 let action_text = if git_status_entry.status.is_deleted() {
2408 Some("Delete")
2409 } else if git_status_entry.status.is_created() {
2410 Some("Create")
2411 } else if git_status_entry.status.is_modified() {
2412 Some("Update")
2413 } else {
2414 None
2415 }?;
2416
2417 let file_name = git_status_entry
2418 .repo_path
2419 .file_name()
2420 .unwrap_or_default()
2421 .to_string();
2422
2423 Some(format!("{} {}", action_text, file_name))
2424 }
2425
2426 fn generate_commit_message_action(
2427 &mut self,
2428 _: &git::GenerateCommitMessage,
2429 _window: &mut Window,
2430 cx: &mut Context<Self>,
2431 ) {
2432 self.generate_commit_message(cx);
2433 }
2434
2435 fn split_patch(patch: &str) -> Vec<String> {
2436 let mut result = Vec::new();
2437 let mut current_patch = String::new();
2438
2439 for line in patch.lines() {
2440 if line.starts_with("---") && !current_patch.is_empty() {
2441 result.push(current_patch.trim_end_matches('\n').into());
2442 current_patch = String::new();
2443 }
2444 current_patch.push_str(line);
2445 current_patch.push('\n');
2446 }
2447
2448 if !current_patch.is_empty() {
2449 result.push(current_patch.trim_end_matches('\n').into());
2450 }
2451
2452 result
2453 }
2454 fn truncate_iteratively(patch: &str, max_bytes: usize) -> String {
2455 let mut current_size = patch.len();
2456 if current_size <= max_bytes {
2457 return patch.to_string();
2458 }
2459 let file_patches = Self::split_patch(patch);
2460 let mut file_infos: Vec<TruncatedPatch> = file_patches
2461 .iter()
2462 .filter_map(|patch| TruncatedPatch::from_unified_diff(patch))
2463 .collect();
2464
2465 if file_infos.is_empty() {
2466 return patch.to_string();
2467 }
2468
2469 current_size = file_infos.iter().map(|f| f.calculate_size()).sum::<usize>();
2470 while current_size > max_bytes {
2471 let file_idx = file_infos
2472 .iter()
2473 .enumerate()
2474 .filter(|(_, f)| f.hunks_to_keep > 1)
2475 .max_by_key(|(_, f)| f.hunks_to_keep)
2476 .map(|(idx, _)| idx);
2477 match file_idx {
2478 Some(idx) => {
2479 let file = &mut file_infos[idx];
2480 let size_before = file.calculate_size();
2481 file.hunks_to_keep -= 1;
2482 let size_after = file.calculate_size();
2483 let saved = size_before.saturating_sub(size_after);
2484 current_size = current_size.saturating_sub(saved);
2485 }
2486 None => {
2487 break;
2488 }
2489 }
2490 }
2491
2492 file_infos
2493 .iter()
2494 .map(|info| info.to_string())
2495 .collect::<Vec<_>>()
2496 .join("\n")
2497 }
2498
2499 pub fn compress_commit_diff(diff_text: &str, max_bytes: usize) -> String {
2500 if diff_text.len() <= max_bytes {
2501 return diff_text.to_string();
2502 }
2503
2504 let mut compressed = diff_text
2505 .lines()
2506 .map(|line| {
2507 if line.len() > 256 {
2508 format!("{}...[truncated]\n", &line[..line.floor_char_boundary(256)])
2509 } else {
2510 format!("{}\n", line)
2511 }
2512 })
2513 .collect::<Vec<_>>()
2514 .join("");
2515
2516 if compressed.len() <= max_bytes {
2517 return compressed;
2518 }
2519
2520 compressed = Self::truncate_iteratively(&compressed, max_bytes);
2521
2522 compressed
2523 }
2524
2525 async fn load_project_rules(
2526 project: &Entity<Project>,
2527 repo_work_dir: &Arc<Path>,
2528 cx: &mut AsyncApp,
2529 ) -> Option<String> {
2530 let rules_path = cx.update(|cx| {
2531 for worktree in project.read(cx).worktrees(cx) {
2532 let worktree_abs_path = worktree.read(cx).abs_path();
2533 if !worktree_abs_path.starts_with(&repo_work_dir) {
2534 continue;
2535 }
2536
2537 let worktree_snapshot = worktree.read(cx).snapshot();
2538 for rules_name in RULES_FILE_NAMES {
2539 if let Ok(rel_path) = RelPath::unix(rules_name) {
2540 if let Some(entry) = worktree_snapshot.entry_for_path(rel_path) {
2541 if entry.is_file() {
2542 return Some(ProjectPath {
2543 worktree_id: worktree.read(cx).id(),
2544 path: entry.path.clone(),
2545 });
2546 }
2547 }
2548 }
2549 }
2550 }
2551 None
2552 })?;
2553
2554 let buffer = project
2555 .update(cx, |project, cx| project.open_buffer(rules_path, cx))
2556 .await
2557 .ok()?;
2558
2559 let content = buffer
2560 .read_with(cx, |buffer, _| buffer.text())
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(cx: &mut AsyncApp) -> String {
2572 let load = async {
2573 let store = cx.update(|cx| PromptStore::global(cx)).await.ok()?;
2574 store
2575 .update(cx, |s, cx| {
2576 s.load(PromptId::BuiltIn(BuiltInPrompt::CommitMessage), cx)
2577 })
2578 .await
2579 .ok()
2580 };
2581 load.await
2582 .unwrap_or_else(|| BuiltInPrompt::CommitMessage.default_content().to_string())
2583 }
2584
2585 /// Generates a commit message using an LLM.
2586 pub fn generate_commit_message(&mut self, cx: &mut Context<Self>) {
2587 if !self.can_commit() || !AgentSettings::get_global(cx).enabled(cx) {
2588 return;
2589 }
2590
2591 let Some(ConfiguredModel { provider, model }) =
2592 LanguageModelRegistry::read_global(cx).commit_message_model()
2593 else {
2594 return;
2595 };
2596
2597 let Some(repo) = self.active_repository.as_ref() else {
2598 return;
2599 };
2600
2601 telemetry::event!("Git Commit Message Generated");
2602
2603 let diff = repo.update(cx, |repo, cx| {
2604 if self.has_staged_changes() {
2605 repo.diff(DiffType::HeadToIndex, cx)
2606 } else {
2607 repo.diff(DiffType::HeadToWorktree, cx)
2608 }
2609 });
2610
2611 let temperature = AgentSettings::temperature_for_model(&model, cx);
2612 let project = self.project.clone();
2613 let repo_work_dir = repo.read(cx).work_directory_abs_path.clone();
2614
2615 self.generate_commit_message_task = Some(cx.spawn(async move |this, mut cx| {
2616 async move {
2617 let _defer = cx.on_drop(&this, |this, _cx| {
2618 this.generate_commit_message_task.take();
2619 });
2620
2621 if let Some(task) = cx.update(|cx| {
2622 if !provider.is_authenticated(cx) {
2623 Some(provider.authenticate(cx))
2624 } else {
2625 None
2626 }
2627 }) {
2628 task.await.log_err();
2629 }
2630
2631 let mut diff_text = match diff.await {
2632 Ok(result) => match result {
2633 Ok(text) => text,
2634 Err(e) => {
2635 Self::show_commit_message_error(&this, &e, cx);
2636 return anyhow::Ok(());
2637 }
2638 },
2639 Err(e) => {
2640 Self::show_commit_message_error(&this, &e, cx);
2641 return anyhow::Ok(());
2642 }
2643 };
2644
2645 const MAX_DIFF_BYTES: usize = 20_000;
2646 diff_text = Self::compress_commit_diff(&diff_text, MAX_DIFF_BYTES);
2647
2648 let rules_content = Self::load_project_rules(&project, &repo_work_dir, &mut cx).await;
2649
2650 let prompt = Self::load_commit_message_prompt(&mut cx).await;
2651
2652 let subject = this.update(cx, |this, cx| {
2653 this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default()
2654 })?;
2655
2656 let text_empty = subject.trim().is_empty();
2657
2658 let rules_section = match &rules_content {
2659 Some(rules) => format!(
2660 "\n\nThe user has provided the following project rules that you should follow when writing the commit message:\n\
2661 <project_rules>\n{rules}\n</project_rules>\n"
2662 ),
2663 None => String::new(),
2664 };
2665
2666 let subject_section = if text_empty {
2667 String::new()
2668 } else {
2669 format!("\nHere is the user's subject line:\n{subject}")
2670 };
2671
2672 let content = format!(
2673 "{prompt}{rules_section}{subject_section}\nHere are the changes in this commit:\n{diff_text}"
2674 );
2675
2676 let request = LanguageModelRequest {
2677 thread_id: None,
2678 prompt_id: None,
2679 intent: Some(CompletionIntent::GenerateGitCommitMessage),
2680 messages: vec![LanguageModelRequestMessage {
2681 role: Role::User,
2682 content: vec![content.into()],
2683 cache: false,
2684 reasoning_details: None,
2685 }],
2686 tools: Vec::new(),
2687 tool_choice: None,
2688 stop: Vec::new(),
2689 temperature,
2690 thinking_allowed: false,
2691 bypass_rate_limit: false,
2692 };
2693
2694 let stream = model.stream_completion_text(request, cx);
2695 match stream.await {
2696 Ok(mut messages) => {
2697 if !text_empty {
2698 this.update(cx, |this, cx| {
2699 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2700 let insert_position = buffer.anchor_before(buffer.len());
2701 buffer.edit([(insert_position..insert_position, "\n")], None, cx)
2702 });
2703 })?;
2704 }
2705
2706 while let Some(message) = messages.stream.next().await {
2707 match message {
2708 Ok(text) => {
2709 this.update(cx, |this, cx| {
2710 this.commit_message_buffer(cx).update(cx, |buffer, cx| {
2711 let insert_position = buffer.anchor_before(buffer.len());
2712 buffer.edit([(insert_position..insert_position, text)], None, cx);
2713 });
2714 })?;
2715 }
2716 Err(e) => {
2717 Self::show_commit_message_error(&this, &e, cx);
2718 break;
2719 }
2720 }
2721 }
2722 }
2723 Err(e) => {
2724 Self::show_commit_message_error(&this, &e, cx);
2725 }
2726 }
2727
2728 anyhow::Ok(())
2729 }
2730 .log_err().await
2731 }));
2732 }
2733
2734 fn get_fetch_options(
2735 &self,
2736 window: &mut Window,
2737 cx: &mut Context<Self>,
2738 ) -> Task<Option<FetchOptions>> {
2739 let repo = self.active_repository.clone();
2740 let workspace = self.workspace.clone();
2741
2742 cx.spawn_in(window, async move |_, cx| {
2743 let repo = repo?;
2744 let remotes = repo
2745 .update(cx, |repo, _| repo.get_remotes(None, false))
2746 .await
2747 .ok()?
2748 .log_err()?;
2749
2750 let mut remotes: Vec<_> = remotes.into_iter().map(FetchOptions::Remote).collect();
2751 if remotes.len() > 1 {
2752 remotes.push(FetchOptions::All);
2753 }
2754 let selection = cx
2755 .update(|window, cx| {
2756 picker_prompt::prompt(
2757 "Pick which remote to fetch",
2758 remotes.iter().map(|r| r.name()).collect(),
2759 workspace,
2760 window,
2761 cx,
2762 )
2763 })
2764 .ok()?
2765 .await?;
2766 remotes.get(selection).cloned()
2767 })
2768 }
2769
2770 pub(crate) fn fetch(
2771 &mut self,
2772 is_fetch_all: bool,
2773 window: &mut Window,
2774 cx: &mut Context<Self>,
2775 ) {
2776 if !self.can_push_and_pull(cx) {
2777 return;
2778 }
2779
2780 let Some(repo) = self.active_repository.clone() else {
2781 return;
2782 };
2783 telemetry::event!("Git Fetched");
2784 let askpass = self.askpass_delegate("git fetch", window, cx);
2785 let this = cx.weak_entity();
2786
2787 let fetch_options = if is_fetch_all {
2788 Task::ready(Some(FetchOptions::All))
2789 } else {
2790 self.get_fetch_options(window, cx)
2791 };
2792
2793 window
2794 .spawn(cx, async move |cx| {
2795 let Some(fetch_options) = fetch_options.await else {
2796 return Ok(());
2797 };
2798 let fetch = repo.update(cx, |repo, cx| {
2799 repo.fetch(fetch_options.clone(), askpass, cx)
2800 });
2801
2802 let remote_message = fetch.await?;
2803 this.update(cx, |this, cx| {
2804 let action = match fetch_options {
2805 FetchOptions::All => RemoteAction::Fetch(None),
2806 FetchOptions::Remote(remote) => RemoteAction::Fetch(Some(remote)),
2807 };
2808 match remote_message {
2809 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2810 Err(e) => {
2811 log::error!("Error while fetching {:?}", e);
2812 this.show_error_toast(action.name(), e, cx)
2813 }
2814 }
2815
2816 anyhow::Ok(())
2817 })
2818 .ok();
2819 anyhow::Ok(())
2820 })
2821 .detach_and_log_err(cx);
2822 }
2823
2824 pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
2825 let workspace = self.workspace.clone();
2826
2827 crate::clone::clone_and_open(
2828 repo.into(),
2829 workspace,
2830 window,
2831 cx,
2832 Arc::new(|_workspace: &mut workspace::Workspace, _window, _cx| {}),
2833 );
2834 }
2835
2836 pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
2837 let worktrees = self
2838 .project
2839 .read(cx)
2840 .visible_worktrees(cx)
2841 .collect::<Vec<_>>();
2842
2843 let worktree = if worktrees.len() == 1 {
2844 Task::ready(Some(worktrees.first().unwrap().clone()))
2845 } else if worktrees.is_empty() {
2846 let result = window.prompt(
2847 PromptLevel::Warning,
2848 "Unable to initialize a git repository",
2849 Some("Open a directory first"),
2850 &["Ok"],
2851 cx,
2852 );
2853 cx.background_executor()
2854 .spawn(async move {
2855 result.await.ok();
2856 })
2857 .detach();
2858 return;
2859 } else {
2860 let worktree_directories = worktrees
2861 .iter()
2862 .map(|worktree| worktree.read(cx).abs_path())
2863 .map(|worktree_abs_path| {
2864 if let Ok(path) = worktree_abs_path.strip_prefix(util::paths::home_dir()) {
2865 Path::new("~")
2866 .join(path)
2867 .to_string_lossy()
2868 .to_string()
2869 .into()
2870 } else {
2871 worktree_abs_path.to_string_lossy().into_owned().into()
2872 }
2873 })
2874 .collect_vec();
2875 let prompt = picker_prompt::prompt(
2876 "Where would you like to initialize this git repository?",
2877 worktree_directories,
2878 self.workspace.clone(),
2879 window,
2880 cx,
2881 );
2882
2883 cx.spawn(async move |_, _| prompt.await.map(|ix| worktrees[ix].clone()))
2884 };
2885
2886 cx.spawn_in(window, async move |this, cx| {
2887 let worktree = match worktree.await {
2888 Some(worktree) => worktree,
2889 None => {
2890 return;
2891 }
2892 };
2893
2894 let Ok(result) = this.update(cx, |this, cx| {
2895 let fallback_branch_name = GitPanelSettings::get_global(cx)
2896 .fallback_branch_name
2897 .clone();
2898 this.project.read(cx).git_init(
2899 worktree.read(cx).abs_path(),
2900 fallback_branch_name,
2901 cx,
2902 )
2903 }) else {
2904 return;
2905 };
2906
2907 let result = result.await;
2908
2909 this.update_in(cx, |this, _, cx| match result {
2910 Ok(()) => {}
2911 Err(e) => this.show_error_toast("init", e, cx),
2912 })
2913 .ok();
2914 })
2915 .detach();
2916 }
2917
2918 pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context<Self>) {
2919 if !self.can_push_and_pull(cx) {
2920 return;
2921 }
2922 let Some(repo) = self.active_repository.clone() else {
2923 return;
2924 };
2925 let Some(branch) = repo.read(cx).branch.as_ref() else {
2926 return;
2927 };
2928 telemetry::event!("Git Pulled");
2929 let branch = branch.clone();
2930 let remote = self.get_remote(false, false, window, cx);
2931 cx.spawn_in(window, async move |this, cx| {
2932 let remote = match remote.await {
2933 Ok(Some(remote)) => remote,
2934 Ok(None) => {
2935 return Ok(());
2936 }
2937 Err(e) => {
2938 log::error!("Failed to get current remote: {}", e);
2939 this.update(cx, |this, cx| this.show_error_toast("pull", e, cx))
2940 .ok();
2941 return Ok(());
2942 }
2943 };
2944
2945 let askpass = this.update_in(cx, |this, window, cx| {
2946 this.askpass_delegate(format!("git pull {}", remote.name), window, cx)
2947 })?;
2948
2949 let branch_name = branch
2950 .upstream
2951 .is_none()
2952 .then(|| branch.name().to_owned().into());
2953
2954 let pull = repo.update(cx, |repo, cx| {
2955 repo.pull(branch_name, remote.name.clone(), rebase, askpass, cx)
2956 });
2957
2958 let remote_message = pull.await?;
2959
2960 let action = RemoteAction::Pull(remote);
2961 this.update(cx, |this, cx| match remote_message {
2962 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
2963 Err(e) => {
2964 log::error!("Error while pulling {:?}", e);
2965 this.show_error_toast(action.name(), e, cx)
2966 }
2967 })
2968 .ok();
2969
2970 anyhow::Ok(())
2971 })
2972 .detach_and_log_err(cx);
2973 }
2974
2975 pub(crate) fn push(
2976 &mut self,
2977 force_push: bool,
2978 select_remote: bool,
2979 window: &mut Window,
2980 cx: &mut Context<Self>,
2981 ) {
2982 if !self.can_push_and_pull(cx) {
2983 return;
2984 }
2985 let Some(repo) = self.active_repository.clone() else {
2986 return;
2987 };
2988 let Some(branch) = repo.read(cx).branch.as_ref() else {
2989 return;
2990 };
2991 telemetry::event!("Git Pushed");
2992 let branch = branch.clone();
2993
2994 let options = if force_push {
2995 Some(PushOptions::Force)
2996 } else {
2997 match branch.upstream {
2998 Some(Upstream {
2999 tracking: UpstreamTracking::Gone,
3000 ..
3001 })
3002 | None => Some(PushOptions::SetUpstream),
3003 _ => None,
3004 }
3005 };
3006 let remote = self.get_remote(select_remote, true, window, cx);
3007
3008 cx.spawn_in(window, async move |this, cx| {
3009 let remote = match remote.await {
3010 Ok(Some(remote)) => remote,
3011 Ok(None) => {
3012 return Ok(());
3013 }
3014 Err(e) => {
3015 log::error!("Failed to get current remote: {}", e);
3016 this.update(cx, |this, cx| this.show_error_toast("push", e, cx))
3017 .ok();
3018 return Ok(());
3019 }
3020 };
3021
3022 let askpass_delegate = this.update_in(cx, |this, window, cx| {
3023 this.askpass_delegate(format!("git push {}", remote.name), window, cx)
3024 })?;
3025
3026 let push = repo.update(cx, |repo, cx| {
3027 repo.push(
3028 branch.name().to_owned().into(),
3029 branch
3030 .upstream
3031 .as_ref()
3032 .filter(|u| matches!(u.tracking, UpstreamTracking::Tracked(_)))
3033 .and_then(|u| u.branch_name())
3034 .unwrap_or_else(|| branch.name())
3035 .to_owned()
3036 .into(),
3037 remote.name.clone(),
3038 options,
3039 askpass_delegate,
3040 cx,
3041 )
3042 });
3043
3044 let remote_output = push.await?;
3045
3046 let action = RemoteAction::Push(branch.name().to_owned().into(), remote);
3047 this.update(cx, |this, cx| match remote_output {
3048 Ok(remote_message) => this.show_remote_output(action, remote_message, cx),
3049 Err(e) => {
3050 log::error!("Error while pushing {:?}", e);
3051 this.show_error_toast(action.name(), e, cx)
3052 }
3053 })?;
3054
3055 anyhow::Ok(())
3056 })
3057 .detach_and_log_err(cx);
3058 }
3059
3060 pub fn create_pull_request(&self, window: &mut Window, cx: &mut Context<Self>) {
3061 let result = (|| -> anyhow::Result<()> {
3062 let repo = self
3063 .active_repository
3064 .clone()
3065 .ok_or_else(|| anyhow::anyhow!("No active repository"))?;
3066
3067 let (branch, remote_origin, remote_upstream) = {
3068 let repository = repo.read(cx);
3069 (
3070 repository.branch.clone(),
3071 repository.remote_origin_url.clone(),
3072 repository.remote_upstream_url.clone(),
3073 )
3074 };
3075
3076 let branch = branch.ok_or_else(|| anyhow::anyhow!("No active branch"))?;
3077 let source_branch = branch
3078 .upstream
3079 .as_ref()
3080 .filter(|upstream| matches!(upstream.tracking, UpstreamTracking::Tracked(_)))
3081 .and_then(|upstream| upstream.branch_name())
3082 .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3083 let source_branch = source_branch.to_string();
3084
3085 let remote_url = branch
3086 .upstream
3087 .as_ref()
3088 .and_then(|upstream| match upstream.remote_name() {
3089 Some("upstream") => remote_upstream.as_deref(),
3090 Some(_) => remote_origin.as_deref(),
3091 None => None,
3092 })
3093 .or(remote_origin.as_deref())
3094 .or(remote_upstream.as_deref())
3095 .ok_or_else(|| anyhow::anyhow!("No remote configured for repository"))?;
3096 let remote_url = remote_url.to_string();
3097
3098 let provider_registry = GitHostingProviderRegistry::global(cx);
3099 let Some((provider, parsed_remote)) =
3100 git::parse_git_remote_url(provider_registry, &remote_url)
3101 else {
3102 return Err(anyhow::anyhow!("Unsupported remote URL: {}", remote_url));
3103 };
3104
3105 let Some(url) = provider.build_create_pull_request_url(&parsed_remote, &source_branch)
3106 else {
3107 return Err(anyhow::anyhow!("Unable to construct pull request URL"));
3108 };
3109
3110 cx.open_url(url.as_str());
3111 Ok(())
3112 })();
3113
3114 if let Err(err) = result {
3115 log::error!("Error while creating pull request {:?}", err);
3116 cx.defer_in(window, |panel, _window, cx| {
3117 panel.show_error_toast("create pull request", err, cx);
3118 });
3119 }
3120 }
3121
3122 fn askpass_delegate(
3123 &self,
3124 operation: impl Into<SharedString>,
3125 window: &mut Window,
3126 cx: &mut Context<Self>,
3127 ) -> AskPassDelegate {
3128 let this = cx.weak_entity();
3129 let operation = operation.into();
3130 let window = window.window_handle();
3131 AskPassDelegate::new(&mut cx.to_async(), move |prompt, tx, cx| {
3132 window
3133 .update(cx, |_, window, cx| {
3134 this.update(cx, |this, cx| {
3135 this.workspace.update(cx, |workspace, cx| {
3136 workspace.toggle_modal(window, cx, |window, cx| {
3137 AskPassModal::new(operation.clone(), prompt.into(), tx, window, cx)
3138 });
3139 })
3140 })
3141 })
3142 .ok();
3143 })
3144 }
3145
3146 fn can_push_and_pull(&self, cx: &App) -> bool {
3147 !self.project.read(cx).is_via_collab()
3148 }
3149
3150 fn get_remote(
3151 &mut self,
3152 always_select: bool,
3153 is_push: bool,
3154 window: &mut Window,
3155 cx: &mut Context<Self>,
3156 ) -> impl Future<Output = anyhow::Result<Option<Remote>>> + use<> {
3157 let repo = self.active_repository.clone();
3158 let workspace = self.workspace.clone();
3159 let mut cx = window.to_async(cx);
3160
3161 async move {
3162 let repo = repo.context("No active repository")?;
3163 let current_remotes: Vec<Remote> = repo
3164 .update(&mut cx, |repo, _| {
3165 let current_branch = if always_select {
3166 None
3167 } else {
3168 let current_branch = repo.branch.as_ref().context("No active branch")?;
3169 Some(current_branch.name().to_string())
3170 };
3171 anyhow::Ok(repo.get_remotes(current_branch, is_push))
3172 })?
3173 .await??;
3174
3175 let current_remotes: Vec<_> = current_remotes
3176 .into_iter()
3177 .map(|remotes| remotes.name)
3178 .collect();
3179 let selection = cx
3180 .update(|window, cx| {
3181 picker_prompt::prompt(
3182 "Pick which remote to push to",
3183 current_remotes.clone(),
3184 workspace,
3185 window,
3186 cx,
3187 )
3188 })?
3189 .await;
3190
3191 Ok(selection.map(|selection| Remote {
3192 name: current_remotes[selection].clone(),
3193 }))
3194 }
3195 }
3196
3197 pub fn load_local_committer(&mut self, cx: &Context<Self>) {
3198 if self.local_committer_task.is_none() {
3199 self.local_committer_task = Some(cx.spawn(async move |this, cx| {
3200 let committer = get_git_committer(cx).await;
3201 this.update(cx, |this, cx| {
3202 this.local_committer = Some(committer);
3203 cx.notify()
3204 })
3205 .ok();
3206 }));
3207 }
3208 }
3209
3210 fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
3211 let mut new_co_authors = Vec::new();
3212 let project = self.project.read(cx);
3213
3214 let Some(room) = self
3215 .workspace
3216 .upgrade()
3217 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned())
3218 else {
3219 return Vec::default();
3220 };
3221
3222 let room = room.read(cx);
3223
3224 for (peer_id, collaborator) in project.collaborators() {
3225 if collaborator.is_host {
3226 continue;
3227 }
3228
3229 let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
3230 continue;
3231 };
3232 if !participant.can_write() {
3233 continue;
3234 }
3235 if let Some(email) = &collaborator.committer_email {
3236 let name = collaborator
3237 .committer_name
3238 .clone()
3239 .or_else(|| participant.user.name.clone())
3240 .unwrap_or_else(|| participant.user.github_login.clone().to_string());
3241 new_co_authors.push((name.clone(), email.clone()))
3242 }
3243 }
3244 if !project.is_local()
3245 && !project.is_read_only(cx)
3246 && let Some(local_committer) = self.local_committer(room, cx)
3247 {
3248 new_co_authors.push(local_committer);
3249 }
3250 new_co_authors
3251 }
3252
3253 fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
3254 let user = room.local_participant_user(cx)?;
3255 let committer = self.local_committer.as_ref()?;
3256 let email = committer.email.clone()?;
3257 let name = committer
3258 .name
3259 .clone()
3260 .or_else(|| user.name.clone())
3261 .unwrap_or_else(|| user.github_login.clone().to_string());
3262 Some((name, email))
3263 }
3264
3265 fn toggle_fill_co_authors(
3266 &mut self,
3267 _: &ToggleFillCoAuthors,
3268 _: &mut Window,
3269 cx: &mut Context<Self>,
3270 ) {
3271 self.add_coauthors = !self.add_coauthors;
3272 cx.notify();
3273 }
3274
3275 fn toggle_sort_by_path(
3276 &mut self,
3277 _: &ToggleSortByPath,
3278 _: &mut Window,
3279 cx: &mut Context<Self>,
3280 ) {
3281 let current_setting = GitPanelSettings::get_global(cx).sort_by_path;
3282 if let Some(workspace) = self.workspace.upgrade() {
3283 let workspace = workspace.read(cx);
3284 let fs = workspace.app_state().fs.clone();
3285 cx.update_global::<SettingsStore, _>(|store, _cx| {
3286 store.update_settings_file(fs, move |settings, _cx| {
3287 settings.git_panel.get_or_insert_default().sort_by_path =
3288 Some(!current_setting);
3289 });
3290 });
3291 }
3292 }
3293
3294 fn toggle_tree_view(&mut self, _: &ToggleTreeView, _: &mut Window, cx: &mut Context<Self>) {
3295 let current_setting = GitPanelSettings::get_global(cx).tree_view;
3296 if let Some(workspace) = self.workspace.upgrade() {
3297 let workspace = workspace.read(cx);
3298 let fs = workspace.app_state().fs.clone();
3299 cx.update_global::<SettingsStore, _>(|store, _cx| {
3300 store.update_settings_file(fs, move |settings, _cx| {
3301 settings.git_panel.get_or_insert_default().tree_view = Some(!current_setting);
3302 });
3303 })
3304 }
3305 }
3306
3307 fn toggle_directory(&mut self, key: &TreeKey, window: &mut Window, cx: &mut Context<Self>) {
3308 if let Some(state) = self.view_mode.tree_state_mut() {
3309 let expanded = state.expanded_dirs.entry(key.clone()).or_insert(true);
3310 *expanded = !*expanded;
3311 self.update_visible_entries(window, cx);
3312 } else {
3313 util::debug_panic!("Attempted to toggle directory in flat Git Panel state");
3314 }
3315 }
3316
3317 fn fill_co_authors(&mut self, message: &mut String, cx: &mut Context<Self>) {
3318 const CO_AUTHOR_PREFIX: &str = "Co-authored-by: ";
3319
3320 let existing_text = message.to_ascii_lowercase();
3321 let lowercase_co_author_prefix = CO_AUTHOR_PREFIX.to_lowercase();
3322 let mut ends_with_co_authors = false;
3323 let existing_co_authors = existing_text
3324 .lines()
3325 .filter_map(|line| {
3326 let line = line.trim();
3327 if line.starts_with(&lowercase_co_author_prefix) {
3328 ends_with_co_authors = true;
3329 Some(line)
3330 } else {
3331 ends_with_co_authors = false;
3332 None
3333 }
3334 })
3335 .collect::<HashSet<_>>();
3336
3337 let new_co_authors = self
3338 .potential_co_authors(cx)
3339 .into_iter()
3340 .filter(|(_, email)| {
3341 !existing_co_authors
3342 .iter()
3343 .any(|existing| existing.contains(email.as_str()))
3344 })
3345 .collect::<Vec<_>>();
3346
3347 if new_co_authors.is_empty() {
3348 return;
3349 }
3350
3351 if !ends_with_co_authors {
3352 message.push('\n');
3353 }
3354 for (name, email) in new_co_authors {
3355 message.push('\n');
3356 message.push_str(CO_AUTHOR_PREFIX);
3357 message.push_str(&name);
3358 message.push_str(" <");
3359 message.push_str(&email);
3360 message.push('>');
3361 }
3362 message.push('\n');
3363 }
3364
3365 fn schedule_update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3366 let handle = cx.entity().downgrade();
3367 self.reopen_commit_buffer(window, cx);
3368 self.update_visible_entries_task = cx.spawn_in(window, async move |_, cx| {
3369 cx.background_executor().timer(UPDATE_DEBOUNCE).await;
3370 if let Some(git_panel) = handle.upgrade() {
3371 git_panel
3372 .update_in(cx, |git_panel, window, cx| {
3373 git_panel.update_visible_entries(window, cx);
3374 })
3375 .ok();
3376 }
3377 });
3378 }
3379
3380 fn reopen_commit_buffer(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3381 let Some(active_repo) = self.active_repository.as_ref() else {
3382 return;
3383 };
3384 let load_buffer = active_repo.update(cx, |active_repo, cx| {
3385 let project = self.project.read(cx);
3386 active_repo.open_commit_buffer(
3387 Some(project.languages().clone()),
3388 project.buffer_store().clone(),
3389 cx,
3390 )
3391 });
3392
3393 cx.spawn_in(window, async move |git_panel, cx| {
3394 let buffer = load_buffer.await?;
3395 git_panel.update_in(cx, |git_panel, window, cx| {
3396 if git_panel
3397 .commit_editor
3398 .read(cx)
3399 .buffer()
3400 .read(cx)
3401 .as_singleton()
3402 .as_ref()
3403 != Some(&buffer)
3404 {
3405 git_panel.commit_editor = cx.new(|cx| {
3406 commit_message_editor(
3407 buffer,
3408 git_panel.suggest_commit_message(cx).map(SharedString::from),
3409 git_panel.project.clone(),
3410 true,
3411 window,
3412 cx,
3413 )
3414 });
3415 }
3416 })
3417 })
3418 .detach_and_log_err(cx);
3419 }
3420
3421 fn update_visible_entries(&mut self, window: &mut Window, cx: &mut Context<Self>) {
3422 let path_style = self.project.read(cx).path_style(cx);
3423 let bulk_staging = self.bulk_staging.take();
3424 let last_staged_path_prev_index = bulk_staging
3425 .as_ref()
3426 .and_then(|op| self.entry_by_path(&op.anchor));
3427
3428 self.active_repository = self.project.read(cx).active_repository(cx);
3429 self.entries.clear();
3430 self.entries_indices.clear();
3431 self.single_staged_entry.take();
3432 self.single_tracked_entry.take();
3433 self.conflicted_count = 0;
3434 self.conflicted_staged_count = 0;
3435 self.changes_count = 0;
3436 self.new_count = 0;
3437 self.tracked_count = 0;
3438 self.new_staged_count = 0;
3439 self.tracked_staged_count = 0;
3440 self.entry_count = 0;
3441 self.max_width_item_index = None;
3442
3443 let sort_by_path = GitPanelSettings::get_global(cx).sort_by_path;
3444 let is_tree_view = matches!(self.view_mode, GitPanelViewMode::Tree(_));
3445 let group_by_status = is_tree_view || !sort_by_path;
3446
3447 let mut changed_entries = Vec::new();
3448 let mut new_entries = Vec::new();
3449 let mut conflict_entries = Vec::new();
3450 let mut single_staged_entry = None;
3451 let mut staged_count = 0;
3452 let mut seen_directories = HashSet::default();
3453 let mut max_width_estimate = 0usize;
3454 let mut max_width_item_index = None;
3455
3456 let Some(repo) = self.active_repository.as_ref() else {
3457 // Just clear entries if no repository is active.
3458 cx.notify();
3459 return;
3460 };
3461
3462 let repo = repo.read(cx);
3463
3464 self.stash_entries = repo.cached_stash();
3465
3466 for entry in repo.cached_status() {
3467 self.changes_count += 1;
3468 let is_conflict = repo.had_conflict_on_last_merge_head_change(&entry.repo_path);
3469 let is_new = entry.status.is_created();
3470 let staging = entry.status.staging();
3471
3472 if let Some(pending) = repo.pending_ops_for_path(&entry.repo_path)
3473 && pending
3474 .ops
3475 .iter()
3476 .any(|op| op.git_status == pending_op::GitStatus::Reverted && op.finished())
3477 {
3478 continue;
3479 }
3480
3481 let entry = GitStatusEntry {
3482 repo_path: entry.repo_path.clone(),
3483 status: entry.status,
3484 staging,
3485 };
3486
3487 if staging.has_staged() {
3488 staged_count += 1;
3489 single_staged_entry = Some(entry.clone());
3490 }
3491
3492 if group_by_status && is_conflict {
3493 conflict_entries.push(entry);
3494 } else if group_by_status && is_new {
3495 new_entries.push(entry);
3496 } else {
3497 changed_entries.push(entry);
3498 }
3499 }
3500
3501 if conflict_entries.is_empty() {
3502 if staged_count == 1
3503 && let Some(entry) = single_staged_entry.as_ref()
3504 {
3505 if let Some(ops) = repo.pending_ops_for_path(&entry.repo_path) {
3506 if ops.staged() {
3507 self.single_staged_entry = single_staged_entry;
3508 }
3509 } else {
3510 self.single_staged_entry = single_staged_entry;
3511 }
3512 } else if repo.pending_ops_summary().item_summary.staging_count == 1
3513 && let Some(ops) = repo.pending_ops().find(|ops| ops.staging())
3514 {
3515 self.single_staged_entry =
3516 repo.status_for_path(&ops.repo_path)
3517 .map(|status| GitStatusEntry {
3518 repo_path: ops.repo_path.clone(),
3519 status: status.status,
3520 staging: StageStatus::Staged,
3521 });
3522 }
3523 }
3524
3525 if conflict_entries.is_empty() && changed_entries.len() == 1 {
3526 self.single_tracked_entry = changed_entries.first().cloned();
3527 }
3528
3529 let mut push_entry =
3530 |this: &mut Self,
3531 entry: GitListEntry,
3532 is_visible: bool,
3533 logical_indices: Option<&mut Vec<usize>>| {
3534 if let Some(estimate) =
3535 this.width_estimate_for_list_entry(is_tree_view, &entry, path_style)
3536 {
3537 if estimate > max_width_estimate {
3538 max_width_estimate = estimate;
3539 max_width_item_index = Some(this.entries.len());
3540 }
3541 }
3542
3543 if let Some(repo_path) = entry.status_entry().map(|status| status.repo_path.clone())
3544 {
3545 this.entries_indices.insert(repo_path, this.entries.len());
3546 }
3547
3548 if let (Some(indices), true) = (logical_indices, is_visible) {
3549 indices.push(this.entries.len());
3550 }
3551
3552 this.entries.push(entry);
3553 };
3554
3555 macro_rules! take_section_entries {
3556 () => {
3557 [
3558 (Section::Conflict, std::mem::take(&mut conflict_entries)),
3559 (Section::Tracked, std::mem::take(&mut changed_entries)),
3560 (Section::New, std::mem::take(&mut new_entries)),
3561 ]
3562 };
3563 }
3564
3565 match &mut self.view_mode {
3566 GitPanelViewMode::Tree(tree_state) => {
3567 tree_state.logical_indices.clear();
3568 tree_state.directory_descendants.clear();
3569
3570 // This is just to get around the borrow checker
3571 // because push_entry mutably borrows self
3572 let mut tree_state = std::mem::take(tree_state);
3573
3574 for (section, entries) in take_section_entries!() {
3575 if entries.is_empty() {
3576 continue;
3577 }
3578
3579 push_entry(
3580 self,
3581 GitListEntry::Header(GitHeaderEntry { header: section }),
3582 true,
3583 Some(&mut tree_state.logical_indices),
3584 );
3585
3586 for (entry, is_visible) in
3587 tree_state.build_tree_entries(section, entries, &mut seen_directories)
3588 {
3589 push_entry(
3590 self,
3591 entry,
3592 is_visible,
3593 Some(&mut tree_state.logical_indices),
3594 );
3595 }
3596 }
3597
3598 tree_state
3599 .expanded_dirs
3600 .retain(|key, _| seen_directories.contains(key));
3601 self.view_mode = GitPanelViewMode::Tree(tree_state);
3602 }
3603 GitPanelViewMode::Flat => {
3604 for (section, entries) in take_section_entries!() {
3605 if entries.is_empty() {
3606 continue;
3607 }
3608
3609 if section != Section::Tracked || !sort_by_path {
3610 push_entry(
3611 self,
3612 GitListEntry::Header(GitHeaderEntry { header: section }),
3613 true,
3614 None,
3615 );
3616 }
3617
3618 for entry in entries {
3619 push_entry(self, GitListEntry::Status(entry), true, None);
3620 }
3621 }
3622 }
3623 }
3624
3625 self.max_width_item_index = max_width_item_index;
3626
3627 self.update_counts(repo);
3628
3629 let bulk_staging_anchor_new_index = bulk_staging
3630 .as_ref()
3631 .filter(|op| op.repo_id == repo.id)
3632 .and_then(|op| self.entry_by_path(&op.anchor));
3633 if bulk_staging_anchor_new_index == last_staged_path_prev_index
3634 && let Some(index) = bulk_staging_anchor_new_index
3635 && let Some(entry) = self.entries.get(index)
3636 && let Some(entry) = entry.status_entry()
3637 && GitPanel::stage_status_for_entry(entry, &repo)
3638 .as_bool()
3639 .unwrap_or(false)
3640 {
3641 self.bulk_staging = bulk_staging;
3642 }
3643
3644 self.select_first_entry_if_none(window, cx);
3645
3646 let suggested_commit_message = self.suggest_commit_message(cx);
3647 let placeholder_text = suggested_commit_message.unwrap_or("Enter commit message".into());
3648
3649 self.commit_editor.update(cx, |editor, cx| {
3650 editor.set_placeholder_text(&placeholder_text, window, cx)
3651 });
3652
3653 cx.notify();
3654 }
3655
3656 fn header_state(&self, header_type: Section) -> ToggleState {
3657 let (staged_count, count) = match header_type {
3658 Section::New => (self.new_staged_count, self.new_count),
3659 Section::Tracked => (self.tracked_staged_count, self.tracked_count),
3660 Section::Conflict => (self.conflicted_staged_count, self.conflicted_count),
3661 };
3662 if staged_count == 0 {
3663 ToggleState::Unselected
3664 } else if count == staged_count {
3665 ToggleState::Selected
3666 } else {
3667 ToggleState::Indeterminate
3668 }
3669 }
3670
3671 fn update_counts(&mut self, repo: &Repository) {
3672 self.show_placeholders = false;
3673 self.conflicted_count = 0;
3674 self.conflicted_staged_count = 0;
3675 self.new_count = 0;
3676 self.tracked_count = 0;
3677 self.new_staged_count = 0;
3678 self.tracked_staged_count = 0;
3679 self.entry_count = 0;
3680
3681 for status_entry in self.entries.iter().filter_map(|entry| entry.status_entry()) {
3682 self.entry_count += 1;
3683 let is_staging_or_staged = GitPanel::stage_status_for_entry(status_entry, repo)
3684 .as_bool()
3685 .unwrap_or(true);
3686
3687 if repo.had_conflict_on_last_merge_head_change(&status_entry.repo_path) {
3688 self.conflicted_count += 1;
3689 if is_staging_or_staged {
3690 self.conflicted_staged_count += 1;
3691 }
3692 } else if status_entry.status.is_created() {
3693 self.new_count += 1;
3694 if is_staging_or_staged {
3695 self.new_staged_count += 1;
3696 }
3697 } else {
3698 self.tracked_count += 1;
3699 if is_staging_or_staged {
3700 self.tracked_staged_count += 1;
3701 }
3702 }
3703 }
3704 }
3705
3706 pub(crate) fn has_staged_changes(&self) -> bool {
3707 self.tracked_staged_count > 0
3708 || self.new_staged_count > 0
3709 || self.conflicted_staged_count > 0
3710 }
3711
3712 pub(crate) fn has_unstaged_changes(&self) -> bool {
3713 self.tracked_count > self.tracked_staged_count
3714 || self.new_count > self.new_staged_count
3715 || self.conflicted_count > self.conflicted_staged_count
3716 }
3717
3718 fn has_tracked_changes(&self) -> bool {
3719 self.tracked_count > 0
3720 }
3721
3722 pub fn has_unstaged_conflicts(&self) -> bool {
3723 self.conflicted_count > 0 && self.conflicted_count != self.conflicted_staged_count
3724 }
3725
3726 fn show_error_toast(&self, action: impl Into<SharedString>, e: anyhow::Error, cx: &mut App) {
3727 let Some(workspace) = self.workspace.upgrade() else {
3728 return;
3729 };
3730 show_error_toast(workspace, action, e, cx)
3731 }
3732
3733 fn show_commit_message_error<E>(weak_this: &WeakEntity<Self>, err: &E, cx: &mut AsyncApp)
3734 where
3735 E: std::fmt::Debug + std::fmt::Display,
3736 {
3737 if let Ok(Some(workspace)) = weak_this.update(cx, |this, _cx| this.workspace.upgrade()) {
3738 let _ = workspace.update(cx, |workspace, cx| {
3739 struct CommitMessageError;
3740 let notification_id = NotificationId::unique::<CommitMessageError>();
3741 workspace.show_notification(notification_id, cx, |cx| {
3742 cx.new(|cx| {
3743 ErrorMessagePrompt::new(
3744 format!("Failed to generate commit message: {err}"),
3745 cx,
3746 )
3747 })
3748 });
3749 });
3750 }
3751 }
3752
3753 fn show_remote_output(
3754 &mut self,
3755 action: RemoteAction,
3756 info: RemoteCommandOutput,
3757 cx: &mut Context<Self>,
3758 ) {
3759 let Some(workspace) = self.workspace.upgrade() else {
3760 return;
3761 };
3762
3763 workspace.update(cx, |workspace, cx| {
3764 let SuccessMessage { message, style } = remote_output::format_output(&action, info);
3765 let workspace_weak = cx.weak_entity();
3766 let operation = action.name();
3767
3768 let status_toast = StatusToast::new(message, cx, move |this, _cx| {
3769 use remote_output::SuccessStyle::*;
3770 match style {
3771 Toast => this.icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted)),
3772 ToastWithLog { output } => this
3773 .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3774 .action("View Log", move |window, cx| {
3775 let output = output.clone();
3776 let output =
3777 format!("stdout:\n{}\nstderr:\n{}", output.stdout, output.stderr);
3778 workspace_weak
3779 .update(cx, move |workspace, cx| {
3780 open_output(operation, workspace, &output, window, cx)
3781 })
3782 .ok();
3783 }),
3784 PushPrLink { text, link } => this
3785 .icon(ToastIcon::new(IconName::GitBranchAlt).color(Color::Muted))
3786 .action(text, move |_, cx| cx.open_url(&link)),
3787 }
3788 .dismiss_button(true)
3789 });
3790 workspace.toggle_status_toast(status_toast, cx)
3791 });
3792 }
3793
3794 pub fn can_commit(&self) -> bool {
3795 (self.has_staged_changes() || self.has_tracked_changes()) && !self.has_unstaged_conflicts()
3796 }
3797
3798 pub fn can_stage_all(&self) -> bool {
3799 self.has_unstaged_changes()
3800 }
3801
3802 pub fn can_unstage_all(&self) -> bool {
3803 self.has_staged_changes()
3804 }
3805
3806 fn status_width_estimate(
3807 tree_view: bool,
3808 entry: &GitStatusEntry,
3809 path_style: PathStyle,
3810 depth: usize,
3811 ) -> usize {
3812 if tree_view {
3813 Self::item_width_estimate(0, entry.display_name(path_style).len(), depth)
3814 } else {
3815 Self::item_width_estimate(
3816 entry.parent_dir(path_style).map(|s| s.len()).unwrap_or(0),
3817 entry.display_name(path_style).len(),
3818 0,
3819 )
3820 }
3821 }
3822
3823 fn width_estimate_for_list_entry(
3824 &self,
3825 tree_view: bool,
3826 entry: &GitListEntry,
3827 path_style: PathStyle,
3828 ) -> Option<usize> {
3829 match entry {
3830 GitListEntry::Status(status) => Some(Self::status_width_estimate(
3831 tree_view, status, path_style, 0,
3832 )),
3833 GitListEntry::TreeStatus(status) => Some(Self::status_width_estimate(
3834 tree_view,
3835 &status.entry,
3836 path_style,
3837 status.depth,
3838 )),
3839 GitListEntry::Directory(dir) => {
3840 Some(Self::item_width_estimate(0, dir.name.len(), dir.depth))
3841 }
3842 GitListEntry::Header(_) => None,
3843 }
3844 }
3845
3846 fn item_width_estimate(path: usize, file_name: usize, depth: usize) -> usize {
3847 path + file_name + depth * 2
3848 }
3849
3850 fn render_overflow_menu(&self, id: impl Into<ElementId>) -> impl IntoElement {
3851 let focus_handle = self.focus_handle.clone();
3852 let has_tracked_changes = self.has_tracked_changes();
3853 let has_staged_changes = self.has_staged_changes();
3854 let has_unstaged_changes = self.has_unstaged_changes();
3855 let has_new_changes = self.new_count > 0;
3856 let has_stash_items = self.stash_entries.entries.len() > 0;
3857
3858 PopoverMenu::new(id.into())
3859 .trigger(
3860 IconButton::new("overflow-menu-trigger", IconName::Ellipsis)
3861 .icon_size(IconSize::Small)
3862 .icon_color(Color::Muted),
3863 )
3864 .menu(move |window, cx| {
3865 Some(git_panel_context_menu(
3866 focus_handle.clone(),
3867 GitMenuState {
3868 has_tracked_changes,
3869 has_staged_changes,
3870 has_unstaged_changes,
3871 has_new_changes,
3872 sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
3873 has_stash_items,
3874 tree_view: GitPanelSettings::get_global(cx).tree_view,
3875 },
3876 window,
3877 cx,
3878 ))
3879 })
3880 .anchor(Corner::TopRight)
3881 }
3882
3883 pub(crate) fn render_generate_commit_message_button(
3884 &self,
3885 cx: &Context<Self>,
3886 ) -> Option<AnyElement> {
3887 if !agent_settings::AgentSettings::get_global(cx).enabled(cx) {
3888 return None;
3889 }
3890
3891 if self.generate_commit_message_task.is_some() {
3892 return Some(
3893 h_flex()
3894 .gap_1()
3895 .child(
3896 Icon::new(IconName::ArrowCircle)
3897 .size(IconSize::XSmall)
3898 .color(Color::Info)
3899 .with_rotate_animation(2),
3900 )
3901 .child(
3902 Label::new("Generating Commit…")
3903 .size(LabelSize::Small)
3904 .color(Color::Muted),
3905 )
3906 .into_any_element(),
3907 );
3908 }
3909
3910 let model_registry = LanguageModelRegistry::read_global(cx);
3911 let has_commit_model_configuration_error = model_registry
3912 .configuration_error(model_registry.commit_message_model(), cx)
3913 .is_some();
3914 let can_commit = self.can_commit();
3915
3916 let editor_focus_handle = self.commit_editor.focus_handle(cx);
3917
3918 Some(
3919 IconButton::new("generate-commit-message", IconName::AiEdit)
3920 .shape(ui::IconButtonShape::Square)
3921 .icon_color(if has_commit_model_configuration_error {
3922 Color::Disabled
3923 } else {
3924 Color::Muted
3925 })
3926 .tooltip(move |_window, cx| {
3927 if !can_commit {
3928 Tooltip::simple("No Changes to Commit", cx)
3929 } else if has_commit_model_configuration_error {
3930 Tooltip::simple("Configure an LLM provider to generate commit messages", cx)
3931 } else {
3932 Tooltip::for_action_in(
3933 "Generate Commit Message",
3934 &git::GenerateCommitMessage,
3935 &editor_focus_handle,
3936 cx,
3937 )
3938 }
3939 })
3940 .disabled(!can_commit || has_commit_model_configuration_error)
3941 .on_click(cx.listener(move |this, _event, _window, cx| {
3942 this.generate_commit_message(cx);
3943 }))
3944 .into_any_element(),
3945 )
3946 }
3947
3948 pub(crate) fn render_co_authors(&self, cx: &Context<Self>) -> Option<AnyElement> {
3949 let potential_co_authors = self.potential_co_authors(cx);
3950
3951 let (tooltip_label, icon) = if self.add_coauthors {
3952 ("Remove co-authored-by", IconName::Person)
3953 } else {
3954 ("Add co-authored-by", IconName::UserCheck)
3955 };
3956
3957 if potential_co_authors.is_empty() {
3958 None
3959 } else {
3960 Some(
3961 IconButton::new("co-authors", icon)
3962 .shape(ui::IconButtonShape::Square)
3963 .icon_color(Color::Disabled)
3964 .selected_icon_color(Color::Selected)
3965 .toggle_state(self.add_coauthors)
3966 .tooltip(move |_, cx| {
3967 let title = format!(
3968 "{}:{}{}",
3969 tooltip_label,
3970 if potential_co_authors.len() == 1 {
3971 ""
3972 } else {
3973 "\n"
3974 },
3975 potential_co_authors
3976 .iter()
3977 .map(|(name, email)| format!(" {} <{}>", name, email))
3978 .join("\n")
3979 );
3980 Tooltip::simple(title, cx)
3981 })
3982 .on_click(cx.listener(|this, _, _, cx| {
3983 this.add_coauthors = !this.add_coauthors;
3984 cx.notify();
3985 }))
3986 .into_any_element(),
3987 )
3988 }
3989 }
3990
3991 fn render_git_commit_menu(
3992 &self,
3993 id: impl Into<ElementId>,
3994 keybinding_target: Option<FocusHandle>,
3995 cx: &mut Context<Self>,
3996 ) -> impl IntoElement {
3997 PopoverMenu::new(id.into())
3998 .trigger(
3999 ui::ButtonLike::new_rounded_right("commit-split-button-right")
4000 .layer(ui::ElevationIndex::ModalSurface)
4001 .size(ButtonSize::None)
4002 .child(
4003 h_flex()
4004 .px_1()
4005 .h_full()
4006 .justify_center()
4007 .border_l_1()
4008 .border_color(cx.theme().colors().border)
4009 .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
4010 ),
4011 )
4012 .menu({
4013 let git_panel = cx.entity();
4014 let has_previous_commit = self.head_commit(cx).is_some();
4015 let amend = self.amend_pending();
4016 let signoff = self.signoff_enabled;
4017
4018 move |window, cx| {
4019 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
4020 context_menu
4021 .when_some(keybinding_target.clone(), |el, keybinding_target| {
4022 el.context(keybinding_target)
4023 })
4024 .when(has_previous_commit, |this| {
4025 this.toggleable_entry(
4026 "Amend",
4027 amend,
4028 IconPosition::Start,
4029 Some(Box::new(Amend)),
4030 {
4031 let git_panel = git_panel.downgrade();
4032 move |_, cx| {
4033 git_panel
4034 .update(cx, |git_panel, cx| {
4035 git_panel.toggle_amend_pending(cx);
4036 })
4037 .ok();
4038 }
4039 },
4040 )
4041 })
4042 .toggleable_entry(
4043 "Signoff",
4044 signoff,
4045 IconPosition::Start,
4046 Some(Box::new(Signoff)),
4047 move |window, cx| window.dispatch_action(Box::new(Signoff), cx),
4048 )
4049 }))
4050 }
4051 })
4052 .anchor(Corner::TopRight)
4053 }
4054
4055 pub fn configure_commit_button(&self, cx: &mut Context<Self>) -> (bool, &'static str) {
4056 if self.has_unstaged_conflicts() {
4057 (false, "You must resolve conflicts before committing")
4058 } else if !self.has_staged_changes() && !self.has_tracked_changes() && !self.amend_pending {
4059 (false, "No changes to commit")
4060 } else if self.pending_commit.is_some() {
4061 (false, "Commit in progress")
4062 } else if !self.has_commit_message(cx) {
4063 (false, "No commit message")
4064 } else if !self.has_write_access(cx) {
4065 (false, "You do not have write access to this project")
4066 } else {
4067 (true, self.commit_button_title())
4068 }
4069 }
4070
4071 pub fn commit_button_title(&self) -> &'static str {
4072 if self.amend_pending {
4073 if self.has_staged_changes() {
4074 "Amend"
4075 } else if self.has_tracked_changes() {
4076 "Amend Tracked"
4077 } else {
4078 "Amend"
4079 }
4080 } else if self.has_staged_changes() {
4081 "Commit"
4082 } else {
4083 "Commit Tracked"
4084 }
4085 }
4086
4087 fn expand_commit_editor(
4088 &mut self,
4089 _: &git::ExpandCommitEditor,
4090 window: &mut Window,
4091 cx: &mut Context<Self>,
4092 ) {
4093 let workspace = self.workspace.clone();
4094 window.defer(cx, move |window, cx| {
4095 workspace
4096 .update(cx, |workspace, cx| {
4097 CommitModal::toggle(workspace, None, window, cx)
4098 })
4099 .ok();
4100 })
4101 }
4102
4103 fn render_panel_header(
4104 &self,
4105 window: &mut Window,
4106 cx: &mut Context<Self>,
4107 ) -> Option<impl IntoElement> {
4108 self.active_repository.as_ref()?;
4109
4110 let (text, action, stage, tooltip) =
4111 if self.total_staged_count() == self.entry_count && self.entry_count > 0 {
4112 ("Unstage All", UnstageAll.boxed_clone(), false, "git reset")
4113 } else {
4114 ("Stage All", StageAll.boxed_clone(), true, "git add --all")
4115 };
4116
4117 let change_string = match self.changes_count {
4118 0 => "No Changes".to_string(),
4119 1 => "1 Change".to_string(),
4120 count => format!("{} Changes", count),
4121 };
4122
4123 Some(
4124 self.panel_header_container(window, cx)
4125 .px_2()
4126 .justify_between()
4127 .child(
4128 panel_button(change_string)
4129 .color(Color::Muted)
4130 .tooltip(Tooltip::for_action_title_in(
4131 "Open Diff",
4132 &Diff,
4133 &self.focus_handle,
4134 ))
4135 .on_click(|_, _, cx| {
4136 cx.defer(|cx| {
4137 cx.dispatch_action(&Diff);
4138 })
4139 }),
4140 )
4141 .child(
4142 h_flex()
4143 .gap_1()
4144 .child(self.render_overflow_menu("overflow_menu"))
4145 .child(
4146 panel_filled_button(text)
4147 .tooltip(Tooltip::for_action_title_in(
4148 tooltip,
4149 action.as_ref(),
4150 &self.focus_handle,
4151 ))
4152 .disabled(self.entry_count == 0)
4153 .on_click({
4154 let git_panel = cx.weak_entity();
4155 move |_, _, cx| {
4156 git_panel
4157 .update(cx, |git_panel, cx| {
4158 git_panel.change_all_files_stage(stage, cx);
4159 })
4160 .ok();
4161 }
4162 }),
4163 ),
4164 ),
4165 )
4166 }
4167
4168 pub(crate) fn render_remote_button(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
4169 let branch = self.active_repository.as_ref()?.read(cx).branch.clone();
4170 if !self.can_push_and_pull(cx) {
4171 return None;
4172 }
4173 Some(
4174 h_flex()
4175 .gap_1()
4176 .flex_shrink_0()
4177 .when_some(branch, |this, branch| {
4178 let focus_handle = Some(self.focus_handle(cx));
4179
4180 this.children(render_remote_button(
4181 "remote-button",
4182 &branch,
4183 focus_handle,
4184 true,
4185 ))
4186 })
4187 .into_any_element(),
4188 )
4189 }
4190
4191 pub fn render_footer(
4192 &self,
4193 window: &mut Window,
4194 cx: &mut Context<Self>,
4195 ) -> Option<impl IntoElement> {
4196 let active_repository = self.active_repository.clone()?;
4197 let panel_editor_style = panel_editor_style(true, window, cx);
4198 let enable_coauthors = self.render_co_authors(cx);
4199
4200 let editor_focus_handle = self.commit_editor.focus_handle(cx);
4201 let expand_tooltip_focus_handle = editor_focus_handle;
4202
4203 let branch = active_repository.read(cx).branch.clone();
4204 let head_commit = active_repository.read(cx).head_commit.clone();
4205
4206 let footer_size = px(32.);
4207 let gap = px(9.0);
4208 let max_height = panel_editor_style
4209 .text
4210 .line_height_in_pixels(window.rem_size())
4211 * MAX_PANEL_EDITOR_LINES
4212 + gap;
4213
4214 let git_panel = cx.entity();
4215 let display_name = SharedString::from(Arc::from(
4216 active_repository
4217 .read(cx)
4218 .display_name()
4219 .trim_end_matches("/"),
4220 ));
4221 let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
4222 editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
4223 });
4224
4225 let footer = v_flex()
4226 .child(PanelRepoFooter::new(
4227 display_name,
4228 branch,
4229 head_commit,
4230 Some(git_panel),
4231 ))
4232 .child(
4233 panel_editor_container(window, cx)
4234 .id("commit-editor-container")
4235 .relative()
4236 .w_full()
4237 .h(max_height + footer_size)
4238 .border_t_1()
4239 .border_color(cx.theme().colors().border)
4240 .cursor_text()
4241 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
4242 window.focus(&this.commit_editor.focus_handle(cx), cx);
4243 }))
4244 .child(
4245 h_flex()
4246 .id("commit-footer")
4247 .border_t_1()
4248 .when(editor_is_long, |el| {
4249 el.border_color(cx.theme().colors().border_variant)
4250 })
4251 .absolute()
4252 .bottom_0()
4253 .left_0()
4254 .w_full()
4255 .px_2()
4256 .h(footer_size)
4257 .flex_none()
4258 .justify_between()
4259 .child(
4260 self.render_generate_commit_message_button(cx)
4261 .unwrap_or_else(|| div().into_any_element()),
4262 )
4263 .child(
4264 h_flex()
4265 .gap_0p5()
4266 .children(enable_coauthors)
4267 .child(self.render_commit_button(cx)),
4268 ),
4269 )
4270 .child(
4271 div()
4272 .pr_2p5()
4273 .on_action(|&editor::actions::MoveUp, _, cx| {
4274 cx.stop_propagation();
4275 })
4276 .on_action(|&editor::actions::MoveDown, _, cx| {
4277 cx.stop_propagation();
4278 })
4279 .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
4280 )
4281 .child(
4282 h_flex()
4283 .absolute()
4284 .top_2()
4285 .right_2()
4286 .opacity(0.5)
4287 .hover(|this| this.opacity(1.0))
4288 .child(
4289 panel_icon_button("expand-commit-editor", IconName::Maximize)
4290 .icon_size(IconSize::Small)
4291 .size(ui::ButtonSize::Default)
4292 .tooltip(move |_window, cx| {
4293 Tooltip::for_action_in(
4294 "Open Commit Modal",
4295 &git::ExpandCommitEditor,
4296 &expand_tooltip_focus_handle,
4297 cx,
4298 )
4299 })
4300 .on_click(cx.listener({
4301 move |_, _, window, cx| {
4302 window.dispatch_action(
4303 git::ExpandCommitEditor.boxed_clone(),
4304 cx,
4305 )
4306 }
4307 })),
4308 ),
4309 ),
4310 );
4311
4312 Some(footer)
4313 }
4314
4315 fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
4316 let (can_commit, tooltip) = self.configure_commit_button(cx);
4317 let title = self.commit_button_title();
4318 let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
4319 let amend = self.amend_pending();
4320 let signoff = self.signoff_enabled;
4321
4322 let label_color = if self.pending_commit.is_some() {
4323 Color::Disabled
4324 } else {
4325 Color::Default
4326 };
4327
4328 div()
4329 .id("commit-wrapper")
4330 .on_hover(cx.listener(move |this, hovered, _, cx| {
4331 this.show_placeholders =
4332 *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
4333 cx.notify()
4334 }))
4335 .child(SplitButton::new(
4336 ButtonLike::new_rounded_left(ElementId::Name(
4337 format!("split-button-left-{}", title).into(),
4338 ))
4339 .layer(ElevationIndex::ModalSurface)
4340 .size(ButtonSize::Compact)
4341 .child(
4342 Label::new(title)
4343 .size(LabelSize::Small)
4344 .color(label_color)
4345 .mr_0p5(),
4346 )
4347 .on_click({
4348 let git_panel = cx.weak_entity();
4349 move |_, window, cx| {
4350 telemetry::event!("Git Committed", source = "Git Panel");
4351 git_panel
4352 .update(cx, |git_panel, cx| {
4353 git_panel.commit_changes(
4354 CommitOptions { amend, signoff },
4355 window,
4356 cx,
4357 );
4358 })
4359 .ok();
4360 }
4361 })
4362 .disabled(!can_commit || self.modal_open)
4363 .tooltip({
4364 let handle = commit_tooltip_focus_handle.clone();
4365 move |_window, cx| {
4366 if can_commit {
4367 Tooltip::with_meta_in(
4368 tooltip,
4369 Some(if amend { &git::Amend } else { &git::Commit }),
4370 format!(
4371 "git commit{}{}",
4372 if amend { " --amend" } else { "" },
4373 if signoff { " --signoff" } else { "" }
4374 ),
4375 &handle.clone(),
4376 cx,
4377 )
4378 } else {
4379 Tooltip::simple(tooltip, cx)
4380 }
4381 }
4382 }),
4383 self.render_git_commit_menu(
4384 ElementId::Name(format!("split-button-right-{}", title).into()),
4385 Some(commit_tooltip_focus_handle),
4386 cx,
4387 )
4388 .into_any_element(),
4389 ))
4390 }
4391
4392 fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
4393 h_flex()
4394 .py_1p5()
4395 .px_2()
4396 .gap_1p5()
4397 .justify_between()
4398 .border_t_1()
4399 .border_color(cx.theme().colors().border.opacity(0.8))
4400 .child(
4401 div()
4402 .flex_grow()
4403 .overflow_hidden()
4404 .max_w(relative(0.85))
4405 .child(
4406 Label::new("This will update your most recent commit.")
4407 .size(LabelSize::Small)
4408 .truncate(),
4409 ),
4410 )
4411 .child(
4412 panel_button("Cancel")
4413 .size(ButtonSize::Default)
4414 .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
4415 )
4416 }
4417
4418 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
4419 let active_repository = self.active_repository.as_ref()?;
4420 let branch = active_repository.read(cx).branch.as_ref()?;
4421 let commit = branch.most_recent_commit.as_ref()?.clone();
4422 let workspace = self.workspace.clone();
4423 let this = cx.entity();
4424
4425 Some(
4426 h_flex()
4427 .p_1p5()
4428 .gap_1p5()
4429 .justify_between()
4430 .border_t_1()
4431 .border_color(cx.theme().colors().border.opacity(0.8))
4432 .child(
4433 div()
4434 .id("commit-msg-hover")
4435 .px_1()
4436 .cursor_pointer()
4437 .line_clamp(1)
4438 .rounded_sm()
4439 .hover(|s| s.bg(cx.theme().colors().element_hover))
4440 .child(
4441 Label::new(commit.subject.clone())
4442 .size(LabelSize::Small)
4443 .truncate(),
4444 )
4445 .on_click({
4446 let commit = commit.clone();
4447 let repo = active_repository.downgrade();
4448 move |_, window, cx| {
4449 CommitView::open(
4450 commit.sha.to_string(),
4451 repo.clone(),
4452 workspace.clone(),
4453 None,
4454 None,
4455 window,
4456 cx,
4457 );
4458 }
4459 })
4460 .hoverable_tooltip({
4461 let repo = active_repository.clone();
4462 move |window, cx| {
4463 GitPanelMessageTooltip::new(
4464 this.clone(),
4465 commit.sha.clone(),
4466 repo.clone(),
4467 window,
4468 cx,
4469 )
4470 .into()
4471 }
4472 }),
4473 )
4474 .when(commit.has_parent, |this| {
4475 let has_unstaged = self.has_unstaged_changes();
4476 this.pr_2().child(
4477 panel_icon_button("undo", IconName::Undo)
4478 .icon_size(IconSize::XSmall)
4479 .icon_color(Color::Muted)
4480 .tooltip(move |_window, cx| {
4481 Tooltip::with_meta(
4482 "Uncommit",
4483 Some(&git::Uncommit),
4484 if has_unstaged {
4485 "git reset HEAD^ --soft"
4486 } else {
4487 "git reset HEAD^"
4488 },
4489 cx,
4490 )
4491 })
4492 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
4493 )
4494 }),
4495 )
4496 }
4497
4498 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
4499 h_flex().h_full().flex_grow().justify_center().child(
4500 v_flex()
4501 .gap_2()
4502 .child(h_flex().w_full().justify_around().child(
4503 if self.active_repository.is_some() {
4504 "No changes to commit"
4505 } else {
4506 "No Git repositories"
4507 },
4508 ))
4509 .children({
4510 let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
4511 (worktree_count > 0 && self.active_repository.is_none()).then(|| {
4512 h_flex().w_full().justify_around().child(
4513 panel_filled_button("Initialize Repository")
4514 .tooltip(Tooltip::for_action_title_in(
4515 "git init",
4516 &git::Init,
4517 &self.focus_handle,
4518 ))
4519 .on_click(move |_, _, cx| {
4520 cx.defer(move |cx| {
4521 cx.dispatch_action(&git::Init);
4522 })
4523 }),
4524 )
4525 })
4526 })
4527 .text_ui_sm(cx)
4528 .mx_auto()
4529 .text_color(Color::Placeholder.color(cx)),
4530 )
4531 }
4532
4533 fn render_buffer_header_controls(
4534 &self,
4535 entity: &Entity<Self>,
4536 file: &Arc<dyn File>,
4537 _: &Window,
4538 cx: &App,
4539 ) -> Option<AnyElement> {
4540 let repo = self.active_repository.as_ref()?.read(cx);
4541 let project_path = (file.worktree_id(cx), file.path().clone()).into();
4542 let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
4543 let ix = self.entry_by_path(&repo_path)?;
4544 let entry = self.entries.get(ix)?;
4545
4546 let is_staging_or_staged = repo
4547 .pending_ops_for_path(&repo_path)
4548 .map(|ops| ops.staging() || ops.staged())
4549 .or_else(|| {
4550 repo.status_for_path(&repo_path)
4551 .and_then(|status| status.status.staging().as_bool())
4552 })
4553 .or_else(|| {
4554 entry
4555 .status_entry()
4556 .and_then(|entry| entry.staging.as_bool())
4557 });
4558
4559 let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into())
4560 .disabled(!self.has_write_access(cx))
4561 .fill()
4562 .elevation(ElevationIndex::Surface)
4563 .on_click({
4564 let entry = entry.clone();
4565 let git_panel = entity.downgrade();
4566 move |_, window, cx| {
4567 git_panel
4568 .update(cx, |this, cx| {
4569 this.toggle_staged_for_entry(&entry, window, cx);
4570 cx.stop_propagation();
4571 })
4572 .ok();
4573 }
4574 });
4575 Some(
4576 h_flex()
4577 .id("start-slot")
4578 .text_lg()
4579 .child(checkbox)
4580 .on_mouse_down(MouseButton::Left, |_, _, cx| {
4581 // prevent the list item active state triggering when toggling checkbox
4582 cx.stop_propagation();
4583 })
4584 .into_any_element(),
4585 )
4586 }
4587
4588 fn render_entries(
4589 &self,
4590 has_write_access: bool,
4591 repo: Entity<Repository>,
4592 window: &mut Window,
4593 cx: &mut Context<Self>,
4594 ) -> impl IntoElement {
4595 let (is_tree_view, entry_count) = match &self.view_mode {
4596 GitPanelViewMode::Tree(state) => (true, state.logical_indices.len()),
4597 GitPanelViewMode::Flat => (false, self.entries.len()),
4598 };
4599 let repo = repo.downgrade();
4600
4601 v_flex()
4602 .flex_1()
4603 .size_full()
4604 .overflow_hidden()
4605 .relative()
4606 .child(
4607 h_flex()
4608 .flex_1()
4609 .size_full()
4610 .relative()
4611 .overflow_hidden()
4612 .child(
4613 uniform_list(
4614 "entries",
4615 entry_count,
4616 cx.processor(move |this, range: Range<usize>, window, cx| {
4617 let Some(repo) = repo.upgrade() else {
4618 return Vec::new();
4619 };
4620 let repo = repo.read(cx);
4621
4622 let mut items = Vec::with_capacity(range.end - range.start);
4623
4624 for ix in range.into_iter().map(|ix| match &this.view_mode {
4625 GitPanelViewMode::Tree(state) => state.logical_indices[ix],
4626 GitPanelViewMode::Flat => ix,
4627 }) {
4628 match &this.entries.get(ix) {
4629 Some(GitListEntry::Status(entry)) => {
4630 items.push(this.render_status_entry(
4631 ix,
4632 entry,
4633 0,
4634 has_write_access,
4635 repo,
4636 window,
4637 cx,
4638 ));
4639 }
4640 Some(GitListEntry::TreeStatus(entry)) => {
4641 items.push(this.render_status_entry(
4642 ix,
4643 &entry.entry,
4644 entry.depth,
4645 has_write_access,
4646 repo,
4647 window,
4648 cx,
4649 ));
4650 }
4651 Some(GitListEntry::Directory(entry)) => {
4652 items.push(this.render_directory_entry(
4653 ix,
4654 entry,
4655 has_write_access,
4656 window,
4657 cx,
4658 ));
4659 }
4660 Some(GitListEntry::Header(header)) => {
4661 items.push(this.render_list_header(
4662 ix,
4663 header,
4664 has_write_access,
4665 window,
4666 cx,
4667 ));
4668 }
4669 None => {}
4670 }
4671 }
4672
4673 items
4674 }),
4675 )
4676 .when(is_tree_view, |list| {
4677 let indent_size = px(TREE_INDENT);
4678 list.with_decoration(
4679 ui::indent_guides(indent_size, IndentGuideColors::panel(cx))
4680 .with_compute_indents_fn(
4681 cx.entity(),
4682 |this, range, _window, _cx| {
4683 range
4684 .map(|ix| match this.entries.get(ix) {
4685 Some(GitListEntry::Directory(dir)) => dir.depth,
4686 Some(GitListEntry::TreeStatus(status)) => {
4687 status.depth
4688 }
4689 _ => 0,
4690 })
4691 .collect()
4692 },
4693 )
4694 .with_render_fn(cx.entity(), |_, params, _, _| {
4695 // Magic number to align the tree item is 3 here
4696 // because we're using 12px as the left-side padding
4697 // and 3 makes the alignment work with the bounding box of the icon
4698 let left_offset = px(TREE_INDENT + 3_f32);
4699 let indent_size = params.indent_size;
4700 let item_height = params.item_height;
4701
4702 params
4703 .indent_guides
4704 .into_iter()
4705 .map(|layout| {
4706 let bounds = Bounds::new(
4707 point(
4708 layout.offset.x * indent_size + left_offset,
4709 layout.offset.y * item_height,
4710 ),
4711 size(px(1.), layout.length * item_height),
4712 );
4713 RenderedIndentGuide {
4714 bounds,
4715 layout,
4716 is_active: false,
4717 hitbox: None,
4718 }
4719 })
4720 .collect()
4721 }),
4722 )
4723 })
4724 .size_full()
4725 .flex_grow()
4726 .with_width_from_item(self.max_width_item_index)
4727 .track_scroll(&self.scroll_handle),
4728 )
4729 .on_mouse_down(
4730 MouseButton::Right,
4731 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
4732 this.deploy_panel_context_menu(event.position, window, cx)
4733 }),
4734 )
4735 .custom_scrollbars(
4736 Scrollbars::for_settings::<GitPanelSettings>()
4737 .tracked_scroll_handle(&self.scroll_handle)
4738 .with_track_along(
4739 ScrollAxes::Horizontal,
4740 cx.theme().colors().panel_background,
4741 ),
4742 window,
4743 cx,
4744 ),
4745 )
4746 }
4747
4748 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
4749 Label::new(label.into()).color(color)
4750 }
4751
4752 fn list_item_height(&self) -> Rems {
4753 rems(1.75)
4754 }
4755
4756 fn render_list_header(
4757 &self,
4758 ix: usize,
4759 header: &GitHeaderEntry,
4760 _: bool,
4761 _: &Window,
4762 _: &Context<Self>,
4763 ) -> AnyElement {
4764 let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
4765
4766 h_flex()
4767 .id(id)
4768 .h(self.list_item_height())
4769 .w_full()
4770 .items_end()
4771 .px_3()
4772 .pb_1()
4773 .child(
4774 Label::new(header.title())
4775 .color(Color::Muted)
4776 .size(LabelSize::Small)
4777 .line_height_style(LineHeightStyle::UiLabel)
4778 .single_line(),
4779 )
4780 .into_any_element()
4781 }
4782
4783 pub fn load_commit_details(
4784 &self,
4785 sha: String,
4786 cx: &mut Context<Self>,
4787 ) -> Task<anyhow::Result<CommitDetails>> {
4788 let Some(repo) = self.active_repository.clone() else {
4789 return Task::ready(Err(anyhow::anyhow!("no active repo")));
4790 };
4791 repo.update(cx, |repo, cx| {
4792 let show = repo.show(sha);
4793 cx.spawn(async move |_, _| show.await?)
4794 })
4795 }
4796
4797 fn deploy_entry_context_menu(
4798 &mut self,
4799 position: Point<Pixels>,
4800 ix: usize,
4801 window: &mut Window,
4802 cx: &mut Context<Self>,
4803 ) {
4804 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
4805 return;
4806 };
4807 let stage_title = if entry.status.staging().is_fully_staged() {
4808 "Unstage File"
4809 } else {
4810 "Stage File"
4811 };
4812 let restore_title = if entry.status.is_created() {
4813 "Trash File"
4814 } else {
4815 "Discard Changes"
4816 };
4817 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
4818 let is_created = entry.status.is_created();
4819 context_menu
4820 .context(self.focus_handle.clone())
4821 .action(stage_title, ToggleStaged.boxed_clone())
4822 .action(restore_title, git::RestoreFile::default().boxed_clone())
4823 .action_disabled_when(
4824 !is_created,
4825 "Add to .gitignore",
4826 git::AddToGitignore.boxed_clone(),
4827 )
4828 .separator()
4829 .action("Open Diff", menu::Confirm.boxed_clone())
4830 .action("Open File", menu::SecondaryConfirm.boxed_clone())
4831 .separator()
4832 .action_disabled_when(is_created, "View File History", Box::new(git::FileHistory))
4833 });
4834 self.selected_entry = Some(ix);
4835 self.set_context_menu(context_menu, position, window, cx);
4836 }
4837
4838 fn deploy_panel_context_menu(
4839 &mut self,
4840 position: Point<Pixels>,
4841 window: &mut Window,
4842 cx: &mut Context<Self>,
4843 ) {
4844 let context_menu = git_panel_context_menu(
4845 self.focus_handle.clone(),
4846 GitMenuState {
4847 has_tracked_changes: self.has_tracked_changes(),
4848 has_staged_changes: self.has_staged_changes(),
4849 has_unstaged_changes: self.has_unstaged_changes(),
4850 has_new_changes: self.new_count > 0,
4851 sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
4852 has_stash_items: self.stash_entries.entries.len() > 0,
4853 tree_view: GitPanelSettings::get_global(cx).tree_view,
4854 },
4855 window,
4856 cx,
4857 );
4858 self.set_context_menu(context_menu, position, window, cx);
4859 }
4860
4861 fn set_context_menu(
4862 &mut self,
4863 context_menu: Entity<ContextMenu>,
4864 position: Point<Pixels>,
4865 window: &Window,
4866 cx: &mut Context<Self>,
4867 ) {
4868 let subscription = cx.subscribe_in(
4869 &context_menu,
4870 window,
4871 |this, _, _: &DismissEvent, window, cx| {
4872 if this.context_menu.as_ref().is_some_and(|context_menu| {
4873 context_menu.0.focus_handle(cx).contains_focused(window, cx)
4874 }) {
4875 cx.focus_self(window);
4876 }
4877 this.context_menu.take();
4878 cx.notify();
4879 },
4880 );
4881 self.context_menu = Some((context_menu, position, subscription));
4882 cx.notify();
4883 }
4884
4885 fn render_status_entry(
4886 &self,
4887 ix: usize,
4888 entry: &GitStatusEntry,
4889 depth: usize,
4890 has_write_access: bool,
4891 repo: &Repository,
4892 window: &Window,
4893 cx: &Context<Self>,
4894 ) -> AnyElement {
4895 let tree_view = GitPanelSettings::get_global(cx).tree_view;
4896 let path_style = self.project.read(cx).path_style(cx);
4897 let git_path_style = ProjectSettings::get_global(cx).git.path_style;
4898 let display_name = entry.display_name(path_style);
4899
4900 let selected = self.selected_entry == Some(ix);
4901 let marked = self.marked_entries.contains(&ix);
4902 let status_style = GitPanelSettings::get_global(cx).status_style;
4903 let status = entry.status;
4904
4905 let has_conflict = status.is_conflicted();
4906 let is_modified = status.is_modified();
4907 let is_deleted = status.is_deleted();
4908 let is_created = status.is_created();
4909
4910 let label_color = if status_style == StatusStyle::LabelColor {
4911 if has_conflict {
4912 Color::VersionControlConflict
4913 } else if is_created {
4914 Color::VersionControlAdded
4915 } else if is_modified {
4916 Color::VersionControlModified
4917 } else if is_deleted {
4918 // We don't want a bunch of red labels in the list
4919 Color::Disabled
4920 } else {
4921 Color::VersionControlAdded
4922 }
4923 } else {
4924 Color::Default
4925 };
4926
4927 let path_color = if status.is_deleted() {
4928 Color::Disabled
4929 } else {
4930 Color::Muted
4931 };
4932
4933 let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
4934 let checkbox_wrapper_id: ElementId =
4935 ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
4936 let checkbox_id: ElementId =
4937 ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
4938
4939 let stage_status = GitPanel::stage_status_for_entry(entry, &repo);
4940 let mut is_staged: ToggleState = match stage_status {
4941 StageStatus::Staged => ToggleState::Selected,
4942 StageStatus::Unstaged => ToggleState::Unselected,
4943 StageStatus::PartiallyStaged => ToggleState::Indeterminate,
4944 };
4945 if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
4946 is_staged = ToggleState::Selected;
4947 }
4948
4949 let handle = cx.weak_entity();
4950
4951 let selected_bg_alpha = 0.08;
4952 let marked_bg_alpha = 0.12;
4953 let state_opacity_step = 0.04;
4954
4955 let info_color = cx.theme().status().info;
4956
4957 let base_bg = match (selected, marked) {
4958 (true, true) => info_color.alpha(selected_bg_alpha + marked_bg_alpha),
4959 (true, false) => info_color.alpha(selected_bg_alpha),
4960 (false, true) => info_color.alpha(marked_bg_alpha),
4961 _ => cx.theme().colors().ghost_element_background,
4962 };
4963
4964 let (hover_bg, active_bg) = if selected {
4965 (
4966 info_color.alpha(selected_bg_alpha + state_opacity_step),
4967 info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
4968 )
4969 } else {
4970 (
4971 cx.theme().colors().ghost_element_hover,
4972 cx.theme().colors().ghost_element_active,
4973 )
4974 };
4975
4976 let name_row = h_flex()
4977 .min_w_0()
4978 .flex_1()
4979 .gap_1()
4980 .child(git_status_icon(status))
4981 .map(|this| {
4982 if tree_view {
4983 this.pl(px(depth as f32 * TREE_INDENT)).child(
4984 self.entry_label(display_name, label_color)
4985 .when(status.is_deleted(), Label::strikethrough)
4986 .truncate(),
4987 )
4988 } else {
4989 this.child(self.path_formatted(
4990 entry.parent_dir(path_style),
4991 path_color,
4992 display_name,
4993 label_color,
4994 path_style,
4995 git_path_style,
4996 status.is_deleted(),
4997 ))
4998 }
4999 });
5000
5001 h_flex()
5002 .id(id)
5003 .h(self.list_item_height())
5004 .w_full()
5005 .pl_3()
5006 .pr_1()
5007 .gap_1p5()
5008 .border_1()
5009 .border_r_2()
5010 .when(selected && self.focus_handle.is_focused(window), |el| {
5011 el.border_color(cx.theme().colors().panel_focused_border)
5012 })
5013 .bg(base_bg)
5014 .hover(|s| s.bg(hover_bg))
5015 .active(|s| s.bg(active_bg))
5016 .child(name_row)
5017 .child(
5018 div()
5019 .id(checkbox_wrapper_id)
5020 .flex_none()
5021 .occlude()
5022 .cursor_pointer()
5023 .child(
5024 Checkbox::new(checkbox_id, is_staged)
5025 .disabled(!has_write_access)
5026 .fill()
5027 .elevation(ElevationIndex::Surface)
5028 .on_click_ext({
5029 let entry = entry.clone();
5030 let this = cx.weak_entity();
5031 move |_, click, window, cx| {
5032 this.update(cx, |this, cx| {
5033 if !has_write_access {
5034 return;
5035 }
5036 if click.modifiers().shift {
5037 this.stage_bulk(ix, cx);
5038 } else {
5039 let list_entry =
5040 if GitPanelSettings::get_global(cx).tree_view {
5041 GitListEntry::TreeStatus(GitTreeStatusEntry {
5042 entry: entry.clone(),
5043 depth,
5044 })
5045 } else {
5046 GitListEntry::Status(entry.clone())
5047 };
5048 this.toggle_staged_for_entry(&list_entry, window, cx);
5049 }
5050 cx.stop_propagation();
5051 })
5052 .ok();
5053 }
5054 })
5055 .tooltip(move |_window, cx| {
5056 let action = match stage_status {
5057 StageStatus::Staged => "Unstage",
5058 StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5059 };
5060 let tooltip_name = action.to_string();
5061
5062 Tooltip::for_action(tooltip_name, &ToggleStaged, cx)
5063 }),
5064 ),
5065 )
5066 .on_click({
5067 cx.listener(move |this, event: &ClickEvent, window, cx| {
5068 this.selected_entry = Some(ix);
5069 cx.notify();
5070 if event.modifiers().secondary() {
5071 this.open_file(&Default::default(), window, cx)
5072 } else {
5073 this.open_diff(&Default::default(), window, cx);
5074 this.focus_handle.focus(window, cx);
5075 }
5076 })
5077 })
5078 .on_mouse_down(
5079 MouseButton::Right,
5080 move |event: &MouseDownEvent, window, cx| {
5081 // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
5082 if event.button != MouseButton::Right {
5083 return;
5084 }
5085
5086 let Some(this) = handle.upgrade() else {
5087 return;
5088 };
5089 this.update(cx, |this, cx| {
5090 this.deploy_entry_context_menu(event.position, ix, window, cx);
5091 });
5092 cx.stop_propagation();
5093 },
5094 )
5095 .into_any_element()
5096 }
5097
5098 fn render_directory_entry(
5099 &self,
5100 ix: usize,
5101 entry: &GitTreeDirEntry,
5102 has_write_access: bool,
5103 window: &Window,
5104 cx: &Context<Self>,
5105 ) -> AnyElement {
5106 // TODO: Have not yet plugin the self.marked_entries. Not sure when and why we need that
5107 let selected = self.selected_entry == Some(ix);
5108 let label_color = Color::Muted;
5109
5110 let id: ElementId = ElementId::Name(format!("dir_{}_{}", entry.name, ix).into());
5111 let checkbox_id: ElementId =
5112 ElementId::Name(format!("dir_checkbox_{}_{}", entry.name, ix).into());
5113 let checkbox_wrapper_id: ElementId =
5114 ElementId::Name(format!("dir_checkbox_wrapper_{}_{}", entry.name, ix).into());
5115
5116 let selected_bg_alpha = 0.08;
5117 let state_opacity_step = 0.04;
5118
5119 let info_color = cx.theme().status().info;
5120 let colors = cx.theme().colors();
5121
5122 let (base_bg, hover_bg, active_bg) = if selected {
5123 (
5124 info_color.alpha(selected_bg_alpha),
5125 info_color.alpha(selected_bg_alpha + state_opacity_step),
5126 info_color.alpha(selected_bg_alpha + state_opacity_step * 2.0),
5127 )
5128 } else {
5129 (
5130 colors.ghost_element_background,
5131 colors.ghost_element_hover,
5132 colors.ghost_element_active,
5133 )
5134 };
5135
5136 let folder_icon = if entry.expanded {
5137 IconName::FolderOpen
5138 } else {
5139 IconName::Folder
5140 };
5141
5142 let stage_status = if let Some(repo) = &self.active_repository {
5143 self.stage_status_for_directory(entry, repo.read(cx))
5144 } else {
5145 util::debug_panic!(
5146 "Won't have entries to render without an active repository in Git Panel"
5147 );
5148 StageStatus::PartiallyStaged
5149 };
5150
5151 let toggle_state: ToggleState = match stage_status {
5152 StageStatus::Staged => ToggleState::Selected,
5153 StageStatus::Unstaged => ToggleState::Unselected,
5154 StageStatus::PartiallyStaged => ToggleState::Indeterminate,
5155 };
5156
5157 let name_row = h_flex()
5158 .min_w_0()
5159 .gap_1()
5160 .pl(px(entry.depth as f32 * TREE_INDENT))
5161 .child(
5162 Icon::new(folder_icon)
5163 .size(IconSize::Small)
5164 .color(Color::Muted),
5165 )
5166 .child(self.entry_label(entry.name.clone(), label_color).truncate());
5167
5168 h_flex()
5169 .id(id)
5170 .h(self.list_item_height())
5171 .min_w_0()
5172 .w_full()
5173 .pl_3()
5174 .pr_1()
5175 .gap_1p5()
5176 .justify_between()
5177 .border_1()
5178 .border_r_2()
5179 .when(selected && self.focus_handle.is_focused(window), |el| {
5180 el.border_color(cx.theme().colors().panel_focused_border)
5181 })
5182 .bg(base_bg)
5183 .hover(|s| s.bg(hover_bg))
5184 .active(|s| s.bg(active_bg))
5185 .child(name_row)
5186 .child(
5187 div()
5188 .id(checkbox_wrapper_id)
5189 .flex_none()
5190 .occlude()
5191 .cursor_pointer()
5192 .child(
5193 Checkbox::new(checkbox_id, toggle_state)
5194 .disabled(!has_write_access)
5195 .fill()
5196 .elevation(ElevationIndex::Surface)
5197 .on_click({
5198 let entry = entry.clone();
5199 let this = cx.weak_entity();
5200 move |_, window, cx| {
5201 this.update(cx, |this, cx| {
5202 if !has_write_access {
5203 return;
5204 }
5205 this.toggle_staged_for_entry(
5206 &GitListEntry::Directory(entry.clone()),
5207 window,
5208 cx,
5209 );
5210 cx.stop_propagation();
5211 })
5212 .ok();
5213 }
5214 })
5215 .tooltip(move |_window, cx| {
5216 let action = match stage_status {
5217 StageStatus::Staged => "Unstage",
5218 StageStatus::Unstaged | StageStatus::PartiallyStaged => "Stage",
5219 };
5220 Tooltip::simple(format!("{action} folder"), cx)
5221 }),
5222 ),
5223 )
5224 .on_click({
5225 let key = entry.key.clone();
5226 cx.listener(move |this, _event: &ClickEvent, window, cx| {
5227 this.selected_entry = Some(ix);
5228 this.toggle_directory(&key, window, cx);
5229 })
5230 })
5231 .into_any_element()
5232 }
5233
5234 fn path_formatted(
5235 &self,
5236 directory: Option<String>,
5237 path_color: Color,
5238 file_name: String,
5239 label_color: Color,
5240 path_style: PathStyle,
5241 git_path_style: GitPathStyle,
5242 strikethrough: bool,
5243 ) -> Div {
5244 let file_name_first = git_path_style == GitPathStyle::FileNameFirst;
5245 let file_path_first = git_path_style == GitPathStyle::FilePathFirst;
5246
5247 let file_name = format!("{} ", file_name);
5248
5249 h_flex()
5250 .min_w_0()
5251 .overflow_hidden()
5252 .when(file_path_first, |this| this.flex_row_reverse())
5253 .child(
5254 div().flex_none().child(
5255 self.entry_label(file_name, label_color)
5256 .when(strikethrough, Label::strikethrough),
5257 ),
5258 )
5259 .when_some(directory, |this, dir| {
5260 let path_name = if file_name_first {
5261 dir
5262 } else {
5263 format!("{dir}{}", path_style.primary_separator())
5264 };
5265
5266 this.child(
5267 self.entry_label(path_name, path_color)
5268 .truncate_start()
5269 .when(strikethrough, Label::strikethrough),
5270 )
5271 })
5272 }
5273
5274 fn has_write_access(&self, cx: &App) -> bool {
5275 !self.project.read(cx).is_read_only(cx)
5276 }
5277
5278 pub fn amend_pending(&self) -> bool {
5279 self.amend_pending
5280 }
5281
5282 /// Sets the pending amend state, ensuring that the original commit message
5283 /// is either saved, when `value` is `true` and there's no pending amend, or
5284 /// restored, when `value` is `false` and there's a pending amend.
5285 pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
5286 if value && !self.amend_pending {
5287 let current_message = self.commit_message_buffer(cx).read(cx).text();
5288 self.original_commit_message = if current_message.trim().is_empty() {
5289 None
5290 } else {
5291 Some(current_message)
5292 };
5293 } else if !value && self.amend_pending {
5294 let message = self.original_commit_message.take().unwrap_or_default();
5295 self.commit_message_buffer(cx).update(cx, |buffer, cx| {
5296 let start = buffer.anchor_before(0);
5297 let end = buffer.anchor_after(buffer.len());
5298 buffer.edit([(start..end, message)], None, cx);
5299 });
5300 }
5301
5302 self.amend_pending = value;
5303 self.serialize(cx);
5304 cx.notify();
5305 }
5306
5307 pub fn signoff_enabled(&self) -> bool {
5308 self.signoff_enabled
5309 }
5310
5311 pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
5312 self.signoff_enabled = value;
5313 self.serialize(cx);
5314 cx.notify();
5315 }
5316
5317 pub fn toggle_signoff_enabled(
5318 &mut self,
5319 _: &Signoff,
5320 _window: &mut Window,
5321 cx: &mut Context<Self>,
5322 ) {
5323 self.set_signoff_enabled(!self.signoff_enabled, cx);
5324 }
5325
5326 pub async fn load(
5327 workspace: WeakEntity<Workspace>,
5328 mut cx: AsyncWindowContext,
5329 ) -> anyhow::Result<Entity<Self>> {
5330 let serialized_panel = match workspace
5331 .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
5332 .ok()
5333 .flatten()
5334 {
5335 Some(serialization_key) => cx
5336 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
5337 .await
5338 .context("loading git panel")
5339 .log_err()
5340 .flatten()
5341 .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
5342 .transpose()
5343 .log_err()
5344 .flatten(),
5345 None => None,
5346 };
5347
5348 workspace.update_in(&mut cx, |workspace, window, cx| {
5349 let panel = GitPanel::new(workspace, window, cx);
5350
5351 if let Some(serialized_panel) = serialized_panel {
5352 panel.update(cx, |panel, cx| {
5353 panel.width = serialized_panel.width;
5354 panel.amend_pending = serialized_panel.amend_pending;
5355 panel.signoff_enabled = serialized_panel.signoff_enabled;
5356 cx.notify();
5357 })
5358 }
5359
5360 panel
5361 })
5362 }
5363
5364 fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
5365 let Some(op) = self.bulk_staging.as_ref() else {
5366 return;
5367 };
5368 let Some(mut anchor_index) = self.entry_by_path(&op.anchor) else {
5369 return;
5370 };
5371 if let Some(entry) = self.entries.get(index)
5372 && let Some(entry) = entry.status_entry()
5373 {
5374 self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
5375 }
5376 if index < anchor_index {
5377 std::mem::swap(&mut index, &mut anchor_index);
5378 }
5379 let entries = self
5380 .entries
5381 .get(anchor_index..=index)
5382 .unwrap_or_default()
5383 .iter()
5384 .filter_map(|entry| entry.status_entry().cloned())
5385 .collect::<Vec<_>>();
5386 self.change_file_stage(true, entries, cx);
5387 }
5388
5389 fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
5390 let Some(repo) = self.active_repository.as_ref() else {
5391 return;
5392 };
5393 self.bulk_staging = Some(BulkStaging {
5394 repo_id: repo.read(cx).id,
5395 anchor: path,
5396 });
5397 }
5398
5399 pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
5400 self.set_amend_pending(!self.amend_pending, cx);
5401 if self.amend_pending {
5402 self.load_last_commit_message(cx);
5403 }
5404 }
5405}
5406
5407impl Render for GitPanel {
5408 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
5409 let project = self.project.read(cx);
5410 let has_entries = !self.entries.is_empty();
5411 let room = self
5412 .workspace
5413 .upgrade()
5414 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
5415
5416 let has_write_access = self.has_write_access(cx);
5417
5418 let has_co_authors = room.is_some_and(|room| {
5419 self.load_local_committer(cx);
5420 let room = room.read(cx);
5421 room.remote_participants()
5422 .values()
5423 .any(|remote_participant| remote_participant.can_write())
5424 });
5425
5426 v_flex()
5427 .id("git_panel")
5428 .key_context(self.dispatch_context(window, cx))
5429 .track_focus(&self.focus_handle)
5430 .when(has_write_access && !project.is_read_only(cx), |this| {
5431 this.on_action(cx.listener(Self::toggle_staged_for_selected))
5432 .on_action(cx.listener(Self::stage_range))
5433 .on_action(cx.listener(GitPanel::on_commit))
5434 .on_action(cx.listener(GitPanel::on_amend))
5435 .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
5436 .on_action(cx.listener(Self::stage_all))
5437 .on_action(cx.listener(Self::unstage_all))
5438 .on_action(cx.listener(Self::stage_selected))
5439 .on_action(cx.listener(Self::unstage_selected))
5440 .on_action(cx.listener(Self::restore_tracked_files))
5441 .on_action(cx.listener(Self::revert_selected))
5442 .on_action(cx.listener(Self::add_to_gitignore))
5443 .on_action(cx.listener(Self::clean_all))
5444 .on_action(cx.listener(Self::generate_commit_message_action))
5445 .on_action(cx.listener(Self::stash_all))
5446 .on_action(cx.listener(Self::stash_pop))
5447 })
5448 .on_action(cx.listener(Self::collapse_selected_entry))
5449 .on_action(cx.listener(Self::expand_selected_entry))
5450 .on_action(cx.listener(Self::select_first))
5451 .on_action(cx.listener(Self::select_next))
5452 .on_action(cx.listener(Self::select_previous))
5453 .on_action(cx.listener(Self::select_last))
5454 .on_action(cx.listener(Self::first_entry))
5455 .on_action(cx.listener(Self::next_entry))
5456 .on_action(cx.listener(Self::previous_entry))
5457 .on_action(cx.listener(Self::last_entry))
5458 .on_action(cx.listener(Self::close_panel))
5459 .on_action(cx.listener(Self::open_diff))
5460 .on_action(cx.listener(Self::open_file))
5461 .on_action(cx.listener(Self::file_history))
5462 .on_action(cx.listener(Self::focus_changes_list))
5463 .on_action(cx.listener(Self::focus_editor))
5464 .on_action(cx.listener(Self::expand_commit_editor))
5465 .when(has_write_access && has_co_authors, |git_panel| {
5466 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
5467 })
5468 .on_action(cx.listener(Self::toggle_sort_by_path))
5469 .on_action(cx.listener(Self::toggle_tree_view))
5470 .size_full()
5471 .overflow_hidden()
5472 .bg(cx.theme().colors().panel_background)
5473 .child(
5474 v_flex()
5475 .size_full()
5476 .children(self.render_panel_header(window, cx))
5477 .map(|this| {
5478 if let Some(repo) = self.active_repository.clone()
5479 && has_entries
5480 {
5481 this.child(self.render_entries(has_write_access, repo, window, cx))
5482 } else {
5483 this.child(self.render_empty_state(cx).into_any_element())
5484 }
5485 })
5486 .children(self.render_footer(window, cx))
5487 .when(self.amend_pending, |this| {
5488 this.child(self.render_pending_amend(cx))
5489 })
5490 .when(!self.amend_pending, |this| {
5491 this.children(self.render_previous_commit(cx))
5492 })
5493 .into_any_element(),
5494 )
5495 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
5496 deferred(
5497 anchored()
5498 .position(*position)
5499 .anchor(Corner::TopLeft)
5500 .child(menu.clone()),
5501 )
5502 .with_priority(1)
5503 }))
5504 }
5505}
5506
5507impl Focusable for GitPanel {
5508 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
5509 if self.entries.is_empty() {
5510 self.commit_editor.focus_handle(cx)
5511 } else {
5512 self.focus_handle.clone()
5513 }
5514 }
5515}
5516
5517impl EventEmitter<Event> for GitPanel {}
5518
5519impl EventEmitter<PanelEvent> for GitPanel {}
5520
5521pub(crate) struct GitPanelAddon {
5522 pub(crate) workspace: WeakEntity<Workspace>,
5523}
5524
5525impl editor::Addon for GitPanelAddon {
5526 fn to_any(&self) -> &dyn std::any::Any {
5527 self
5528 }
5529
5530 fn render_buffer_header_controls(
5531 &self,
5532 excerpt_info: &ExcerptInfo,
5533 window: &Window,
5534 cx: &App,
5535 ) -> Option<AnyElement> {
5536 let file = excerpt_info.buffer.file()?;
5537 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
5538
5539 git_panel
5540 .read(cx)
5541 .render_buffer_header_controls(&git_panel, file, window, cx)
5542 }
5543}
5544
5545impl Panel for GitPanel {
5546 fn persistent_name() -> &'static str {
5547 "GitPanel"
5548 }
5549
5550 fn panel_key() -> &'static str {
5551 GIT_PANEL_KEY
5552 }
5553
5554 fn position(&self, _: &Window, cx: &App) -> DockPosition {
5555 GitPanelSettings::get_global(cx).dock
5556 }
5557
5558 fn position_is_valid(&self, position: DockPosition) -> bool {
5559 matches!(position, DockPosition::Left | DockPosition::Right)
5560 }
5561
5562 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
5563 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
5564 settings.git_panel.get_or_insert_default().dock = Some(position.into())
5565 });
5566 }
5567
5568 fn size(&self, _: &Window, cx: &App) -> Pixels {
5569 self.width
5570 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
5571 }
5572
5573 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
5574 self.width = size;
5575 self.serialize(cx);
5576 cx.notify();
5577 }
5578
5579 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
5580 Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
5581 }
5582
5583 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
5584 Some("Git Panel")
5585 }
5586
5587 fn toggle_action(&self) -> Box<dyn Action> {
5588 Box::new(ToggleFocus)
5589 }
5590
5591 fn activation_priority(&self) -> u32 {
5592 2
5593 }
5594}
5595
5596impl PanelHeader for GitPanel {}
5597
5598struct GitPanelMessageTooltip {
5599 commit_tooltip: Option<Entity<CommitTooltip>>,
5600}
5601
5602impl GitPanelMessageTooltip {
5603 fn new(
5604 git_panel: Entity<GitPanel>,
5605 sha: SharedString,
5606 repository: Entity<Repository>,
5607 window: &mut Window,
5608 cx: &mut App,
5609 ) -> Entity<Self> {
5610 let remote_url = repository.read(cx).default_remote_url();
5611 cx.new(|cx| {
5612 cx.spawn_in(window, async move |this, cx| {
5613 let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
5614 (
5615 git_panel.load_commit_details(sha.to_string(), cx),
5616 git_panel.workspace.clone(),
5617 )
5618 });
5619 let details = details.await?;
5620 let provider_registry = cx
5621 .update(|_, app| GitHostingProviderRegistry::default_global(app))
5622 .ok();
5623
5624 let commit_details = crate::commit_tooltip::CommitDetails {
5625 sha: details.sha.clone(),
5626 author_name: details.author_name.clone(),
5627 author_email: details.author_email.clone(),
5628 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
5629 message: Some(ParsedCommitMessage::parse(
5630 details.sha.to_string(),
5631 details.message.to_string(),
5632 remote_url.as_deref(),
5633 provider_registry,
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, false, 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}