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