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