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
3467 let enable_coauthors = self.render_co_authors(cx);
3468
3469 let editor_focus_handle = self.commit_editor.focus_handle(cx);
3470 let expand_tooltip_focus_handle = editor_focus_handle;
3471
3472 let branch = active_repository.read(cx).branch.clone();
3473 let head_commit = active_repository.read(cx).head_commit.clone();
3474
3475 let footer_size = px(32.);
3476 let gap = px(9.0);
3477 let max_height = panel_editor_style
3478 .text
3479 .line_height_in_pixels(window.rem_size())
3480 * MAX_PANEL_EDITOR_LINES
3481 + gap;
3482
3483 let git_panel = cx.entity();
3484 let display_name = SharedString::from(Arc::from(
3485 active_repository
3486 .read(cx)
3487 .display_name()
3488 .trim_end_matches("/"),
3489 ));
3490 let editor_is_long = self.commit_editor.update(cx, |editor, cx| {
3491 editor.max_point(cx).row().0 >= MAX_PANEL_EDITOR_LINES as u32
3492 });
3493
3494 let footer = v_flex()
3495 .child(PanelRepoFooter::new(
3496 display_name,
3497 branch,
3498 head_commit,
3499 Some(git_panel),
3500 ))
3501 .child(
3502 panel_editor_container(window, cx)
3503 .id("commit-editor-container")
3504 .relative()
3505 .w_full()
3506 .h(max_height + footer_size)
3507 .border_t_1()
3508 .border_color(cx.theme().colors().border)
3509 .cursor_text()
3510 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
3511 window.focus(&this.commit_editor.focus_handle(cx));
3512 }))
3513 .child(
3514 h_flex()
3515 .id("commit-footer")
3516 .border_t_1()
3517 .when(editor_is_long, |el| {
3518 el.border_color(cx.theme().colors().border_variant)
3519 })
3520 .absolute()
3521 .bottom_0()
3522 .left_0()
3523 .w_full()
3524 .px_2()
3525 .h(footer_size)
3526 .flex_none()
3527 .justify_between()
3528 .child(
3529 self.render_generate_commit_message_button(cx)
3530 .unwrap_or_else(|| div().into_any_element()),
3531 )
3532 .child(
3533 h_flex()
3534 .gap_0p5()
3535 .children(enable_coauthors)
3536 .child(self.render_commit_button(cx)),
3537 ),
3538 )
3539 .child(
3540 div()
3541 .pr_2p5()
3542 .on_action(|&editor::actions::MoveUp, _, cx| {
3543 cx.stop_propagation();
3544 })
3545 .on_action(|&editor::actions::MoveDown, _, cx| {
3546 cx.stop_propagation();
3547 })
3548 .child(EditorElement::new(&self.commit_editor, panel_editor_style)),
3549 )
3550 .child(
3551 h_flex()
3552 .absolute()
3553 .top_2()
3554 .right_2()
3555 .opacity(0.5)
3556 .hover(|this| this.opacity(1.0))
3557 .child(
3558 panel_icon_button("expand-commit-editor", IconName::Maximize)
3559 .icon_size(IconSize::Small)
3560 .size(ui::ButtonSize::Default)
3561 .tooltip(move |_window, cx| {
3562 Tooltip::for_action_in(
3563 "Open Commit Modal",
3564 &git::ExpandCommitEditor,
3565 &expand_tooltip_focus_handle,
3566 cx,
3567 )
3568 })
3569 .on_click(cx.listener({
3570 move |_, _, window, cx| {
3571 window.dispatch_action(
3572 git::ExpandCommitEditor.boxed_clone(),
3573 cx,
3574 )
3575 }
3576 })),
3577 ),
3578 ),
3579 );
3580
3581 Some(footer)
3582 }
3583
3584 fn render_commit_button(&self, cx: &mut Context<Self>) -> impl IntoElement {
3585 let (can_commit, tooltip) = self.configure_commit_button(cx);
3586 let title = self.commit_button_title();
3587 let commit_tooltip_focus_handle = self.commit_editor.focus_handle(cx);
3588 let amend = self.amend_pending();
3589 let signoff = self.signoff_enabled;
3590
3591 let label_color = if self.pending_commit.is_some() {
3592 Color::Disabled
3593 } else {
3594 Color::Default
3595 };
3596
3597 div()
3598 .id("commit-wrapper")
3599 .on_hover(cx.listener(move |this, hovered, _, cx| {
3600 this.show_placeholders =
3601 *hovered && !this.has_staged_changes() && !this.has_unstaged_conflicts();
3602 cx.notify()
3603 }))
3604 .child(SplitButton::new(
3605 ButtonLike::new_rounded_left(ElementId::Name(
3606 format!("split-button-left-{}", title).into(),
3607 ))
3608 .layer(ElevationIndex::ModalSurface)
3609 .size(ButtonSize::Compact)
3610 .child(
3611 Label::new(title)
3612 .size(LabelSize::Small)
3613 .color(label_color)
3614 .mr_0p5(),
3615 )
3616 .on_click({
3617 let git_panel = cx.weak_entity();
3618 move |_, window, cx| {
3619 telemetry::event!("Git Committed", source = "Git Panel");
3620 git_panel
3621 .update(cx, |git_panel, cx| {
3622 git_panel.commit_changes(
3623 CommitOptions { amend, signoff },
3624 window,
3625 cx,
3626 );
3627 })
3628 .ok();
3629 }
3630 })
3631 .disabled(!can_commit || self.modal_open)
3632 .tooltip({
3633 let handle = commit_tooltip_focus_handle.clone();
3634 move |_window, cx| {
3635 if can_commit {
3636 Tooltip::with_meta_in(
3637 tooltip,
3638 Some(if amend { &git::Amend } else { &git::Commit }),
3639 format!(
3640 "git commit{}{}",
3641 if amend { " --amend" } else { "" },
3642 if signoff { " --signoff" } else { "" }
3643 ),
3644 &handle.clone(),
3645 cx,
3646 )
3647 } else {
3648 Tooltip::simple(tooltip, cx)
3649 }
3650 }
3651 }),
3652 self.render_git_commit_menu(
3653 ElementId::Name(format!("split-button-right-{}", title).into()),
3654 Some(commit_tooltip_focus_handle),
3655 cx,
3656 )
3657 .into_any_element(),
3658 ))
3659 }
3660
3661 fn render_pending_amend(&self, cx: &mut Context<Self>) -> impl IntoElement {
3662 h_flex()
3663 .py_1p5()
3664 .px_2()
3665 .gap_1p5()
3666 .justify_between()
3667 .border_t_1()
3668 .border_color(cx.theme().colors().border.opacity(0.8))
3669 .child(
3670 div()
3671 .flex_grow()
3672 .overflow_hidden()
3673 .max_w(relative(0.85))
3674 .child(
3675 Label::new("This will update your most recent commit.")
3676 .size(LabelSize::Small)
3677 .truncate(),
3678 ),
3679 )
3680 .child(
3681 panel_button("Cancel")
3682 .size(ButtonSize::Default)
3683 .on_click(cx.listener(|this, _, _, cx| this.set_amend_pending(false, cx))),
3684 )
3685 }
3686
3687 fn render_previous_commit(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
3688 let active_repository = self.active_repository.as_ref()?;
3689 let branch = active_repository.read(cx).branch.as_ref()?;
3690 let commit = branch.most_recent_commit.as_ref()?.clone();
3691 let workspace = self.workspace.clone();
3692 let this = cx.entity();
3693
3694 Some(
3695 h_flex()
3696 .py_1p5()
3697 .px_2()
3698 .gap_1p5()
3699 .justify_between()
3700 .border_t_1()
3701 .border_color(cx.theme().colors().border.opacity(0.8))
3702 .child(
3703 div()
3704 .cursor_pointer()
3705 .overflow_hidden()
3706 .line_clamp(1)
3707 .child(
3708 Label::new(commit.subject.clone())
3709 .size(LabelSize::Small)
3710 .truncate(),
3711 )
3712 .id("commit-msg-hover")
3713 .on_click({
3714 let commit = commit.clone();
3715 let repo = active_repository.downgrade();
3716 move |_, window, cx| {
3717 CommitView::open(
3718 commit.sha.to_string(),
3719 repo.clone(),
3720 workspace.clone(),
3721 None,
3722 None,
3723 window,
3724 cx,
3725 );
3726 }
3727 })
3728 .hoverable_tooltip({
3729 let repo = active_repository.clone();
3730 move |window, cx| {
3731 GitPanelMessageTooltip::new(
3732 this.clone(),
3733 commit.sha.clone(),
3734 repo.clone(),
3735 window,
3736 cx,
3737 )
3738 .into()
3739 }
3740 }),
3741 )
3742 .when(commit.has_parent, |this| {
3743 let has_unstaged = self.has_unstaged_changes();
3744 this.child(
3745 panel_icon_button("undo", IconName::Undo)
3746 .icon_size(IconSize::XSmall)
3747 .icon_color(Color::Muted)
3748 .tooltip(move |_window, cx| {
3749 Tooltip::with_meta(
3750 "Uncommit",
3751 Some(&git::Uncommit),
3752 if has_unstaged {
3753 "git reset HEAD^ --soft"
3754 } else {
3755 "git reset HEAD^"
3756 },
3757 cx,
3758 )
3759 })
3760 .on_click(cx.listener(|this, _, window, cx| this.uncommit(window, cx))),
3761 )
3762 }),
3763 )
3764 }
3765
3766 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
3767 h_flex().h_full().flex_grow().justify_center().child(
3768 v_flex()
3769 .gap_2()
3770 .child(h_flex().w_full().justify_around().child(
3771 if self.active_repository.is_some() {
3772 "No changes to commit"
3773 } else {
3774 "No Git repositories"
3775 },
3776 ))
3777 .children({
3778 let worktree_count = self.project.read(cx).visible_worktrees(cx).count();
3779 (worktree_count > 0 && self.active_repository.is_none()).then(|| {
3780 h_flex().w_full().justify_around().child(
3781 panel_filled_button("Initialize Repository")
3782 .tooltip(Tooltip::for_action_title_in(
3783 "git init",
3784 &git::Init,
3785 &self.focus_handle,
3786 ))
3787 .on_click(move |_, _, cx| {
3788 cx.defer(move |cx| {
3789 cx.dispatch_action(&git::Init);
3790 })
3791 }),
3792 )
3793 })
3794 })
3795 .text_ui_sm(cx)
3796 .mx_auto()
3797 .text_color(Color::Placeholder.color(cx)),
3798 )
3799 }
3800
3801 fn render_buffer_header_controls(
3802 &self,
3803 entity: &Entity<Self>,
3804 file: &Arc<dyn File>,
3805 _: &Window,
3806 cx: &App,
3807 ) -> Option<AnyElement> {
3808 let repo = self.active_repository.as_ref()?.read(cx);
3809 let project_path = (file.worktree_id(cx), file.path().clone()).into();
3810 let repo_path = repo.project_path_to_repo_path(&project_path, cx)?;
3811 let ix = self.entry_by_path(&repo_path, cx)?;
3812 let entry = self.entries.get(ix)?;
3813
3814 let is_staging_or_staged = repo
3815 .pending_ops_for_path(&repo_path)
3816 .map(|ops| ops.staging() || ops.staged())
3817 .or_else(|| {
3818 repo.status_for_path(&repo_path)
3819 .and_then(|status| status.status.staging().as_bool())
3820 })
3821 .or_else(|| {
3822 entry
3823 .status_entry()
3824 .and_then(|entry| entry.staging.as_bool())
3825 });
3826
3827 let checkbox = Checkbox::new("stage-file", is_staging_or_staged.into())
3828 .disabled(!self.has_write_access(cx))
3829 .fill()
3830 .elevation(ElevationIndex::Surface)
3831 .on_click({
3832 let entry = entry.clone();
3833 let git_panel = entity.downgrade();
3834 move |_, window, cx| {
3835 git_panel
3836 .update(cx, |this, cx| {
3837 this.toggle_staged_for_entry(&entry, window, cx);
3838 cx.stop_propagation();
3839 })
3840 .ok();
3841 }
3842 });
3843 Some(
3844 h_flex()
3845 .id("start-slot")
3846 .text_lg()
3847 .child(checkbox)
3848 .on_mouse_down(MouseButton::Left, |_, _, cx| {
3849 // prevent the list item active state triggering when toggling checkbox
3850 cx.stop_propagation();
3851 })
3852 .into_any_element(),
3853 )
3854 }
3855
3856 fn render_entries(
3857 &self,
3858 has_write_access: bool,
3859 window: &mut Window,
3860 cx: &mut Context<Self>,
3861 ) -> impl IntoElement {
3862 let entry_count = self.entries.len();
3863
3864 v_flex()
3865 .flex_1()
3866 .size_full()
3867 .overflow_hidden()
3868 .relative()
3869 .child(
3870 h_flex()
3871 .flex_1()
3872 .size_full()
3873 .relative()
3874 .overflow_hidden()
3875 .child(
3876 uniform_list(
3877 "entries",
3878 entry_count,
3879 cx.processor(move |this, range: Range<usize>, window, cx| {
3880 let mut items = Vec::with_capacity(range.end - range.start);
3881
3882 for ix in range {
3883 match &this.entries.get(ix) {
3884 Some(GitListEntry::Status(entry)) => {
3885 items.push(this.render_entry(
3886 ix,
3887 entry,
3888 has_write_access,
3889 window,
3890 cx,
3891 ));
3892 }
3893 Some(GitListEntry::Header(header)) => {
3894 items.push(this.render_list_header(
3895 ix,
3896 header,
3897 has_write_access,
3898 window,
3899 cx,
3900 ));
3901 }
3902 None => {}
3903 }
3904 }
3905
3906 items
3907 }),
3908 )
3909 .size_full()
3910 .flex_grow()
3911 .with_sizing_behavior(ListSizingBehavior::Auto)
3912 .with_horizontal_sizing_behavior(
3913 ListHorizontalSizingBehavior::Unconstrained,
3914 )
3915 .with_width_from_item(self.max_width_item_index)
3916 .track_scroll(&self.scroll_handle),
3917 )
3918 .on_mouse_down(
3919 MouseButton::Right,
3920 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
3921 this.deploy_panel_context_menu(event.position, window, cx)
3922 }),
3923 )
3924 .custom_scrollbars(
3925 Scrollbars::for_settings::<GitPanelSettings>()
3926 .tracked_scroll_handle(&self.scroll_handle)
3927 .with_track_along(
3928 ScrollAxes::Horizontal,
3929 cx.theme().colors().panel_background,
3930 ),
3931 window,
3932 cx,
3933 ),
3934 )
3935 }
3936
3937 fn entry_label(&self, label: impl Into<SharedString>, color: Color) -> Label {
3938 Label::new(label.into()).color(color).single_line()
3939 }
3940
3941 fn list_item_height(&self) -> Rems {
3942 rems(1.75)
3943 }
3944
3945 fn render_list_header(
3946 &self,
3947 ix: usize,
3948 header: &GitHeaderEntry,
3949 _: bool,
3950 _: &Window,
3951 _: &Context<Self>,
3952 ) -> AnyElement {
3953 let id: ElementId = ElementId::Name(format!("header_{}", ix).into());
3954
3955 h_flex()
3956 .id(id)
3957 .h(self.list_item_height())
3958 .w_full()
3959 .items_end()
3960 .px(rems(0.75)) // ~12px
3961 .pb(rems(0.3125)) // ~ 5px
3962 .child(
3963 Label::new(header.title())
3964 .color(Color::Muted)
3965 .size(LabelSize::Small)
3966 .line_height_style(LineHeightStyle::UiLabel)
3967 .single_line(),
3968 )
3969 .into_any_element()
3970 }
3971
3972 pub fn load_commit_details(
3973 &self,
3974 sha: String,
3975 cx: &mut Context<Self>,
3976 ) -> Task<anyhow::Result<CommitDetails>> {
3977 let Some(repo) = self.active_repository.clone() else {
3978 return Task::ready(Err(anyhow::anyhow!("no active repo")));
3979 };
3980 repo.update(cx, |repo, cx| {
3981 let show = repo.show(sha);
3982 cx.spawn(async move |_, _| show.await?)
3983 })
3984 }
3985
3986 fn deploy_entry_context_menu(
3987 &mut self,
3988 position: Point<Pixels>,
3989 ix: usize,
3990 window: &mut Window,
3991 cx: &mut Context<Self>,
3992 ) {
3993 let Some(entry) = self.entries.get(ix).and_then(|e| e.status_entry()) else {
3994 return;
3995 };
3996 let stage_title = if entry.status.staging().is_fully_staged() {
3997 "Unstage File"
3998 } else {
3999 "Stage File"
4000 };
4001 let restore_title = if entry.status.is_created() {
4002 "Trash File"
4003 } else {
4004 "Restore File"
4005 };
4006 let context_menu = ContextMenu::build(window, cx, |context_menu, _, _| {
4007 let mut context_menu = 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
4012 if entry.status.is_created() {
4013 context_menu =
4014 context_menu.action("Add to .gitignore", git::AddToGitignore.boxed_clone())
4015 }
4016
4017 context_menu = context_menu
4018 .separator()
4019 .action("Open Diff", Confirm.boxed_clone())
4020 .action("Open File", SecondaryConfirm.boxed_clone());
4021
4022 if !entry.status.is_created() {
4023 context_menu = context_menu
4024 .separator()
4025 .action("File History", Box::new(git::FileHistory));
4026 }
4027
4028 context_menu
4029 });
4030 self.selected_entry = Some(ix);
4031 self.set_context_menu(context_menu, position, window, cx);
4032 }
4033
4034 fn deploy_panel_context_menu(
4035 &mut self,
4036 position: Point<Pixels>,
4037 window: &mut Window,
4038 cx: &mut Context<Self>,
4039 ) {
4040 let context_menu = git_panel_context_menu(
4041 self.focus_handle.clone(),
4042 GitMenuState {
4043 has_tracked_changes: self.has_tracked_changes(),
4044 has_staged_changes: self.has_staged_changes(),
4045 has_unstaged_changes: self.has_unstaged_changes(),
4046 has_new_changes: self.new_count > 0,
4047 sort_by_path: GitPanelSettings::get_global(cx).sort_by_path,
4048 has_stash_items: self.stash_entries.entries.len() > 0,
4049 },
4050 window,
4051 cx,
4052 );
4053 self.set_context_menu(context_menu, position, window, cx);
4054 }
4055
4056 fn set_context_menu(
4057 &mut self,
4058 context_menu: Entity<ContextMenu>,
4059 position: Point<Pixels>,
4060 window: &Window,
4061 cx: &mut Context<Self>,
4062 ) {
4063 let subscription = cx.subscribe_in(
4064 &context_menu,
4065 window,
4066 |this, _, _: &DismissEvent, window, cx| {
4067 if this.context_menu.as_ref().is_some_and(|context_menu| {
4068 context_menu.0.focus_handle(cx).contains_focused(window, cx)
4069 }) {
4070 cx.focus_self(window);
4071 }
4072 this.context_menu.take();
4073 cx.notify();
4074 },
4075 );
4076 self.context_menu = Some((context_menu, position, subscription));
4077 cx.notify();
4078 }
4079
4080 fn render_entry(
4081 &self,
4082 ix: usize,
4083 entry: &GitStatusEntry,
4084 has_write_access: bool,
4085 window: &Window,
4086 cx: &Context<Self>,
4087 ) -> AnyElement {
4088 let path_style = self.project.read(cx).path_style(cx);
4089 let git_path_style = ProjectSettings::get_global(cx).git.path_style;
4090 let display_name = entry.display_name(path_style);
4091
4092 let selected = self.selected_entry == Some(ix);
4093 let marked = self.marked_entries.contains(&ix);
4094 let status_style = GitPanelSettings::get_global(cx).status_style;
4095 let status = entry.status;
4096
4097 let has_conflict = status.is_conflicted();
4098 let is_modified = status.is_modified();
4099 let is_deleted = status.is_deleted();
4100
4101 let label_color = if status_style == StatusStyle::LabelColor {
4102 if has_conflict {
4103 Color::VersionControlConflict
4104 } else if is_modified {
4105 Color::VersionControlModified
4106 } else if is_deleted {
4107 // We don't want a bunch of red labels in the list
4108 Color::Disabled
4109 } else {
4110 Color::VersionControlAdded
4111 }
4112 } else {
4113 Color::Default
4114 };
4115
4116 let path_color = if status.is_deleted() {
4117 Color::Disabled
4118 } else {
4119 Color::Muted
4120 };
4121
4122 let id: ElementId = ElementId::Name(format!("entry_{}_{}", display_name, ix).into());
4123 let checkbox_wrapper_id: ElementId =
4124 ElementId::Name(format!("entry_{}_{}_checkbox_wrapper", display_name, ix).into());
4125 let checkbox_id: ElementId =
4126 ElementId::Name(format!("entry_{}_{}_checkbox", display_name, ix).into());
4127
4128 let active_repo = self
4129 .project
4130 .read(cx)
4131 .active_repository(cx)
4132 .expect("active repository must be set");
4133 let repo = active_repo.read(cx);
4134 // Checking for current staged/unstaged file status is a chained operation:
4135 // 1. first, we check for any pending operation recorded in repository
4136 // 2. if there are no pending ops either running or finished, we then ask the repository
4137 // for the most up-to-date file status read from disk - we do this since `entry` arg to this function `render_entry`
4138 // is likely to be staled, and may lead to weird artifacts in the form of subsecond auto-uncheck/check on
4139 // the checkbox's state (or flickering) which is undesirable.
4140 // 3. finally, if there is no info about this `entry` in the repo, we fall back to whatever status is encoded
4141 // in `entry` arg.
4142 let is_staging_or_staged = repo
4143 .pending_ops_for_path(&entry.repo_path)
4144 .map(|ops| ops.staging() || ops.staged())
4145 .or_else(|| {
4146 repo.status_for_path(&entry.repo_path)
4147 .and_then(|status| status.status.staging().as_bool())
4148 })
4149 .or_else(|| entry.staging.as_bool());
4150 let mut is_staged: ToggleState = is_staging_or_staged.into();
4151 if self.show_placeholders && !self.has_staged_changes() && !entry.status.is_created() {
4152 is_staged = ToggleState::Selected;
4153 }
4154
4155 let handle = cx.weak_entity();
4156
4157 let selected_bg_alpha = 0.08;
4158 let marked_bg_alpha = 0.12;
4159 let state_opacity_step = 0.04;
4160
4161 let base_bg = match (selected, marked) {
4162 (true, true) => cx
4163 .theme()
4164 .status()
4165 .info
4166 .alpha(selected_bg_alpha + marked_bg_alpha),
4167 (true, false) => cx.theme().status().info.alpha(selected_bg_alpha),
4168 (false, true) => cx.theme().status().info.alpha(marked_bg_alpha),
4169 _ => cx.theme().colors().ghost_element_background,
4170 };
4171
4172 let hover_bg = if selected {
4173 cx.theme()
4174 .status()
4175 .info
4176 .alpha(selected_bg_alpha + state_opacity_step)
4177 } else {
4178 cx.theme().colors().ghost_element_hover
4179 };
4180
4181 let active_bg = if selected {
4182 cx.theme()
4183 .status()
4184 .info
4185 .alpha(selected_bg_alpha + state_opacity_step * 2.0)
4186 } else {
4187 cx.theme().colors().ghost_element_active
4188 };
4189 h_flex()
4190 .id(id)
4191 .h(self.list_item_height())
4192 .w_full()
4193 .items_center()
4194 .border_1()
4195 .when(selected && self.focus_handle.is_focused(window), |el| {
4196 el.border_color(cx.theme().colors().border_focused)
4197 })
4198 .px(rems(0.75)) // ~12px
4199 .overflow_hidden()
4200 .flex_none()
4201 .gap_1p5()
4202 .bg(base_bg)
4203 .hover(|this| this.bg(hover_bg))
4204 .active(|this| this.bg(active_bg))
4205 .on_click({
4206 cx.listener(move |this, event: &ClickEvent, window, cx| {
4207 this.selected_entry = Some(ix);
4208 cx.notify();
4209 if event.modifiers().secondary() {
4210 this.open_file(&Default::default(), window, cx)
4211 } else {
4212 this.open_diff(&Default::default(), window, cx);
4213 this.focus_handle.focus(window);
4214 }
4215 })
4216 })
4217 .on_mouse_down(
4218 MouseButton::Right,
4219 move |event: &MouseDownEvent, window, cx| {
4220 // why isn't this happening automatically? we are passing MouseButton::Right to `on_mouse_down`?
4221 if event.button != MouseButton::Right {
4222 return;
4223 }
4224
4225 let Some(this) = handle.upgrade() else {
4226 return;
4227 };
4228 this.update(cx, |this, cx| {
4229 this.deploy_entry_context_menu(event.position, ix, window, cx);
4230 });
4231 cx.stop_propagation();
4232 },
4233 )
4234 .child(
4235 div()
4236 .id(checkbox_wrapper_id)
4237 .flex_none()
4238 .occlude()
4239 .cursor_pointer()
4240 .child(
4241 Checkbox::new(checkbox_id, is_staged)
4242 .disabled(!has_write_access)
4243 .fill()
4244 .elevation(ElevationIndex::Surface)
4245 .on_click_ext({
4246 let entry = entry.clone();
4247 let this = cx.weak_entity();
4248 move |_, click, window, cx| {
4249 this.update(cx, |this, cx| {
4250 if !has_write_access {
4251 return;
4252 }
4253 if click.modifiers().shift {
4254 this.stage_bulk(ix, cx);
4255 } else {
4256 this.toggle_staged_for_entry(
4257 &GitListEntry::Status(entry.clone()),
4258 window,
4259 cx,
4260 );
4261 }
4262 cx.stop_propagation();
4263 })
4264 .ok();
4265 }
4266 })
4267 .tooltip(move |_window, cx| {
4268 // If is_staging_or_staged is None, this implies the file was partially staged, and so
4269 // we allow the user to stage it in full by displaying `Stage` in the tooltip.
4270 let action = if is_staging_or_staged.unwrap_or(false) {
4271 "Unstage"
4272 } else {
4273 "Stage"
4274 };
4275 let tooltip_name = action.to_string();
4276
4277 Tooltip::for_action(tooltip_name, &ToggleStaged, cx)
4278 }),
4279 ),
4280 )
4281 .child(git_status_icon(status))
4282 .child(
4283 h_flex()
4284 .items_center()
4285 .flex_1()
4286 .child(h_flex().items_center().flex_1().map(|this| {
4287 self.path_formatted(
4288 this,
4289 entry.parent_dir(path_style),
4290 path_color,
4291 display_name,
4292 label_color,
4293 path_style,
4294 git_path_style,
4295 status.is_deleted(),
4296 )
4297 })),
4298 )
4299 .into_any_element()
4300 }
4301
4302 fn path_formatted(
4303 &self,
4304 parent: Div,
4305 directory: Option<String>,
4306 path_color: Color,
4307 file_name: String,
4308 label_color: Color,
4309 path_style: PathStyle,
4310 git_path_style: GitPathStyle,
4311 strikethrough: bool,
4312 ) -> Div {
4313 parent
4314 .when(git_path_style == GitPathStyle::FileNameFirst, |this| {
4315 this.child(
4316 self.entry_label(
4317 match directory.as_ref().is_none_or(|d| d.is_empty()) {
4318 true => file_name.clone(),
4319 false => format!("{file_name} "),
4320 },
4321 label_color,
4322 )
4323 .when(strikethrough, Label::strikethrough),
4324 )
4325 })
4326 .when_some(directory, |this, dir| {
4327 match (
4328 !dir.is_empty(),
4329 git_path_style == GitPathStyle::FileNameFirst,
4330 ) {
4331 (true, true) => this.child(
4332 self.entry_label(dir, path_color)
4333 .when(strikethrough, Label::strikethrough),
4334 ),
4335 (true, false) => this.child(
4336 self.entry_label(
4337 format!("{dir}{}", path_style.primary_separator()),
4338 path_color,
4339 )
4340 .when(strikethrough, Label::strikethrough),
4341 ),
4342 _ => this,
4343 }
4344 })
4345 .when(git_path_style == GitPathStyle::FilePathFirst, |this| {
4346 this.child(
4347 self.entry_label(file_name, label_color)
4348 .when(strikethrough, Label::strikethrough),
4349 )
4350 })
4351 }
4352
4353 fn has_write_access(&self, cx: &App) -> bool {
4354 !self.project.read(cx).is_read_only(cx)
4355 }
4356
4357 pub fn amend_pending(&self) -> bool {
4358 self.amend_pending
4359 }
4360
4361 pub fn set_amend_pending(&mut self, value: bool, cx: &mut Context<Self>) {
4362 if value && !self.amend_pending {
4363 let current_message = self.commit_message_buffer(cx).read(cx).text();
4364 self.original_commit_message = if current_message.trim().is_empty() {
4365 None
4366 } else {
4367 Some(current_message)
4368 };
4369 } else if !value && self.amend_pending {
4370 let message = self.original_commit_message.take().unwrap_or_default();
4371 self.commit_message_buffer(cx).update(cx, |buffer, cx| {
4372 let start = buffer.anchor_before(0);
4373 let end = buffer.anchor_after(buffer.len());
4374 buffer.edit([(start..end, message)], None, cx);
4375 });
4376 }
4377
4378 self.amend_pending = value;
4379 self.serialize(cx);
4380 cx.notify();
4381 }
4382
4383 pub fn signoff_enabled(&self) -> bool {
4384 self.signoff_enabled
4385 }
4386
4387 pub fn set_signoff_enabled(&mut self, value: bool, cx: &mut Context<Self>) {
4388 self.signoff_enabled = value;
4389 self.serialize(cx);
4390 cx.notify();
4391 }
4392
4393 pub fn toggle_signoff_enabled(
4394 &mut self,
4395 _: &Signoff,
4396 _window: &mut Window,
4397 cx: &mut Context<Self>,
4398 ) {
4399 self.set_signoff_enabled(!self.signoff_enabled, cx);
4400 }
4401
4402 pub async fn load(
4403 workspace: WeakEntity<Workspace>,
4404 mut cx: AsyncWindowContext,
4405 ) -> anyhow::Result<Entity<Self>> {
4406 let serialized_panel = match workspace
4407 .read_with(&cx, |workspace, _| Self::serialization_key(workspace))
4408 .ok()
4409 .flatten()
4410 {
4411 Some(serialization_key) => cx
4412 .background_spawn(async move { KEY_VALUE_STORE.read_kvp(&serialization_key) })
4413 .await
4414 .context("loading git panel")
4415 .log_err()
4416 .flatten()
4417 .map(|panel| serde_json::from_str::<SerializedGitPanel>(&panel))
4418 .transpose()
4419 .log_err()
4420 .flatten(),
4421 None => None,
4422 };
4423
4424 workspace.update_in(&mut cx, |workspace, window, cx| {
4425 let panel = GitPanel::new(workspace, window, cx);
4426
4427 if let Some(serialized_panel) = serialized_panel {
4428 panel.update(cx, |panel, cx| {
4429 panel.width = serialized_panel.width;
4430 panel.amend_pending = serialized_panel.amend_pending;
4431 panel.signoff_enabled = serialized_panel.signoff_enabled;
4432 cx.notify();
4433 })
4434 }
4435
4436 panel
4437 })
4438 }
4439
4440 fn stage_bulk(&mut self, mut index: usize, cx: &mut Context<'_, Self>) {
4441 let Some(op) = self.bulk_staging.as_ref() else {
4442 return;
4443 };
4444 let Some(mut anchor_index) = self.entry_by_path(&op.anchor, cx) else {
4445 return;
4446 };
4447 if let Some(entry) = self.entries.get(index)
4448 && let Some(entry) = entry.status_entry()
4449 {
4450 self.set_bulk_staging_anchor(entry.repo_path.clone(), cx);
4451 }
4452 if index < anchor_index {
4453 std::mem::swap(&mut index, &mut anchor_index);
4454 }
4455 let entries = self
4456 .entries
4457 .get(anchor_index..=index)
4458 .unwrap_or_default()
4459 .iter()
4460 .filter_map(|entry| entry.status_entry().cloned())
4461 .collect::<Vec<_>>();
4462 self.change_file_stage(true, entries, cx);
4463 }
4464
4465 fn set_bulk_staging_anchor(&mut self, path: RepoPath, cx: &mut Context<'_, GitPanel>) {
4466 let Some(repo) = self.active_repository.as_ref() else {
4467 return;
4468 };
4469 self.bulk_staging = Some(BulkStaging {
4470 repo_id: repo.read(cx).id,
4471 anchor: path,
4472 });
4473 }
4474
4475 pub(crate) fn toggle_amend_pending(&mut self, cx: &mut Context<Self>) {
4476 self.set_amend_pending(!self.amend_pending, cx);
4477 if self.amend_pending {
4478 self.load_last_commit_message_if_empty(cx);
4479 }
4480 }
4481}
4482
4483impl Render for GitPanel {
4484 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
4485 let project = self.project.read(cx);
4486 let has_entries = !self.entries.is_empty();
4487 let room = self
4488 .workspace
4489 .upgrade()
4490 .and_then(|workspace| workspace.read(cx).active_call()?.read(cx).room().cloned());
4491
4492 let has_write_access = self.has_write_access(cx);
4493
4494 let has_co_authors = room.is_some_and(|room| {
4495 self.load_local_committer(cx);
4496 let room = room.read(cx);
4497 room.remote_participants()
4498 .values()
4499 .any(|remote_participant| remote_participant.can_write())
4500 });
4501
4502 v_flex()
4503 .id("git_panel")
4504 .key_context(self.dispatch_context(window, cx))
4505 .track_focus(&self.focus_handle)
4506 .when(has_write_access && !project.is_read_only(cx), |this| {
4507 this.on_action(cx.listener(Self::toggle_staged_for_selected))
4508 .on_action(cx.listener(Self::stage_range))
4509 .on_action(cx.listener(GitPanel::commit))
4510 .on_action(cx.listener(GitPanel::amend))
4511 .on_action(cx.listener(GitPanel::toggle_signoff_enabled))
4512 .on_action(cx.listener(Self::stage_all))
4513 .on_action(cx.listener(Self::unstage_all))
4514 .on_action(cx.listener(Self::stage_selected))
4515 .on_action(cx.listener(Self::unstage_selected))
4516 .on_action(cx.listener(Self::restore_tracked_files))
4517 .on_action(cx.listener(Self::revert_selected))
4518 .on_action(cx.listener(Self::add_to_gitignore))
4519 .on_action(cx.listener(Self::clean_all))
4520 .on_action(cx.listener(Self::generate_commit_message_action))
4521 .on_action(cx.listener(Self::stash_all))
4522 .on_action(cx.listener(Self::stash_pop))
4523 })
4524 .on_action(cx.listener(Self::select_first))
4525 .on_action(cx.listener(Self::select_next))
4526 .on_action(cx.listener(Self::select_previous))
4527 .on_action(cx.listener(Self::select_last))
4528 .on_action(cx.listener(Self::close_panel))
4529 .on_action(cx.listener(Self::open_diff))
4530 .on_action(cx.listener(Self::open_file))
4531 .on_action(cx.listener(Self::file_history))
4532 .on_action(cx.listener(Self::focus_changes_list))
4533 .on_action(cx.listener(Self::focus_editor))
4534 .on_action(cx.listener(Self::expand_commit_editor))
4535 .when(has_write_access && has_co_authors, |git_panel| {
4536 git_panel.on_action(cx.listener(Self::toggle_fill_co_authors))
4537 })
4538 .on_action(cx.listener(Self::toggle_sort_by_path))
4539 .size_full()
4540 .overflow_hidden()
4541 .bg(cx.theme().colors().panel_background)
4542 .child(
4543 v_flex()
4544 .size_full()
4545 .children(self.render_panel_header(window, cx))
4546 .map(|this| {
4547 if has_entries {
4548 this.child(self.render_entries(has_write_access, window, cx))
4549 } else {
4550 this.child(self.render_empty_state(cx).into_any_element())
4551 }
4552 })
4553 .children(self.render_footer(window, cx))
4554 .when(self.amend_pending, |this| {
4555 this.child(self.render_pending_amend(cx))
4556 })
4557 .when(!self.amend_pending, |this| {
4558 this.children(self.render_previous_commit(cx))
4559 })
4560 .into_any_element(),
4561 )
4562 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
4563 deferred(
4564 anchored()
4565 .position(*position)
4566 .anchor(Corner::TopLeft)
4567 .child(menu.clone()),
4568 )
4569 .with_priority(1)
4570 }))
4571 }
4572}
4573
4574impl Focusable for GitPanel {
4575 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
4576 if self.entries.is_empty() {
4577 self.commit_editor.focus_handle(cx)
4578 } else {
4579 self.focus_handle.clone()
4580 }
4581 }
4582}
4583
4584impl EventEmitter<Event> for GitPanel {}
4585
4586impl EventEmitter<PanelEvent> for GitPanel {}
4587
4588pub(crate) struct GitPanelAddon {
4589 pub(crate) workspace: WeakEntity<Workspace>,
4590}
4591
4592impl editor::Addon for GitPanelAddon {
4593 fn to_any(&self) -> &dyn std::any::Any {
4594 self
4595 }
4596
4597 fn render_buffer_header_controls(
4598 &self,
4599 excerpt_info: &ExcerptInfo,
4600 window: &Window,
4601 cx: &App,
4602 ) -> Option<AnyElement> {
4603 let file = excerpt_info.buffer.file()?;
4604 let git_panel = self.workspace.upgrade()?.read(cx).panel::<GitPanel>(cx)?;
4605
4606 git_panel
4607 .read(cx)
4608 .render_buffer_header_controls(&git_panel, file, window, cx)
4609 }
4610}
4611
4612impl Panel for GitPanel {
4613 fn persistent_name() -> &'static str {
4614 "GitPanel"
4615 }
4616
4617 fn panel_key() -> &'static str {
4618 GIT_PANEL_KEY
4619 }
4620
4621 fn position(&self, _: &Window, cx: &App) -> DockPosition {
4622 GitPanelSettings::get_global(cx).dock
4623 }
4624
4625 fn position_is_valid(&self, position: DockPosition) -> bool {
4626 matches!(position, DockPosition::Left | DockPosition::Right)
4627 }
4628
4629 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
4630 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
4631 settings.git_panel.get_or_insert_default().dock = Some(position.into())
4632 });
4633 }
4634
4635 fn size(&self, _: &Window, cx: &App) -> Pixels {
4636 self.width
4637 .unwrap_or_else(|| GitPanelSettings::get_global(cx).default_width)
4638 }
4639
4640 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
4641 self.width = size;
4642 self.serialize(cx);
4643 cx.notify();
4644 }
4645
4646 fn icon(&self, _: &Window, cx: &App) -> Option<ui::IconName> {
4647 Some(ui::IconName::GitBranchAlt).filter(|_| GitPanelSettings::get_global(cx).button)
4648 }
4649
4650 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
4651 Some("Git Panel")
4652 }
4653
4654 fn toggle_action(&self) -> Box<dyn Action> {
4655 Box::new(ToggleFocus)
4656 }
4657
4658 fn activation_priority(&self) -> u32 {
4659 2
4660 }
4661}
4662
4663impl PanelHeader for GitPanel {}
4664
4665struct GitPanelMessageTooltip {
4666 commit_tooltip: Option<Entity<CommitTooltip>>,
4667}
4668
4669impl GitPanelMessageTooltip {
4670 fn new(
4671 git_panel: Entity<GitPanel>,
4672 sha: SharedString,
4673 repository: Entity<Repository>,
4674 window: &mut Window,
4675 cx: &mut App,
4676 ) -> Entity<Self> {
4677 cx.new(|cx| {
4678 cx.spawn_in(window, async move |this, cx| {
4679 let (details, workspace) = git_panel.update(cx, |git_panel, cx| {
4680 (
4681 git_panel.load_commit_details(sha.to_string(), cx),
4682 git_panel.workspace.clone(),
4683 )
4684 })?;
4685 let details = details.await?;
4686
4687 let commit_details = crate::commit_tooltip::CommitDetails {
4688 sha: details.sha.clone(),
4689 author_name: details.author_name.clone(),
4690 author_email: details.author_email.clone(),
4691 commit_time: OffsetDateTime::from_unix_timestamp(details.commit_timestamp)?,
4692 message: Some(ParsedCommitMessage {
4693 message: details.message,
4694 ..Default::default()
4695 }),
4696 };
4697
4698 this.update(cx, |this: &mut GitPanelMessageTooltip, cx| {
4699 this.commit_tooltip = Some(cx.new(move |cx| {
4700 CommitTooltip::new(commit_details, repository, workspace, cx)
4701 }));
4702 cx.notify();
4703 })
4704 })
4705 .detach();
4706
4707 Self {
4708 commit_tooltip: None,
4709 }
4710 })
4711 }
4712}
4713
4714impl Render for GitPanelMessageTooltip {
4715 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
4716 if let Some(commit_tooltip) = &self.commit_tooltip {
4717 commit_tooltip.clone().into_any_element()
4718 } else {
4719 gpui::Empty.into_any_element()
4720 }
4721 }
4722}
4723
4724#[derive(IntoElement, RegisterComponent)]
4725pub struct PanelRepoFooter {
4726 active_repository: SharedString,
4727 branch: Option<Branch>,
4728 head_commit: Option<CommitDetails>,
4729
4730 // Getting a GitPanel in previews will be difficult.
4731 //
4732 // For now just take an option here, and we won't bind handlers to buttons in previews.
4733 git_panel: Option<Entity<GitPanel>>,
4734}
4735
4736impl PanelRepoFooter {
4737 pub fn new(
4738 active_repository: SharedString,
4739 branch: Option<Branch>,
4740 head_commit: Option<CommitDetails>,
4741 git_panel: Option<Entity<GitPanel>>,
4742 ) -> Self {
4743 Self {
4744 active_repository,
4745 branch,
4746 head_commit,
4747 git_panel,
4748 }
4749 }
4750
4751 pub fn new_preview(active_repository: SharedString, branch: Option<Branch>) -> Self {
4752 Self {
4753 active_repository,
4754 branch,
4755 head_commit: None,
4756 git_panel: None,
4757 }
4758 }
4759}
4760
4761impl RenderOnce for PanelRepoFooter {
4762 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
4763 let project = self
4764 .git_panel
4765 .as_ref()
4766 .map(|panel| panel.read(cx).project.clone());
4767
4768 let repo = self
4769 .git_panel
4770 .as_ref()
4771 .and_then(|panel| panel.read(cx).active_repository.clone());
4772
4773 let single_repo = project
4774 .as_ref()
4775 .map(|project| project.read(cx).git_store().read(cx).repositories().len() == 1)
4776 .unwrap_or(true);
4777
4778 const MAX_BRANCH_LEN: usize = 16;
4779 const MAX_REPO_LEN: usize = 16;
4780 const LABEL_CHARACTER_BUDGET: usize = MAX_BRANCH_LEN + MAX_REPO_LEN;
4781 const MAX_SHORT_SHA_LEN: usize = 8;
4782
4783 let branch_name = self
4784 .branch
4785 .as_ref()
4786 .map(|branch| branch.name().to_owned())
4787 .or_else(|| {
4788 self.head_commit.as_ref().map(|commit| {
4789 commit
4790 .sha
4791 .chars()
4792 .take(MAX_SHORT_SHA_LEN)
4793 .collect::<String>()
4794 })
4795 })
4796 .unwrap_or_else(|| " (no branch)".to_owned());
4797 let show_separator = self.branch.is_some() || self.head_commit.is_some();
4798
4799 let active_repo_name = self.active_repository.clone();
4800
4801 let branch_actual_len = branch_name.len();
4802 let repo_actual_len = active_repo_name.len();
4803
4804 // ideally, show the whole branch and repo names but
4805 // when we can't, use a budget to allocate space between the two
4806 let (repo_display_len, branch_display_len) =
4807 if branch_actual_len + repo_actual_len <= LABEL_CHARACTER_BUDGET {
4808 (repo_actual_len, branch_actual_len)
4809 } else if branch_actual_len <= MAX_BRANCH_LEN {
4810 let repo_space = (LABEL_CHARACTER_BUDGET - branch_actual_len).min(MAX_REPO_LEN);
4811 (repo_space, branch_actual_len)
4812 } else if repo_actual_len <= MAX_REPO_LEN {
4813 let branch_space = (LABEL_CHARACTER_BUDGET - repo_actual_len).min(MAX_BRANCH_LEN);
4814 (repo_actual_len, branch_space)
4815 } else {
4816 (MAX_REPO_LEN, MAX_BRANCH_LEN)
4817 };
4818
4819 let truncated_repo_name = if repo_actual_len <= repo_display_len {
4820 active_repo_name.to_string()
4821 } else {
4822 util::truncate_and_trailoff(active_repo_name.trim_ascii(), repo_display_len)
4823 };
4824
4825 let truncated_branch_name = if branch_actual_len <= branch_display_len {
4826 branch_name
4827 } else {
4828 util::truncate_and_trailoff(branch_name.trim_ascii(), branch_display_len)
4829 };
4830
4831 let repo_selector_trigger = Button::new("repo-selector", truncated_repo_name)
4832 .size(ButtonSize::None)
4833 .label_size(LabelSize::Small)
4834 .color(Color::Muted);
4835
4836 let repo_selector = PopoverMenu::new("repository-switcher")
4837 .menu({
4838 let project = project;
4839 move |window, cx| {
4840 let project = project.clone()?;
4841 Some(cx.new(|cx| RepositorySelector::new(project, rems(16.), window, cx)))
4842 }
4843 })
4844 .trigger_with_tooltip(
4845 repo_selector_trigger.disabled(single_repo).truncate(true),
4846 Tooltip::text("Switch Active Repository"),
4847 )
4848 .anchor(Corner::BottomLeft)
4849 .into_any_element();
4850
4851 let branch_selector_button = Button::new("branch-selector", truncated_branch_name)
4852 .size(ButtonSize::None)
4853 .label_size(LabelSize::Small)
4854 .truncate(true)
4855 .on_click(|_, window, cx| {
4856 window.dispatch_action(zed_actions::git::Switch.boxed_clone(), cx);
4857 });
4858
4859 let branch_selector = PopoverMenu::new("popover-button")
4860 .menu(move |window, cx| Some(branch_picker::popover(repo.clone(), window, cx)))
4861 .trigger_with_tooltip(
4862 branch_selector_button,
4863 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Switch),
4864 )
4865 .anchor(Corner::BottomLeft)
4866 .offset(gpui::Point {
4867 x: px(0.0),
4868 y: px(-2.0),
4869 });
4870
4871 h_flex()
4872 .h(px(36.))
4873 .w_full()
4874 .px_2()
4875 .justify_between()
4876 .gap_1()
4877 .child(
4878 h_flex()
4879 .flex_1()
4880 .overflow_hidden()
4881 .gap_px()
4882 .child(
4883 Icon::new(IconName::GitBranchAlt)
4884 .size(IconSize::Small)
4885 .color(if single_repo {
4886 Color::Disabled
4887 } else {
4888 Color::Muted
4889 }),
4890 )
4891 .child(repo_selector)
4892 .when(show_separator, |this| {
4893 this.child(
4894 div()
4895 .text_sm()
4896 .text_color(cx.theme().colors().icon_muted.opacity(0.5))
4897 .child("/"),
4898 )
4899 })
4900 .child(branch_selector),
4901 )
4902 .children(if let Some(git_panel) = self.git_panel {
4903 git_panel.update(cx, |git_panel, cx| git_panel.render_remote_button(cx))
4904 } else {
4905 None
4906 })
4907 }
4908}
4909
4910impl Component for PanelRepoFooter {
4911 fn scope() -> ComponentScope {
4912 ComponentScope::VersionControl
4913 }
4914
4915 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
4916 let unknown_upstream = None;
4917 let no_remote_upstream = Some(UpstreamTracking::Gone);
4918 let ahead_of_upstream = Some(
4919 UpstreamTrackingStatus {
4920 ahead: 2,
4921 behind: 0,
4922 }
4923 .into(),
4924 );
4925 let behind_upstream = Some(
4926 UpstreamTrackingStatus {
4927 ahead: 0,
4928 behind: 2,
4929 }
4930 .into(),
4931 );
4932 let ahead_and_behind_upstream = Some(
4933 UpstreamTrackingStatus {
4934 ahead: 3,
4935 behind: 1,
4936 }
4937 .into(),
4938 );
4939
4940 let not_ahead_or_behind_upstream = Some(
4941 UpstreamTrackingStatus {
4942 ahead: 0,
4943 behind: 0,
4944 }
4945 .into(),
4946 );
4947
4948 fn branch(upstream: Option<UpstreamTracking>) -> Branch {
4949 Branch {
4950 is_head: true,
4951 ref_name: "some-branch".into(),
4952 upstream: upstream.map(|tracking| Upstream {
4953 ref_name: "origin/some-branch".into(),
4954 tracking,
4955 }),
4956 most_recent_commit: Some(CommitSummary {
4957 sha: "abc123".into(),
4958 subject: "Modify stuff".into(),
4959 commit_timestamp: 1710932954,
4960 author_name: "John Doe".into(),
4961 has_parent: true,
4962 }),
4963 }
4964 }
4965
4966 fn custom(branch_name: &str, upstream: Option<UpstreamTracking>) -> Branch {
4967 Branch {
4968 is_head: true,
4969 ref_name: branch_name.to_string().into(),
4970 upstream: upstream.map(|tracking| Upstream {
4971 ref_name: format!("zed/{}", branch_name).into(),
4972 tracking,
4973 }),
4974 most_recent_commit: Some(CommitSummary {
4975 sha: "abc123".into(),
4976 subject: "Modify stuff".into(),
4977 commit_timestamp: 1710932954,
4978 author_name: "John Doe".into(),
4979 has_parent: true,
4980 }),
4981 }
4982 }
4983
4984 fn active_repository(id: usize) -> SharedString {
4985 format!("repo-{}", id).into()
4986 }
4987
4988 let example_width = px(340.);
4989 Some(
4990 v_flex()
4991 .gap_6()
4992 .w_full()
4993 .flex_none()
4994 .children(vec![
4995 example_group_with_title(
4996 "Action Button States",
4997 vec![
4998 single_example(
4999 "No Branch",
5000 div()
5001 .w(example_width)
5002 .overflow_hidden()
5003 .child(PanelRepoFooter::new_preview(active_repository(1), None))
5004 .into_any_element(),
5005 ),
5006 single_example(
5007 "Remote status unknown",
5008 div()
5009 .w(example_width)
5010 .overflow_hidden()
5011 .child(PanelRepoFooter::new_preview(
5012 active_repository(2),
5013 Some(branch(unknown_upstream)),
5014 ))
5015 .into_any_element(),
5016 ),
5017 single_example(
5018 "No Remote Upstream",
5019 div()
5020 .w(example_width)
5021 .overflow_hidden()
5022 .child(PanelRepoFooter::new_preview(
5023 active_repository(3),
5024 Some(branch(no_remote_upstream)),
5025 ))
5026 .into_any_element(),
5027 ),
5028 single_example(
5029 "Not Ahead or Behind",
5030 div()
5031 .w(example_width)
5032 .overflow_hidden()
5033 .child(PanelRepoFooter::new_preview(
5034 active_repository(4),
5035 Some(branch(not_ahead_or_behind_upstream)),
5036 ))
5037 .into_any_element(),
5038 ),
5039 single_example(
5040 "Behind remote",
5041 div()
5042 .w(example_width)
5043 .overflow_hidden()
5044 .child(PanelRepoFooter::new_preview(
5045 active_repository(5),
5046 Some(branch(behind_upstream)),
5047 ))
5048 .into_any_element(),
5049 ),
5050 single_example(
5051 "Ahead of remote",
5052 div()
5053 .w(example_width)
5054 .overflow_hidden()
5055 .child(PanelRepoFooter::new_preview(
5056 active_repository(6),
5057 Some(branch(ahead_of_upstream)),
5058 ))
5059 .into_any_element(),
5060 ),
5061 single_example(
5062 "Ahead and behind remote",
5063 div()
5064 .w(example_width)
5065 .overflow_hidden()
5066 .child(PanelRepoFooter::new_preview(
5067 active_repository(7),
5068 Some(branch(ahead_and_behind_upstream)),
5069 ))
5070 .into_any_element(),
5071 ),
5072 ],
5073 )
5074 .grow()
5075 .vertical(),
5076 ])
5077 .children(vec![
5078 example_group_with_title(
5079 "Labels",
5080 vec![
5081 single_example(
5082 "Short Branch & Repo",
5083 div()
5084 .w(example_width)
5085 .overflow_hidden()
5086 .child(PanelRepoFooter::new_preview(
5087 SharedString::from("zed"),
5088 Some(custom("main", behind_upstream)),
5089 ))
5090 .into_any_element(),
5091 ),
5092 single_example(
5093 "Long Branch",
5094 div()
5095 .w(example_width)
5096 .overflow_hidden()
5097 .child(PanelRepoFooter::new_preview(
5098 SharedString::from("zed"),
5099 Some(custom(
5100 "redesign-and-update-git-ui-list-entry-style",
5101 behind_upstream,
5102 )),
5103 ))
5104 .into_any_element(),
5105 ),
5106 single_example(
5107 "Long Repo",
5108 div()
5109 .w(example_width)
5110 .overflow_hidden()
5111 .child(PanelRepoFooter::new_preview(
5112 SharedString::from("zed-industries-community-examples"),
5113 Some(custom("gpui", ahead_of_upstream)),
5114 ))
5115 .into_any_element(),
5116 ),
5117 single_example(
5118 "Long Repo & Branch",
5119 div()
5120 .w(example_width)
5121 .overflow_hidden()
5122 .child(PanelRepoFooter::new_preview(
5123 SharedString::from("zed-industries-community-examples"),
5124 Some(custom(
5125 "redesign-and-update-git-ui-list-entry-style",
5126 behind_upstream,
5127 )),
5128 ))
5129 .into_any_element(),
5130 ),
5131 single_example(
5132 "Uppercase Repo",
5133 div()
5134 .w(example_width)
5135 .overflow_hidden()
5136 .child(PanelRepoFooter::new_preview(
5137 SharedString::from("LICENSES"),
5138 Some(custom("main", ahead_of_upstream)),
5139 ))
5140 .into_any_element(),
5141 ),
5142 single_example(
5143 "Uppercase Branch",
5144 div()
5145 .w(example_width)
5146 .overflow_hidden()
5147 .child(PanelRepoFooter::new_preview(
5148 SharedString::from("zed"),
5149 Some(custom("update-README", behind_upstream)),
5150 ))
5151 .into_any_element(),
5152 ),
5153 ],
5154 )
5155 .grow()
5156 .vertical(),
5157 ])
5158 .into_any_element(),
5159 )
5160 }
5161}
5162
5163fn open_output(
5164 operation: impl Into<SharedString>,
5165 workspace: &mut Workspace,
5166 output: &str,
5167 window: &mut Window,
5168 cx: &mut Context<Workspace>,
5169) {
5170 let operation = operation.into();
5171 let buffer = cx.new(|cx| Buffer::local(output, cx));
5172 buffer.update(cx, |buffer, cx| {
5173 buffer.set_capability(language::Capability::ReadOnly, cx);
5174 });
5175 let editor = cx.new(|cx| {
5176 let mut editor = Editor::for_buffer(buffer, None, window, cx);
5177 editor.buffer().update(cx, |buffer, cx| {
5178 buffer.set_title(format!("Output from git {operation}"), cx);
5179 });
5180 editor.set_read_only(true);
5181 editor
5182 });
5183
5184 workspace.add_item_to_center(Box::new(editor), window, cx);
5185}
5186
5187pub(crate) fn show_error_toast(
5188 workspace: Entity<Workspace>,
5189 action: impl Into<SharedString>,
5190 e: anyhow::Error,
5191 cx: &mut App,
5192) {
5193 let action = action.into();
5194 let message = e.to_string().trim().to_string();
5195 if message
5196 .matches(git::repository::REMOTE_CANCELLED_BY_USER)
5197 .next()
5198 .is_some()
5199 { // Hide the cancelled by user message
5200 } else {
5201 workspace.update(cx, |workspace, cx| {
5202 let workspace_weak = cx.weak_entity();
5203 let toast = StatusToast::new(format!("git {} failed", action), cx, |this, _cx| {
5204 this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
5205 .action("View Log", move |window, cx| {
5206 let message = message.clone();
5207 let action = action.clone();
5208 workspace_weak
5209 .update(cx, move |workspace, cx| {
5210 open_output(action, workspace, &message, window, cx)
5211 })
5212 .ok();
5213 })
5214 });
5215 workspace.toggle_status_toast(toast, cx)
5216 });
5217 }
5218}
5219
5220#[cfg(test)]
5221mod tests {
5222 use git::{
5223 repository::repo_path,
5224 status::{StatusCode, UnmergedStatus, UnmergedStatusCode},
5225 };
5226 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
5227 use indoc::indoc;
5228 use project::FakeFs;
5229 use serde_json::json;
5230 use settings::SettingsStore;
5231 use theme::LoadThemes;
5232 use util::path;
5233 use util::rel_path::rel_path;
5234
5235 use super::*;
5236
5237 fn init_test(cx: &mut gpui::TestAppContext) {
5238 zlog::init_test();
5239
5240 cx.update(|cx| {
5241 let settings_store = SettingsStore::test(cx);
5242 cx.set_global(settings_store);
5243 theme::init(LoadThemes::JustBase, cx);
5244 editor::init(cx);
5245 crate::init(cx);
5246 });
5247 }
5248
5249 #[gpui::test]
5250 async fn test_entry_worktree_paths(cx: &mut TestAppContext) {
5251 init_test(cx);
5252 let fs = FakeFs::new(cx.background_executor.clone());
5253 fs.insert_tree(
5254 "/root",
5255 json!({
5256 "zed": {
5257 ".git": {},
5258 "crates": {
5259 "gpui": {
5260 "gpui.rs": "fn main() {}"
5261 },
5262 "util": {
5263 "util.rs": "fn do_it() {}"
5264 }
5265 }
5266 },
5267 }),
5268 )
5269 .await;
5270
5271 fs.set_status_for_repo(
5272 Path::new(path!("/root/zed/.git")),
5273 &[
5274 ("crates/gpui/gpui.rs", StatusCode::Modified.worktree()),
5275 ("crates/util/util.rs", StatusCode::Modified.worktree()),
5276 ],
5277 );
5278
5279 let project =
5280 Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await;
5281 let workspace =
5282 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5283 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5284
5285 cx.read(|cx| {
5286 project
5287 .read(cx)
5288 .worktrees(cx)
5289 .next()
5290 .unwrap()
5291 .read(cx)
5292 .as_local()
5293 .unwrap()
5294 .scan_complete()
5295 })
5296 .await;
5297
5298 cx.executor().run_until_parked();
5299
5300 let panel = workspace.update(cx, GitPanel::new).unwrap();
5301
5302 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5303 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5304 });
5305 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5306 handle.await;
5307
5308 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5309 pretty_assertions::assert_eq!(
5310 entries,
5311 [
5312 GitListEntry::Header(GitHeaderEntry {
5313 header: Section::Tracked
5314 }),
5315 GitListEntry::Status(GitStatusEntry {
5316 repo_path: repo_path("crates/gpui/gpui.rs"),
5317 status: StatusCode::Modified.worktree(),
5318 staging: StageStatus::Unstaged,
5319 }),
5320 GitListEntry::Status(GitStatusEntry {
5321 repo_path: repo_path("crates/util/util.rs"),
5322 status: StatusCode::Modified.worktree(),
5323 staging: StageStatus::Unstaged,
5324 },),
5325 ],
5326 );
5327
5328 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5329 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5330 });
5331 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5332 handle.await;
5333 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5334 pretty_assertions::assert_eq!(
5335 entries,
5336 [
5337 GitListEntry::Header(GitHeaderEntry {
5338 header: Section::Tracked
5339 }),
5340 GitListEntry::Status(GitStatusEntry {
5341 repo_path: repo_path("crates/gpui/gpui.rs"),
5342 status: StatusCode::Modified.worktree(),
5343 staging: StageStatus::Unstaged,
5344 }),
5345 GitListEntry::Status(GitStatusEntry {
5346 repo_path: repo_path("crates/util/util.rs"),
5347 status: StatusCode::Modified.worktree(),
5348 staging: StageStatus::Unstaged,
5349 },),
5350 ],
5351 );
5352 }
5353
5354 #[gpui::test]
5355 async fn test_bulk_staging(cx: &mut TestAppContext) {
5356 use GitListEntry::*;
5357
5358 init_test(cx);
5359 let fs = FakeFs::new(cx.background_executor.clone());
5360 fs.insert_tree(
5361 "/root",
5362 json!({
5363 "project": {
5364 ".git": {},
5365 "src": {
5366 "main.rs": "fn main() {}",
5367 "lib.rs": "pub fn hello() {}",
5368 "utils.rs": "pub fn util() {}"
5369 },
5370 "tests": {
5371 "test.rs": "fn test() {}"
5372 },
5373 "new_file.txt": "new content",
5374 "another_new.rs": "// new file",
5375 "conflict.txt": "conflicted content"
5376 }
5377 }),
5378 )
5379 .await;
5380
5381 fs.set_status_for_repo(
5382 Path::new(path!("/root/project/.git")),
5383 &[
5384 ("src/main.rs", StatusCode::Modified.worktree()),
5385 ("src/lib.rs", StatusCode::Modified.worktree()),
5386 ("tests/test.rs", StatusCode::Modified.worktree()),
5387 ("new_file.txt", FileStatus::Untracked),
5388 ("another_new.rs", FileStatus::Untracked),
5389 ("src/utils.rs", FileStatus::Untracked),
5390 (
5391 "conflict.txt",
5392 UnmergedStatus {
5393 first_head: UnmergedStatusCode::Updated,
5394 second_head: UnmergedStatusCode::Updated,
5395 }
5396 .into(),
5397 ),
5398 ],
5399 );
5400
5401 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5402 let workspace =
5403 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5404 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5405
5406 cx.read(|cx| {
5407 project
5408 .read(cx)
5409 .worktrees(cx)
5410 .next()
5411 .unwrap()
5412 .read(cx)
5413 .as_local()
5414 .unwrap()
5415 .scan_complete()
5416 })
5417 .await;
5418
5419 cx.executor().run_until_parked();
5420
5421 let panel = workspace.update(cx, GitPanel::new).unwrap();
5422
5423 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5424 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5425 });
5426 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5427 handle.await;
5428
5429 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5430 #[rustfmt::skip]
5431 pretty_assertions::assert_matches!(
5432 entries.as_slice(),
5433 &[
5434 Header(GitHeaderEntry { header: Section::Conflict }),
5435 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5436 Header(GitHeaderEntry { header: Section::Tracked }),
5437 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5438 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5439 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5440 Header(GitHeaderEntry { header: Section::New }),
5441 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5442 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5443 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5444 ],
5445 );
5446
5447 let second_status_entry = entries[3].clone();
5448 panel.update_in(cx, |panel, window, cx| {
5449 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
5450 });
5451
5452 panel.update_in(cx, |panel, window, cx| {
5453 panel.selected_entry = Some(7);
5454 panel.stage_range(&git::StageRange, window, cx);
5455 });
5456
5457 cx.read(|cx| {
5458 project
5459 .read(cx)
5460 .worktrees(cx)
5461 .next()
5462 .unwrap()
5463 .read(cx)
5464 .as_local()
5465 .unwrap()
5466 .scan_complete()
5467 })
5468 .await;
5469
5470 cx.executor().run_until_parked();
5471
5472 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5473 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5474 });
5475 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5476 handle.await;
5477
5478 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5479 #[rustfmt::skip]
5480 pretty_assertions::assert_matches!(
5481 entries.as_slice(),
5482 &[
5483 Header(GitHeaderEntry { header: Section::Conflict }),
5484 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5485 Header(GitHeaderEntry { header: Section::Tracked }),
5486 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5487 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5488 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5489 Header(GitHeaderEntry { header: Section::New }),
5490 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5491 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5492 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5493 ],
5494 );
5495
5496 let third_status_entry = entries[4].clone();
5497 panel.update_in(cx, |panel, window, cx| {
5498 panel.toggle_staged_for_entry(&third_status_entry, window, cx);
5499 });
5500
5501 panel.update_in(cx, |panel, window, cx| {
5502 panel.selected_entry = Some(9);
5503 panel.stage_range(&git::StageRange, window, cx);
5504 });
5505
5506 cx.read(|cx| {
5507 project
5508 .read(cx)
5509 .worktrees(cx)
5510 .next()
5511 .unwrap()
5512 .read(cx)
5513 .as_local()
5514 .unwrap()
5515 .scan_complete()
5516 })
5517 .await;
5518
5519 cx.executor().run_until_parked();
5520
5521 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5522 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5523 });
5524 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5525 handle.await;
5526
5527 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5528 #[rustfmt::skip]
5529 pretty_assertions::assert_matches!(
5530 entries.as_slice(),
5531 &[
5532 Header(GitHeaderEntry { header: Section::Conflict }),
5533 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5534 Header(GitHeaderEntry { header: Section::Tracked }),
5535 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5536 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5537 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5538 Header(GitHeaderEntry { header: Section::New }),
5539 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5540 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5541 Status(GitStatusEntry { staging: StageStatus::Staged, .. }),
5542 ],
5543 );
5544 }
5545
5546 #[gpui::test]
5547 async fn test_bulk_staging_with_sort_by_paths(cx: &mut TestAppContext) {
5548 use GitListEntry::*;
5549
5550 init_test(cx);
5551 let fs = FakeFs::new(cx.background_executor.clone());
5552 fs.insert_tree(
5553 "/root",
5554 json!({
5555 "project": {
5556 ".git": {},
5557 "src": {
5558 "main.rs": "fn main() {}",
5559 "lib.rs": "pub fn hello() {}",
5560 "utils.rs": "pub fn util() {}"
5561 },
5562 "tests": {
5563 "test.rs": "fn test() {}"
5564 },
5565 "new_file.txt": "new content",
5566 "another_new.rs": "// new file",
5567 "conflict.txt": "conflicted content"
5568 }
5569 }),
5570 )
5571 .await;
5572
5573 fs.set_status_for_repo(
5574 Path::new(path!("/root/project/.git")),
5575 &[
5576 ("src/main.rs", StatusCode::Modified.worktree()),
5577 ("src/lib.rs", StatusCode::Modified.worktree()),
5578 ("tests/test.rs", StatusCode::Modified.worktree()),
5579 ("new_file.txt", FileStatus::Untracked),
5580 ("another_new.rs", FileStatus::Untracked),
5581 ("src/utils.rs", FileStatus::Untracked),
5582 (
5583 "conflict.txt",
5584 UnmergedStatus {
5585 first_head: UnmergedStatusCode::Updated,
5586 second_head: UnmergedStatusCode::Updated,
5587 }
5588 .into(),
5589 ),
5590 ],
5591 );
5592
5593 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5594 let workspace =
5595 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5596 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5597
5598 cx.read(|cx| {
5599 project
5600 .read(cx)
5601 .worktrees(cx)
5602 .next()
5603 .unwrap()
5604 .read(cx)
5605 .as_local()
5606 .unwrap()
5607 .scan_complete()
5608 })
5609 .await;
5610
5611 cx.executor().run_until_parked();
5612
5613 let panel = workspace.update(cx, GitPanel::new).unwrap();
5614
5615 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5616 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5617 });
5618 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5619 handle.await;
5620
5621 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5622 #[rustfmt::skip]
5623 pretty_assertions::assert_matches!(
5624 entries.as_slice(),
5625 &[
5626 Header(GitHeaderEntry { header: Section::Conflict }),
5627 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5628 Header(GitHeaderEntry { header: Section::Tracked }),
5629 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5630 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5631 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5632 Header(GitHeaderEntry { header: Section::New }),
5633 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5634 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5635 Status(GitStatusEntry { staging: StageStatus::Unstaged, .. }),
5636 ],
5637 );
5638
5639 assert_entry_paths(
5640 &entries,
5641 &[
5642 None,
5643 Some("conflict.txt"),
5644 None,
5645 Some("src/lib.rs"),
5646 Some("src/main.rs"),
5647 Some("tests/test.rs"),
5648 None,
5649 Some("another_new.rs"),
5650 Some("new_file.txt"),
5651 Some("src/utils.rs"),
5652 ],
5653 );
5654
5655 let second_status_entry = entries[3].clone();
5656 panel.update_in(cx, |panel, window, cx| {
5657 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
5658 });
5659
5660 cx.update(|_window, cx| {
5661 SettingsStore::update_global(cx, |store, cx| {
5662 store.update_user_settings(cx, |settings| {
5663 settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
5664 })
5665 });
5666 });
5667
5668 panel.update_in(cx, |panel, window, cx| {
5669 panel.selected_entry = Some(7);
5670 panel.stage_range(&git::StageRange, window, cx);
5671 });
5672
5673 cx.read(|cx| {
5674 project
5675 .read(cx)
5676 .worktrees(cx)
5677 .next()
5678 .unwrap()
5679 .read(cx)
5680 .as_local()
5681 .unwrap()
5682 .scan_complete()
5683 })
5684 .await;
5685
5686 cx.executor().run_until_parked();
5687
5688 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5689 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5690 });
5691 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5692 handle.await;
5693
5694 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5695 #[rustfmt::skip]
5696 pretty_assertions::assert_matches!(
5697 entries.as_slice(),
5698 &[
5699 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
5700 Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
5701 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
5702 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
5703 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
5704 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
5705 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
5706 ],
5707 );
5708
5709 assert_entry_paths(
5710 &entries,
5711 &[
5712 Some("another_new.rs"),
5713 Some("conflict.txt"),
5714 Some("new_file.txt"),
5715 Some("src/lib.rs"),
5716 Some("src/main.rs"),
5717 Some("src/utils.rs"),
5718 Some("tests/test.rs"),
5719 ],
5720 );
5721
5722 let third_status_entry = entries[4].clone();
5723 panel.update_in(cx, |panel, window, cx| {
5724 panel.toggle_staged_for_entry(&third_status_entry, window, cx);
5725 });
5726
5727 panel.update_in(cx, |panel, window, cx| {
5728 panel.selected_entry = Some(9);
5729 panel.stage_range(&git::StageRange, window, cx);
5730 });
5731
5732 cx.read(|cx| {
5733 project
5734 .read(cx)
5735 .worktrees(cx)
5736 .next()
5737 .unwrap()
5738 .read(cx)
5739 .as_local()
5740 .unwrap()
5741 .scan_complete()
5742 })
5743 .await;
5744
5745 cx.executor().run_until_parked();
5746
5747 let handle = cx.update_window_entity(&panel, |panel, _, _| {
5748 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5749 });
5750 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
5751 handle.await;
5752
5753 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
5754 #[rustfmt::skip]
5755 pretty_assertions::assert_matches!(
5756 entries.as_slice(),
5757 &[
5758 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
5759 Status(GitStatusEntry { status: FileStatus::Unmerged(..), staging: StageStatus::Unstaged, .. }),
5760 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
5761 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
5762 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Staged, .. }),
5763 Status(GitStatusEntry { status: FileStatus::Untracked, staging: StageStatus::Unstaged, .. }),
5764 Status(GitStatusEntry { status: FileStatus::Tracked(..), staging: StageStatus::Unstaged, .. }),
5765 ],
5766 );
5767
5768 assert_entry_paths(
5769 &entries,
5770 &[
5771 Some("another_new.rs"),
5772 Some("conflict.txt"),
5773 Some("new_file.txt"),
5774 Some("src/lib.rs"),
5775 Some("src/main.rs"),
5776 Some("src/utils.rs"),
5777 Some("tests/test.rs"),
5778 ],
5779 );
5780 }
5781
5782 #[gpui::test]
5783 async fn test_amend_commit_message_handling(cx: &mut TestAppContext) {
5784 init_test(cx);
5785 let fs = FakeFs::new(cx.background_executor.clone());
5786 fs.insert_tree(
5787 "/root",
5788 json!({
5789 "project": {
5790 ".git": {},
5791 "src": {
5792 "main.rs": "fn main() {}"
5793 }
5794 }
5795 }),
5796 )
5797 .await;
5798
5799 fs.set_status_for_repo(
5800 Path::new(path!("/root/project/.git")),
5801 &[("src/main.rs", StatusCode::Modified.worktree())],
5802 );
5803
5804 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
5805 let workspace =
5806 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5807 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5808
5809 let panel = workspace.update(cx, GitPanel::new).unwrap();
5810
5811 // Test: User has commit message, enables amend (saves message), then disables (restores message)
5812 panel.update(cx, |panel, cx| {
5813 panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
5814 let start = buffer.anchor_before(0);
5815 let end = buffer.anchor_after(buffer.len());
5816 buffer.edit([(start..end, "Initial commit message")], None, cx);
5817 });
5818
5819 panel.set_amend_pending(true, cx);
5820 assert!(panel.original_commit_message.is_some());
5821
5822 panel.set_amend_pending(false, cx);
5823 let current_message = panel.commit_message_buffer(cx).read(cx).text();
5824 assert_eq!(current_message, "Initial commit message");
5825 assert!(panel.original_commit_message.is_none());
5826 });
5827
5828 // Test: User has empty commit message, enables amend, then disables (clears message)
5829 panel.update(cx, |panel, cx| {
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, "")], None, cx);
5834 });
5835
5836 panel.set_amend_pending(true, cx);
5837 assert!(panel.original_commit_message.is_none());
5838
5839 panel.commit_message_buffer(cx).update(cx, |buffer, cx| {
5840 let start = buffer.anchor_before(0);
5841 let end = buffer.anchor_after(buffer.len());
5842 buffer.edit([(start..end, "Previous commit message")], None, cx);
5843 });
5844
5845 panel.set_amend_pending(false, cx);
5846 let current_message = panel.commit_message_buffer(cx).read(cx).text();
5847 assert_eq!(current_message, "");
5848 });
5849 }
5850
5851 #[gpui::test]
5852 async fn test_open_diff(cx: &mut TestAppContext) {
5853 init_test(cx);
5854
5855 let fs = FakeFs::new(cx.background_executor.clone());
5856 fs.insert_tree(
5857 path!("/project"),
5858 json!({
5859 ".git": {},
5860 "tracked": "tracked\n",
5861 "untracked": "\n",
5862 }),
5863 )
5864 .await;
5865
5866 fs.set_head_and_index_for_repo(
5867 path!("/project/.git").as_ref(),
5868 &[("tracked", "old tracked\n".into())],
5869 );
5870
5871 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
5872 let workspace =
5873 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
5874 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5875 let panel = workspace.update(cx, GitPanel::new).unwrap();
5876
5877 // Enable the `sort_by_path` setting and wait for entries to be updated,
5878 // as there should no longer be separators between Tracked and Untracked
5879 // files.
5880 cx.update(|_window, cx| {
5881 SettingsStore::update_global(cx, |store, cx| {
5882 store.update_user_settings(cx, |settings| {
5883 settings.git_panel.get_or_insert_default().sort_by_path = Some(true);
5884 })
5885 });
5886 });
5887
5888 cx.update_window_entity(&panel, |panel, _, _| {
5889 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
5890 })
5891 .await;
5892
5893 // Confirm that `Open Diff` still works for the untracked file, updating
5894 // the Project Diff's active path.
5895 panel.update_in(cx, |panel, window, cx| {
5896 panel.selected_entry = Some(1);
5897 panel.open_diff(&Confirm, window, cx);
5898 });
5899 cx.run_until_parked();
5900
5901 let _ = workspace.update(cx, |workspace, _window, cx| {
5902 let active_path = workspace
5903 .item_of_type::<ProjectDiff>(cx)
5904 .expect("ProjectDiff should exist")
5905 .read(cx)
5906 .active_path(cx)
5907 .expect("active_path should exist");
5908
5909 assert_eq!(active_path.path, rel_path("untracked").into_arc());
5910 });
5911 }
5912
5913 fn assert_entry_paths(entries: &[GitListEntry], expected_paths: &[Option<&str>]) {
5914 assert_eq!(entries.len(), expected_paths.len());
5915 for (entry, expected_path) in entries.iter().zip(expected_paths) {
5916 assert_eq!(
5917 entry.status_entry().map(|status| status
5918 .repo_path
5919 .as_ref()
5920 .as_std_path()
5921 .to_string_lossy()
5922 .to_string()),
5923 expected_path.map(|s| s.to_string())
5924 );
5925 }
5926 }
5927
5928 #[test]
5929 fn test_compress_diff_no_truncation() {
5930 let diff = indoc! {"
5931 --- a/file.txt
5932 +++ b/file.txt
5933 @@ -1,2 +1,2 @@
5934 -old
5935 +new
5936 "};
5937 let result = GitPanel::compress_commit_diff(diff, 1000);
5938 assert_eq!(result, diff);
5939 }
5940
5941 #[test]
5942 fn test_compress_diff_truncate_long_lines() {
5943 let long_line = "🦀".repeat(300);
5944 let diff = indoc::formatdoc! {"
5945 --- a/file.txt
5946 +++ b/file.txt
5947 @@ -1,2 +1,3 @@
5948 context
5949 +{}
5950 more context
5951 ", long_line};
5952 let result = GitPanel::compress_commit_diff(&diff, 100);
5953 assert!(result.contains("...[truncated]"));
5954 assert!(result.len() < diff.len());
5955 }
5956
5957 #[test]
5958 fn test_compress_diff_truncate_hunks() {
5959 let diff = indoc! {"
5960 --- a/file.txt
5961 +++ b/file.txt
5962 @@ -1,2 +1,2 @@
5963 context
5964 -old1
5965 +new1
5966 @@ -5,2 +5,2 @@
5967 context 2
5968 -old2
5969 +new2
5970 @@ -10,2 +10,2 @@
5971 context 3
5972 -old3
5973 +new3
5974 "};
5975 let result = GitPanel::compress_commit_diff(diff, 100);
5976 let expected = indoc! {"
5977 --- a/file.txt
5978 +++ b/file.txt
5979 @@ -1,2 +1,2 @@
5980 context
5981 -old1
5982 +new1
5983 [...skipped 2 hunks...]
5984 "};
5985 assert_eq!(result, expected);
5986 }
5987
5988 #[gpui::test]
5989 async fn test_suggest_commit_message(cx: &mut TestAppContext) {
5990 init_test(cx);
5991
5992 let fs = FakeFs::new(cx.background_executor.clone());
5993 fs.insert_tree(
5994 path!("/project"),
5995 json!({
5996 ".git": {},
5997 "tracked": "tracked\n",
5998 "untracked": "\n",
5999 }),
6000 )
6001 .await;
6002
6003 fs.set_head_and_index_for_repo(
6004 path!("/project/.git").as_ref(),
6005 &[("tracked", "old tracked\n".into())],
6006 );
6007
6008 let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await;
6009 let workspace =
6010 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
6011 let cx = &mut VisualTestContext::from_window(*workspace, cx);
6012 let panel = workspace.update(cx, GitPanel::new).unwrap();
6013
6014 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6015 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6016 });
6017 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6018 handle.await;
6019
6020 let entries = panel.read_with(cx, |panel, _| panel.entries.clone());
6021
6022 // GitPanel
6023 // - Tracked:
6024 // - [] tracked
6025 // - Untracked
6026 // - [] untracked
6027 //
6028 // The commit message should now read:
6029 // "Update tracked"
6030 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
6031 assert_eq!(message, Some("Update tracked".to_string()));
6032
6033 let first_status_entry = entries[1].clone();
6034 panel.update_in(cx, |panel, window, cx| {
6035 panel.toggle_staged_for_entry(&first_status_entry, window, cx);
6036 });
6037
6038 cx.read(|cx| {
6039 project
6040 .read(cx)
6041 .worktrees(cx)
6042 .next()
6043 .unwrap()
6044 .read(cx)
6045 .as_local()
6046 .unwrap()
6047 .scan_complete()
6048 })
6049 .await;
6050
6051 cx.executor().run_until_parked();
6052
6053 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6054 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6055 });
6056 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6057 handle.await;
6058
6059 // GitPanel
6060 // - Tracked:
6061 // - [x] tracked
6062 // - Untracked
6063 // - [] untracked
6064 //
6065 // The commit message should still read:
6066 // "Update tracked"
6067 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
6068 assert_eq!(message, Some("Update tracked".to_string()));
6069
6070 let second_status_entry = entries[3].clone();
6071 panel.update_in(cx, |panel, window, cx| {
6072 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6073 });
6074
6075 cx.read(|cx| {
6076 project
6077 .read(cx)
6078 .worktrees(cx)
6079 .next()
6080 .unwrap()
6081 .read(cx)
6082 .as_local()
6083 .unwrap()
6084 .scan_complete()
6085 })
6086 .await;
6087
6088 cx.executor().run_until_parked();
6089
6090 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6091 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6092 });
6093 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6094 handle.await;
6095
6096 // GitPanel
6097 // - Tracked:
6098 // - [x] tracked
6099 // - Untracked
6100 // - [x] untracked
6101 //
6102 // The commit message should now read:
6103 // "Enter commit message"
6104 // (which means we should see None returned).
6105 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
6106 assert!(message.is_none());
6107
6108 panel.update_in(cx, |panel, window, cx| {
6109 panel.toggle_staged_for_entry(&first_status_entry, window, cx);
6110 });
6111
6112 cx.read(|cx| {
6113 project
6114 .read(cx)
6115 .worktrees(cx)
6116 .next()
6117 .unwrap()
6118 .read(cx)
6119 .as_local()
6120 .unwrap()
6121 .scan_complete()
6122 })
6123 .await;
6124
6125 cx.executor().run_until_parked();
6126
6127 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6128 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6129 });
6130 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6131 handle.await;
6132
6133 // GitPanel
6134 // - Tracked:
6135 // - [] tracked
6136 // - Untracked
6137 // - [x] untracked
6138 //
6139 // The commit message should now read:
6140 // "Update untracked"
6141 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
6142 assert_eq!(message, Some("Create untracked".to_string()));
6143
6144 panel.update_in(cx, |panel, window, cx| {
6145 panel.toggle_staged_for_entry(&second_status_entry, window, cx);
6146 });
6147
6148 cx.read(|cx| {
6149 project
6150 .read(cx)
6151 .worktrees(cx)
6152 .next()
6153 .unwrap()
6154 .read(cx)
6155 .as_local()
6156 .unwrap()
6157 .scan_complete()
6158 })
6159 .await;
6160
6161 cx.executor().run_until_parked();
6162
6163 let handle = cx.update_window_entity(&panel, |panel, _, _| {
6164 std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(()))
6165 });
6166 cx.executor().advance_clock(2 * UPDATE_DEBOUNCE);
6167 handle.await;
6168
6169 // GitPanel
6170 // - Tracked:
6171 // - [] tracked
6172 // - Untracked
6173 // - [] untracked
6174 //
6175 // The commit message should now read:
6176 // "Update tracked"
6177 let message = panel.update(cx, |panel, cx| panel.suggest_commit_message(cx));
6178 assert_eq!(message, Some("Update tracked".to_string()));
6179 }
6180}