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