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