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